diff --git a/src/modules/sailing-calendar/pages/SailingCalendarView.vue b/src/modules/sailing-calendar/pages/SailingCalendarView.vue index 759793e6..40d28e32 100644 --- a/src/modules/sailing-calendar/pages/SailingCalendarView.vue +++ b/src/modules/sailing-calendar/pages/SailingCalendarView.vue @@ -7,12 +7,11 @@ import AdminPage from '@/shared/components/AdminPage.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' -import { getPlatformPriceBook, listPlatformPriceBooks } from '@/modules/fba-pricing' +import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { cancelSailing, copySailing, - createSailingChannel, createSailingTemplate, createSailing, generateSailingTemplate, @@ -23,12 +22,10 @@ import { listSailingCalendar, listSailingTemplates, previewSailingTemplateGeneration, - updateSailingChannel, updateSailingTemplate, updateSailing, } from '../api/platformSailingCalendar' -import type { PlatformPriceBookParsedChannel } from '@/modules/fba-pricing' -import type { SailingBookingAdvice, SailingCalendarEntry, SailingCalendarFilterOptions, SailingCalendarResponse, SailingChannelResource, SailingChannelSaveRequest, SailingChannelSourceType, SailingHistory, SailingSaveRequest, SailingStatus, SailingTemplateResource, SailingTemplateSaveRequest, SailingVolumeDetail } from '../types' +import type { SailingBookingAdvice, SailingCalendarEntry, SailingCalendarFilterOptions, SailingCalendarResponse, SailingChannelResource, SailingHistory, SailingSaveRequest, SailingStatus, SailingTemplateResource, SailingTemplateSaveRequest, SailingVolumeDetail } from '../types' interface CalendarCell { date: string; day: number; currentMonth: boolean; records: SailingCalendarEntry[] } @@ -62,11 +59,12 @@ const managementTab = ref<'channels' | 'templates'>('channels') const managementLoading = ref(false) const managementError = ref(null) const channels = ref([]) -const priceBookChannels = ref([]) -const channelDialogOpen = ref(false) -const channelSaving = ref(false) -const editingChannelId = ref('') -const channelForm = reactive({ channelCode: '', channelName: '', sourceType: 'SCHEDULE_ONLY', mappedChannelCode: '', defaultDeparturePortCode: '', defaultDestinationPortCode: '', defaultCutoffDays: 3, remark: '' }) +const warehouseOptions = ref([]) +const warehouseLoading = ref(false) +let warehouseRequest: Promise | null = null +const warehousesLoaded = ref(false) +const formOptionsLoading = ref(false) +const routeExpanded = ref([]) const templates = ref([]) const templateDialogOpen = ref(false) const templateSaving = ref(false) @@ -195,37 +193,53 @@ async function openManagement(tab: 'channels' | 'templates' = 'channels') { managementError.value = null managementLoading.value = true try { - const [channelRows, templateRows] = await Promise.all([listSailingChannels(), listSailingTemplates()]) + const [channelRows, templateRows] = await Promise.all([listSailingChannels(), listSailingTemplates(), ensureWarehouseOptions()]) channels.value = channelRows templates.value = templateRows } catch (cause) { managementError.value = getApiErrorDetails(cause, '渠道与周模板加载失败') } finally { managementLoading.value = false } } -async function toggleChannel(row: unknown) { - const channelRow = row as SailingChannelResource - try { - const updated = await updateSailingChannel(channelRow.id, { channelCode: channelRow.channelCode, channelName: channelRow.channelName, sourceType: channelRow.sourceType, mappedChannelCode: channelRow.mappedChannelCode, defaultDeparturePortCode: channelRow.defaultDeparturePortCode, defaultDestinationPortCode: channelRow.defaultDestinationPortCode, defaultCutoffDays: channelRow.defaultCutoffDays, remark: channelRow.remark, expectedVersion: channelRow.version, status: channelRow.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE' } as SailingChannelSaveRequest & { status: string }) - channelRow.status = updated.status - ElMessage.success(channelRow.status === 'ACTIVE' ? '渠道已启用' : '渠道已停用') - } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '渠道状态更新失败').message) } +async function ensureWarehouseOptions() { + if (warehousesLoaded.value) return + if (warehouseRequest) return warehouseRequest + warehouseLoading.value = true + warehouseRequest = listAllPlatformWarehouses({ warehouseType: 'DOMESTIC', status: 'ACTIVE' }) + .then((rows) => { warehouseOptions.value = rows; warehousesLoaded.value = true }) + .finally(() => { warehouseLoading.value = false; warehouseRequest = null }) + return warehouseRequest } -function openChannelCreate() { editingChannelId.value = ''; Object.assign(channelForm, { channelCode: '', channelName: '', sourceType: 'SCHEDULE_ONLY' as SailingChannelSourceType, mappedChannelCode: '', defaultDeparturePortCode: '', defaultDestinationPortCode: '', defaultCutoffDays: 3, remark: '', expectedVersion: undefined, status: undefined }); channelDialogOpen.value = true } -function openChannelEdit(row: unknown) { const channelRow = row as SailingChannelResource; editingChannelId.value = channelRow.id; Object.assign(channelForm, { channelCode: channelRow.channelCode, channelName: channelRow.channelName, sourceType: channelRow.sourceType, mappedChannelCode: channelRow.mappedChannelCode || '', defaultDeparturePortCode: channelRow.defaultDeparturePortCode || '', defaultDestinationPortCode: channelRow.defaultDestinationPortCode || '', defaultCutoffDays: channelRow.defaultCutoffDays, remark: channelRow.remark || '', expectedVersion: channelRow.version, status: channelRow.status }); channelDialogOpen.value = true } -async function saveChannel() { - if (!channelForm.channelCode.trim() || !channelForm.channelName.trim() || channelForm.defaultCutoffDays < 0) { ElMessage.error('请填写渠道编码、名称和有效的提前截止天数'); return } - channelSaving.value = true - try { - const response = editingChannelId.value ? await updateSailingChannel(editingChannelId.value, channelForm) : await createSailingChannel(channelForm) - if (editingChannelId.value) channels.value = channels.value.map((item) => item.id === response.id ? response : item) - else channels.value = [...channels.value, response] - channelDialogOpen.value = false - ElMessage.success(editingChannelId.value ? '渠道已更新' : '渠道已创建') - editingChannelId.value = '' - } catch (cause) { ElMessage.error(getApiErrorDetails(cause, editingChannelId.value ? '渠道更新失败' : '渠道创建失败').message) } finally { channelSaving.value = false } +function warehouseLabel(id: string) { + const warehouse = warehouseOptions.value.find((item) => String(item.id) === String(id)) + return warehouse ? `${warehouse.warehouseName}(${warehouse.warehouseCode})` : '未关联收货仓' } -function openTemplateCreate() { +function applyWarehouse(id: string, target: { warehouseId: string; warehouseName?: string | null }) { + target.warehouseId = String(id || '') + const warehouse = warehouseOptions.value.find((item) => String(item.id) === String(id)) + target.warehouseName = warehouse?.warehouseName || target.warehouseName || '' +} +function syncTemplateDerivedFields() { + const channel = channels.value.find((item) => item.id === String(templateForm.channelId)) + if (channel) { + templateForm.departurePortCode = templateForm.departurePortCode || channel.defaultDeparturePortCode || '' + templateForm.destinationPortCode = templateForm.destinationPortCode || channel.defaultDestinationPortCode || '' + templateForm.vesselName = templateForm.vesselName || '待订舱确认' + } + const warehouse = warehouseOptions.value.find((item) => String(item.id) === String(templateForm.warehouseId)) + if (warehouse) templateForm.warehouseName = warehouse.warehouseName +} +function onTemplateChannelChanged(id: string) { + const channel = channels.value.find((item) => item.id === String(id)) + if (!channel) return + templateForm.cutoffDays = channel.defaultCutoffDays + templateForm.departurePortCode = channel.defaultDeparturePortCode || '' + templateForm.destinationPortCode = channel.defaultDestinationPortCode || '' + templateForm.vesselName = '待订舱确认' +} +function onTemplateWarehouseChanged(id: string) { applyWarehouse(id, templateForm) } +async function openTemplateCreate() { + await ensureWarehouseOptions() editingTemplateId.value = '' const activeChannel = channels.value.find((item) => item.status === 'ACTIVE') - Object.assign(templateForm, { templateName: '', channelId: activeChannel?.id || '', warehouseId: '', warehouseName: '', weekdays: [1], effectiveFrom: formatDate(today), effectiveTo: '', departureTime: '10:00:00', cutoffDays: activeChannel?.defaultCutoffDays ?? 3, cutoffLocalTime: '18:00:00', voyageRule: '', vesselName: '', departurePortCode: '', destinationPortCode: '', remark: '', expectedVersion: undefined }) + Object.assign(templateForm, { templateName: '', channelId: activeChannel?.id || '', warehouseId: '', warehouseName: '', weekdays: [1], effectiveFrom: formatDate(today), effectiveTo: '', departureTime: '10:00:00', cutoffDays: activeChannel?.defaultCutoffDays ?? 3, cutoffLocalTime: '18:00:00', voyageRule: '', vesselName: '待订舱确认', departurePortCode: activeChannel?.defaultDeparturePortCode || '', destinationPortCode: activeChannel?.defaultDestinationPortCode || '', remark: '', expectedVersion: undefined }) templatePreview.value = [] resetGenerationRange(templateForm.effectiveFrom) templateDialogOpen.value = true @@ -238,8 +252,8 @@ function openTemplateEdit(row: unknown, options: { preserveGenerationRange?: boo if (!options.preserveGenerationRange) resetGenerationRange(templateForm.effectiveFrom) templateDialogOpen.value = true } -function templatePayload() { return { ...templateForm, effectiveTo: templateForm.effectiveTo || null, weekdays: [...templateForm.weekdays].sort((a, b) => a - b) } } -function validateTemplate() { if (!templateForm.channelId || !templateForm.warehouseId || !templateForm.weekdays.length || !templateForm.effectiveFrom || !templateForm.departureTime || templateForm.cutoffDays < 0 || !templateForm.vesselName?.trim() || !templateForm.departurePortCode?.trim() || !templateForm.destinationPortCode?.trim()) { ElMessage.error('请补全渠道、收货仓、周几、默认船名、起运港、目的港、有效期和截止天数'); return false } if (templateForm.cutoffDays === 0 && templateForm.cutoffLocalTime >= templateForm.departureTime) { ElMessage.error('同日收货截止时刻必须早于开船时间'); return false } return true } +function templatePayload() { syncTemplateDerivedFields(); return { ...templateForm, effectiveTo: templateForm.effectiveTo || null, weekdays: [...templateForm.weekdays].sort((a, b) => a - b), voyageRule: templateForm.voyageRule || null, vesselName: templateForm.vesselName || '待订舱确认', remark: templateForm.remark || null } } +function validateTemplate() { syncTemplateDerivedFields(); if (!templateForm.channelId || !templateForm.warehouseId || !templateForm.weekdays.length || !templateForm.effectiveFrom || !templateForm.departureTime || templateForm.cutoffDays < 0 || !templateForm.departurePortCode?.trim() || !templateForm.destinationPortCode?.trim()) { ElMessage.error('请补全渠道、收货仓、周几、有效期、开船时间、起运港和目的港'); return false } if (templateForm.cutoffDays === 0 && templateForm.cutoffLocalTime >= templateForm.departureTime) { ElMessage.error('同日收货截止时刻必须早于开船时间'); return false } return true } async function saveTemplate() { if (!validateTemplate()) return templateSaving.value = true @@ -283,11 +297,38 @@ async function confirmTemplateGeneration() { } finally { templateGenerateLoading.value = false } } function formatWeekdays(value: unknown) { return Array.isArray(value) ? value.map((day: unknown) => ['一', '二', '三', '四', '五', '六', '日'][Number(day) - 1]).join('、') : '—' } -async function onChannelChanged(value: string) { const channel = priceBookChannels.value.find((item) => item.channelCode === value); form.channelName = channel?.channelName || ''; form.channelId = null } -async function openCreate() { dialogMode.value = 'create'; cutoffMode.value = 'rule'; cutoffDays.value = 3; cutoffLocalTime.value = '18:00:00'; Object.assign(form, blankForm()); try { if (!priceBookChannels.value.length) { const versions = await listPlatformPriceBooks({ customerId: '0', status: 'ACTIVE' }); const latest = versions.sort((a, b) => (b.publishedAt || b.createdAt || '').localeCompare(a.publishedAt || a.createdAt || '') || b.versionNo - a.versionNo)[0]; if (latest) priceBookChannels.value = (await getPlatformPriceBook(latest.id)).channels } } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '价格表渠道加载失败').message) } dialogOpen.value = true } -function openEdit(record: SailingCalendarEntry) { dialogMode.value = 'edit'; cutoffMode.value = 'custom'; cutoffDays.value = 3; cutoffLocalTime.value = record.cutoffAt.slice(11, 19) || '18:00:00'; Object.assign(form, { templateId: record.templateId ?? null, channelId: record.channelId ?? null, warehouseId: record.warehouseId, warehouseName: record.warehouseName, channelCode: record.channelCode, channelName: record.channelName, vesselName: record.vesselName, voyageNo: record.voyageNo, departurePortCode: record.departurePortCode, departurePortName: record.departurePortName || '', destinationPortCode: record.destinationPortCode, destinationPortName: record.destinationPortName || '', etdAt: record.etdAt.slice(0, 19), etaAt: record.etaAt ?? null, cutoffAt: record.cutoffAt.slice(0, 19), sourceType: record.sourceType ?? 'MANUAL', plannedCapacityCbm: record.plannedCapacityCbm ?? null, remark: record.remark || '', expectedVersion: record.version }); dialogOpen.value = true } +function onChannelChanged(value: string) { + const channel = channels.value.find((item) => item.channelCode === value) + form.channelName = channel?.channelName || '' + form.channelId = channel?.id || null + form.departurePortCode = channel?.defaultDeparturePortCode || '' + form.destinationPortCode = channel?.defaultDestinationPortCode || '' + form.departurePortName = '' + form.destinationPortName = '' + form.vesselName = null + form.voyageNo = null + cutoffDays.value = channel?.defaultCutoffDays ?? 3 + syncCutoffFromRule() + routeExpanded.value = form.departurePortCode && form.destinationPortCode ? [] : ['route'] +} +async function openCreate() { + dialogMode.value = 'create' + cutoffMode.value = 'rule' + cutoffDays.value = 3 + cutoffLocalTime.value = '18:00:00' + Object.assign(form, blankForm(), { expectedVersion: undefined }) + routeExpanded.value = [] + dialogOpen.value = true + formOptionsLoading.value = true + try { + const [rows] = await Promise.all([listSailingChannels(), ensureWarehouseOptions()]) + channels.value = rows + } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '排船资料加载失败,请关闭后重试').message) } + finally { formOptionsLoading.value = false } +} +async function openEdit(record: SailingCalendarEntry) { try { const [rows] = await Promise.all([listSailingChannels(), ensureWarehouseOptions()]); channels.value = rows } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '排船资料加载失败').message); return } routeExpanded.value = []; dialogMode.value = 'edit'; cutoffMode.value = 'custom'; cutoffDays.value = 3; cutoffLocalTime.value = record.cutoffAt.slice(11, 19) || '18:00:00'; Object.assign(form, { templateId: record.templateId ?? null, channelId: record.channelId ?? null, warehouseId: record.warehouseId, warehouseName: record.warehouseName, channelCode: record.channelCode, channelName: record.channelName, vesselName: record.vesselName, voyageNo: record.voyageNo, departurePortCode: record.departurePortCode, departurePortName: record.departurePortName || '', destinationPortCode: record.destinationPortCode, destinationPortName: record.destinationPortName || '', etdAt: record.etdAt.slice(0, 19), etaAt: record.etaAt ?? null, cutoffAt: record.cutoffAt.slice(0, 19), sourceType: record.sourceType ?? 'MANUAL', plannedCapacityCbm: record.plannedCapacityCbm ?? null, remark: record.remark || '', expectedVersion: record.version }); dialogOpen.value = true } function onEtdChanged() { if (dialogMode.value === 'create' || cutoffMode.value === 'rule') syncCutoffFromRule() } -function validateForm() { const required: Array<[keyof SailingSaveRequest, string]> = [['warehouseId', '收货仓'], ['channelCode', '价格表渠道'], ['departurePortCode', '起运港'], ['destinationPortCode', '目的港'], ['etdAt', '开船时间'], ['cutoffAt', '截止时间']]; const missing = required.find(([key]) => !String(form[key] || '').trim()); if (missing) { ElMessage.error(`请填写${missing[1]}`); return false } if (new Date(form.cutoffAt).getTime() >= new Date(form.etdAt).getTime()) { ElMessage.error('收货截止时间必须早于预计开船时间'); return false } return true } +function validateForm() { const required: Array<[keyof SailingSaveRequest, string]> = [['warehouseId', '收货仓'], ['channelCode', '渠道'], ['departurePortCode', '起运港'], ['destinationPortCode', '目的港'], ['etdAt', '开船时间'], ['cutoffAt', '截止时间']]; const missing = required.find(([key]) => !String(form[key] || '').trim()); if (missing) { ElMessage.error(`请填写${missing[1]}`); return false } if (new Date(form.cutoffAt).getTime() >= new Date(form.etdAt).getTime()) { ElMessage.error('收货截止时间必须早于预计开船时间'); return false } return true } async function save() { if (!validateForm()) return saving.value = true @@ -380,15 +421,14 @@ onMounted(() => void load()) - 渠道名称和编码来自已解析的船期价格表。
按开船前 N 天自定义时间
提前天数2 天3 天4 天
截止时刻
建议截止{{ dateTime(form.cutoffAt) }}按开船本地日期倒推自然日
+ 选择已有渠道,自动带入排船默认资料。
{{ form.vesselName || '待订舱确认' }} / {{ form.voyageNo || '待订舱确认' }}
未确认船名、航次时可先排船,显示为待订舱确认。
按开船前 N 天自定义时间
提前天数2 天3 天4 天
截止时刻
建议截止{{ dateTime(form.cutoffAt) }}按开船本地日期倒推自然日
-
停用渠道仍保留历史船期,但不能新建模板或生成未来船期。新增渠道
按周几生成前先预览,生成操作使用服务端幂等键。新增周模板
- - 周{{ ['一', '二', '三', '四', '五', '六', '日'][day - 1] }}
2 天3 天4 天
快捷生成范围:未来 4 周未来 8 周未来 12 周

生成预览({{ templatePreview.length }} 条)

+
查看已有船期渠道及默认收货规则。
设置每周开船日和收货截止,预览后批量生成;重复日期自动跳过。新增周模板
+ 周{{ ['一', '二', '三', '四', '五', '六', '日'][day - 1] }}
2 天3 天4 天
快捷生成范围:未来 4 周未来 8 周未来 12 周

生成预览({{ templatePreview.length }} 条)