From 536308f3ec987a4bbe6e814a83f3b55f4a1bab71 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 21 Oct 2026 15:58:47 +0800 Subject: [PATCH] fix: align customs warehouse arrival with appointment calendar --- src/modules/appointment-calendar/schedule.ts | 10 +++---- .../api/containerArrivalTimes.test.ts | 14 ++++++++-- .../api/containerArrivalTimes.ts | 12 ++++++-- src/modules/container-loading/index.ts | 1 + ...nerDetailView.emptyReturn.behavior.test.ts | 10 ++++++- .../pages/ContainerDetailView.vue | 11 +++++++- .../pages/ContainersView.test.ts | 28 +++++++++++++++++-- .../pages/ContainersView.vue | 14 ++++++++-- src/modules/container-loading/types.ts | 1 + .../warehouseArrival.test.ts | 24 ++++++++++++++++ .../container-loading/warehouseArrival.ts | 20 +++++++++++++ 11 files changed, 129 insertions(+), 16 deletions(-) create mode 100644 src/modules/container-loading/warehouseArrival.test.ts create mode 100644 src/modules/container-loading/warehouseArrival.ts diff --git a/src/modules/appointment-calendar/schedule.ts b/src/modules/appointment-calendar/schedule.ts index 0f02b5bb..b3e2bccb 100644 --- a/src/modules/appointment-calendar/schedule.ts +++ b/src/modules/appointment-calendar/schedule.ts @@ -1,4 +1,4 @@ -import { addDays } from './calendar' +import { projectWarehouseArrival } from '@/modules/container-loading' import type { CalendarSources, ScheduleRange, WarehouseArrival } from './scheduleApi' export const scheduleTypes = ['arrival', 'dispatch', 'return', 'warehouse'] as const @@ -36,12 +36,12 @@ export function projectContainerSchedule(sources: CalendarSources, warehouses: W if (warehouses === null) continue const warehouse = warehouseMap.get(row.containerBookingId) warehouseMap.delete(row.containerBookingId) - const canDefault = !warehouse || warehouse.version === 0 && warehouse.planCount === 0 - const planned = warehouse?.plannedAt?.slice(0, 10) || (canDefault && date ? addDays(date, 2) : null) + const projected = projectWarehouseArrival(row.arrivalAt, warehouse) + const planned = projected.date if (inRange(planned, range)) events.push({ key: `warehouse-${row.containerBookingId}`, type: 'warehouse', date: planned, - time: warehouse?.plannedAt?.slice(11, 16) || '', title: `${label} 计划到仓`, sourcePath, warehouse, + time: projected.time, title: `${label} 计划到仓`, sourcePath, warehouse, canEdit: warehouse?.canEdit === true, - subtitle: warehouse?.plannedAt ? warehouse.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护' : canDefault ? '到港日期+2个自然日(默认)' : '柜内计划时间不一致,待核实', + subtitle: warehouse?.plannedAt ? warehouse.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护' : projected.isDefault ? '到港日期+2个自然日(默认)' : '柜内计划时间不一致,待核实', fields: [{ label: '集装箱号', value: row.containerNo }, { label: '接仓仓库', value: warehouse?.warehouseName || warehouse?.warehouseCode }, { label: '计划到仓', value: warehouse?.plannedAt ? timestamp(warehouse.plannedAt) : planned }, { label: '实际到仓', value: timestamp(warehouse?.actualAt || null) }] }) diff --git a/src/modules/container-loading/api/containerArrivalTimes.test.ts b/src/modules/container-loading/api/containerArrivalTimes.test.ts index e379e18e..4983df80 100644 --- a/src/modules/container-loading/api/containerArrivalTimes.test.ts +++ b/src/modules/container-loading/api/containerArrivalTimes.test.ts @@ -8,8 +8,8 @@ describe('container arrival-time overseas API', () => { beforeEach(() => vi.clearAllMocks()) it('queries visible containers and updates the whole-container expected arrival', async () => { - vi.mocked(http.post).mockResolvedValue({ code: '200', data: [{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0 }] }) - vi.mocked(http.put).mockResolvedValue({ code: '200', data: { sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: null, version: 1 } } as never) + vi.mocked(http.post).mockResolvedValue({ code: '200', data: [{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0 }] }) + vi.mocked(http.put).mockResolvedValue({ code: '200', data: { sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: null, version: 1, planCount: 0 } } as never) await listContainerArrivalTimes(['9001']) await updateContainerExpectedArrival('9001', { estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', expectedVersion: 0 }) @@ -17,4 +17,14 @@ describe('container arrival-time overseas API', () => { expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-arrival-times/query', { sourceContainerBookingIds: ['9001'] }) expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-summaries/9001/expected-arrival', { estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', expectedVersion: 0 }, { headers: { 'Idempotency-Key': expect.stringContaining('container-expected-arrival-9001-') } }) }) + + it('normalizes serialized plan counts and rejects unknown counts instead of showing a default', async () => { + const row = { sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0 } + vi.mocked(http.post).mockResolvedValue({ code: '200', data: [{ ...row, planCount: '1' }] }) + expect(await listContainerArrivalTimes(['9001'])).toEqual([{ ...row, planCount: 1 }]) + for (const planCount of [undefined, null, '', 'unknown', -1, 0.5]) { + vi.mocked(http.post).mockResolvedValue({ code: '200', data: [{ ...row, planCount }] }) + await expect(listContainerArrivalTimes(['9001'])).rejects.toThrow('入库计划数量无效') + } + }) }) diff --git a/src/modules/container-loading/api/containerArrivalTimes.ts b/src/modules/container-loading/api/containerArrivalTimes.ts index aedc9f82..c458b5bc 100644 --- a/src/modules/container-loading/api/containerArrivalTimes.ts +++ b/src/modules/container-loading/api/containerArrivalTimes.ts @@ -6,6 +6,14 @@ export interface ContainerArrivalTime { estimatedWarehouseArrivalAt: string | null actualWarehouseArrivalAt: string | null version: number + planCount: number +} + +function normalizeArrival(item: ContainerArrivalTime): ContainerArrivalTime { + if (typeof item.planCount !== 'number' && (typeof item.planCount !== 'string' || !/^(0|[1-9]\d*)$/.test(item.planCount))) throw new Error('到仓记录的入库计划数量无效,请刷新后重试') + const count = Number(item.planCount) + if (item.planCount == null || !Number.isSafeInteger(count) || count < 0) throw new Error('到仓记录的入库计划数量无效,请刷新后重试') + return { ...item, version: Number(item.version), planCount: count } } const queryPath = '/platform/v1/last-leg/orders/container-arrival-times/query' @@ -20,7 +28,7 @@ export async function listContainerArrivalTimes(sourceContainerBookingIds: strin queryPath, { sourceContainerBookingIds }, ) - return (unwrapApiResponse(response) || []).map((item) => ({ ...item, version: Number(item.version || 0) })) + return (unwrapApiResponse(response) || []).map(normalizeArrival) } export async function updateContainerExpectedArrival( @@ -33,5 +41,5 @@ export async function updateContainerExpectedArrival( { headers: { 'Idempotency-Key': operationId(sourceContainerBookingId) } }, ) const result = unwrapApiResponse(response) - return { ...result, version: Number(result.version || 0) } + return normalizeArrival(result) } diff --git a/src/modules/container-loading/index.ts b/src/modules/container-loading/index.ts index b37f139a..aaf8634f 100644 --- a/src/modules/container-loading/index.ts +++ b/src/modules/container-loading/index.ts @@ -1,5 +1,6 @@ export * from './api/platformContainerLoading' export * from './types' +export { projectWarehouseArrival } from './warehouseArrival' export { getSmartLoadingChannelGroup, isSeaRailChannel, SMART_LOADING_CHANNEL_GROUPS } from './smartLoadingChannelGroups' export { default as SmartLoadingDialog } from './components/SmartLoadingDialog.vue' export { default as ContainerSimulationCreate } from './components/ContainerSimulationCreate.vue' diff --git a/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts b/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts index 7562bf70..6970882d 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts +++ b/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts @@ -57,6 +57,14 @@ function accessibleText(wrapper: { element: Element } | undefined) { } describe('ContainerDetailView empty return', () => { + it('shows the same default warehouse date as the calendar, with no fabricated midnight', async () => { + vi.mocked(getContainer).mockResolvedValue({ ...base, estimatedPortArrivalAt: '2026-10-20T10:00:00', actualFacts: { arrivedDestinationPortAt: '2026-10-21T15:49:00' } } as never) + vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0 }]) + const wrapper = await mountDetail() + const value = wrapper.findAll('dt').find(item => item.text() === '预计到仓')!.element.nextElementSibling!.textContent + expect(value).toBe('2026-10-23(默认推算)') + expect(value).not.toContain('00:00') + }) it('passes the original container to schedule maintenance and reloads its projection after save', async () => { const wrapper = await mountDetail('DOMESTIC') const panel = wrapper.findComponent({ name: 'ContainerPortSchedulePanel' }) @@ -83,7 +91,7 @@ describe('ContainerDetailView empty return', () => { vi.mocked(getContainerOverview).mockReset().mockResolvedValue(overview as never) vi.mocked(getConfirmedLoadingList).mockReset().mockResolvedValue({ planNo: 'LP-1', summary: { status: 'COMPLETED' }, items: [] } as never) vi.mocked(recordTerminalDrayageEmptyReturn).mockReset().mockResolvedValue({ ...base.terminalDrayage, status: 'EMPTY_RETURNED', version: 4, actualEmptyReturnAt: '2026-09-09T12:00:00', allowedActions: [] } as never) - vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0 }]) + vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0, planCount: 0 }]) }) it('records a real direct-delivery source with explicit local time and proof, retaining the request after an unknown outcome', async () => { diff --git a/src/modules/container-loading/pages/ContainerDetailView.vue b/src/modules/container-loading/pages/ContainerDetailView.vue index 8d20e7b6..3c8b792f 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.vue +++ b/src/modules/container-loading/pages/ContainerDetailView.vue @@ -46,6 +46,7 @@ import { } from '../api/platformContainerLoading' import { recordTerminalDrayageEmptyReturn, getDirectDeliveries, recordDirectDelivery, getTerminalDrayage, selectTerminalDrayageFleet, scheduleTerminalDrayagePickup, recordTerminalPickup, type TerminalDrayageView, type DirectDelivery, type DirectDeliveryCommand } from '../api/terminalDrayages' import { listContainerArrivalTimes, type ContainerArrivalTime } from '../api/containerArrivalTimes' +import { projectWarehouseArrival } from '../warehouseArrival' import type { BusinessDocument, ContainerBookingResource, ContainerOverview, ContainerOverviewDocument, ContainerPlanStatus, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types' const props = withDefaults(defineProps<{ workbench?: 'DOMESTIC' | 'OVERSEAS' }>(), { workbench: 'DOMESTIC' }) @@ -91,6 +92,14 @@ async function rememberWorkbenchTab(tabName: string | number) { } const workbenchDetail = ref(null) const arrivalTime = ref(null) +const expectedArrivalText = computed(() => { + const row = workbenchDetail.value + const arrival = arrivalTime.value + const projected = projectWarehouseArrival(row?.actualFacts?.arrivedDestinationPortAt || row?.actualPortArrivalAt || row?.estimatedPortArrivalAt, + arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined) + return arrival?.estimatedWarehouseArrivalAt ? formatTime(arrival.estimatedWarehouseArrivalAt) + : projected.date ? `${projected.date}(默认推算)` : '—' +}) const hasAction = (action: ContainerWorkbenchAction) => Boolean(workbenchDetail.value?.allowedActions?.includes(action)) const canEdit = computed(() => isDomestic.value && access.can('platform:firstleg:container:edit') && hasAction('EDIT_CONTAINER')) const canManageCustoms = computed(() => isDomestic.value && access.can('platform:firstleg:customs:manage') && hasAction('MANAGE_CUSTOMS')) @@ -726,7 +735,7 @@ onMounted(async () => {

提柜、卸柜与还空

提柜安排与执行直送卸货清单确认还柜
-
码头拖车状态
{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}
码头车队
{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}
预计到仓
{{ formatTime(arrivalTime?.estimatedWarehouseArrivalAt) }}
实际到仓
{{ formatTime(arrivalTime?.actualWarehouseArrivalAt) }}
卸柜完成
{{ formatTime((workbenchDetail?.actualFacts?.unloadingCompletedAt || workbenchDetail?.terminalDrayage?.unloadingCompletedAt) as string | null) }}
实际还柜
{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}
+
码头拖车状态
{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}
码头车队
{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}
预计到仓
{{ expectedArrivalText }}
实际到仓
{{ formatTime(arrivalTime?.actualWarehouseArrivalAt) }}
卸柜完成
{{ formatTime((workbenchDetail?.actualFacts?.unloadingCompletedAt || workbenchDetail?.terminalDrayage?.unloadingCompletedAt) as string | null) }}
实际还柜
{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}
diff --git a/src/modules/container-loading/pages/ContainersView.test.ts b/src/modules/container-loading/pages/ContainersView.test.ts index 950f0632..db7f05df 100644 --- a/src/modules/container-loading/pages/ContainersView.test.ts +++ b/src/modules/container-loading/pages/ContainersView.test.ts @@ -74,8 +74,8 @@ describe('ContainersView', () => { vi.mocked(getTerminalDrayageHistory).mockResolvedValue([]) vi.mocked(selectTerminalDrayageFleet).mockResolvedValue({ ...drayage, fleetSupplierId: '502', fleetSupplierName: '新车队', version: 4, allowedActions: [...drayage.allowedActions] }) vi.mocked(recordTerminalDrayageEmptyReturn).mockReset().mockResolvedValue({ ...drayage, status: 'EMPTY_RETURNED', actualEmptyReturnAt: '2026-09-11T13:00:00', version: 4, emptyReturnWarning: { status: 'RESOLVED', deadlineAt: '2026-09-10T10:00:00', overdueHours: 0, overdueDays: 0 }, allowedActions: ['SELECT_FLEET'] }) - vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0 }]) - vi.mocked(updateContainerExpectedArrival).mockReset().mockResolvedValue({ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T16:00:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 1 }) + vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0, planCount: 0 }]) + vi.mocked(updateContainerExpectedArrival).mockReset().mockResolvedValue({ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T16:00:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 1, planCount: 0 }) vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER', 'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_EMPTY_RETURN'], terminalDrayage: drayage, oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 }) vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }) vi.mocked(getContainerOverview).mockResolvedValue({ container: { id: '9001', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'WAIT_LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: null, vesselName: null, version: 0 }, loadingPlanId: null, pda: null, documents: [], exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [] }) @@ -584,6 +584,30 @@ describe('ContainersView', () => { }) }) + it('shows the calendar default from actual port arrival without submitting an invented time', async () => { + const page = await listContainers({ page: 1, size: 20 }, 'OVERSEAS') + vi.mocked(listContainers).mockResolvedValue({ ...page, records: page.records.map(row => ({ ...row, + estimatedPortArrivalAt: '2026-10-20T10:00:00', actualFacts: { arrivedDestinationPortAt: '2026-10-21T15:49:00' }, + })) }) + vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0 }]) + const wrapper = await mountOverseasRoute() + const picker = wrapper.findAllComponents({ name: 'ElDatePicker' }).find(item => item.props('placeholder') === '2026-10-23(默认)')! + expect(picker).toBeDefined() + expect(picker.props('modelValue')).toBe('') + expect(updateContainerExpectedArrival).not.toHaveBeenCalled() + wrapper.unmount() + }) + + it('keeps a frozen missing arrival undated and prevents editing it', async () => { + const page = await listContainers({ page: 1, size: 20 }, 'OVERSEAS') + vi.mocked(listContainers).mockResolvedValue({ ...page, records: page.records.map(row => ({ ...row, estimatedPortArrivalAt: '2026-10-20T10:00:00' })) }) + vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 1 }]) + const wrapper = await mountOverseasRoute() + const picker = wrapper.findAllComponents({ name: 'ElDatePicker' }).find(item => item.props('placeholder') === '选择预计到仓')! + expect(picker.props('disabled')).toBe(true) + wrapper.unmount() + }) + it.each(['EDITING', 'LOADING', 'COMPLETED'] as const)('keeps released-booking linking reachable alongside the %s process action', async (planStatus) => { const page = await listContainers({ page: 1, size: 20 }, 'DOMESTIC') vi.mocked(listContainers).mockResolvedValue({ ...page, records: page.records.map((row) => ({ diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue index 22098c00..d1e35f71 100644 --- a/src/modules/container-loading/pages/ContainersView.vue +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -40,6 +40,7 @@ import { type TerminalDrayageView, } from '../api/terminalDrayages' import { listContainerArrivalTimes, updateContainerExpectedArrival, type ContainerArrivalTime } from '../api/containerArrivalTimes' +import { projectWarehouseArrival } from '../warehouseArrival' import type { ContainerBookingResource, ContainerDocumentStatus, ContainerPlanStatus, ContainerSaveRequest, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types' type Filters = QueryModel & { customerId: string; keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] } @@ -285,6 +286,13 @@ function optional(value: string | null) { return value?.trim() || null } function statusText(status: ContainerPlanStatus) { return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] } function documentStatusText(status: ContainerDocumentStatus | null | undefined) { return status ? ({ MISSING: '未上传', UPLOADED: '已上传', COMPLETED: '已完成' })[status] : '—' } function formatTime(value: string | null | undefined) { return value?.replace('T', ' ').slice(0, 16) || '—' } +function defaultArrivalDate(source: object) { + const row = source as ContainerWorkbenchRow + const arrival = arrivalTimes.value[row.id] + const projected = projectWarehouseArrival(row.actualFacts?.arrivedDestinationPortAt || row.actualPortArrivalAt || row.estimatedPortArrivalAt, + arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined) + return projected.isDefault ? projected.date : null +} function drayageFor(containerBookingId: string) { return drayages.value[containerBookingId] } function drayageStatusText(status?: string | null) { return ({ WAITING_FOR_ARRIVAL: '待到港', ARRIVED: '已到港', PICKUP_SCHEDULED: '已设置预计到仓', PICKED_UP: '已实际到仓', UNLOADED: '已卸柜', EMPTY_RETURNED: '已还柜' } as Record)[status || ''] || status || '—' } function drayageActionText(action: TerminalDrayageAction) { return ({ SELECT_FLEET: '选择码头车队', SCHEDULE_PICKUP: '修改预计到仓', RECORD_EMPTY_RETURN: '确认还柜', SEND_PICKUP_EMAIL: '发送提柜邮件' } as Record)[action] } @@ -577,7 +585,7 @@ async function saveDrayageAction() { } async function saveExpectedArrival(row: ContainerBookingResource, value: string | null) { const current = arrivalTimes.value[row.id] - if (!value || !current || current.version < 0 || arrivalSavingIds.value.includes(row.id)) return + if (!value || !current || current.version < 0 || current.planCount > 0 || arrivalSavingIds.value.includes(row.id)) return arrivalSavingIds.value.push(row.id) try { const updated = await updateContainerExpectedArrival(row.id, { @@ -760,7 +768,7 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) }) - + @@ -772,7 +780,7 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) }) - +
diff --git a/src/modules/container-loading/types.ts b/src/modules/container-loading/types.ts index 1fb043ea..c40aac10 100644 --- a/src/modules/container-loading/types.ts +++ b/src/modules/container-loading/types.ts @@ -224,6 +224,7 @@ export interface ContainerWorkbenchStatusSummary { } export interface ContainerWorkbenchActualFacts { + arrivedDestinationPortAt?: string | null actualSailingAt?: string | null actualPortArrivalAt?: string | null actualWarehouseArrivalAt?: string | null diff --git a/src/modules/container-loading/warehouseArrival.test.ts b/src/modules/container-loading/warehouseArrival.test.ts new file mode 100644 index 00000000..5a9200e6 --- /dev/null +++ b/src/modules/container-loading/warehouseArrival.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { projectWarehouseArrival } from './warehouseArrival' + +describe('shared warehouse arrival projection', () => { + it.each([ + ['2026-10-21T15:49:00', '2026-10-23'], + ['2026-10-30T23:30:00-07:00', '2026-11-01'], + ['2026-12-31T23:30:00', '2027-01-02'], + ['2026-03-07T23:30:00-08:00', '2026-03-09'], + ])('adds two local calendar days to %s without manufacturing a time', (arrivalAt, date) => { + expect(projectWarehouseArrival(arrivalAt, { plannedAt: null, version: 0, planCount: 0 })).toEqual({ date, time: '', isDefault: true }) + }) + it('keeps manually maintained and frozen dates instead of following port arrival', () => { + for (const plan of [{ plannedAt: '2026-10-28T09:30:00', version: 1, planCount: 0 }, { plannedAt: '2026-10-28T09:30:00', version: 0, planCount: 1 }]) { + expect(projectWarehouseArrival('2026-10-21T15:49:00', plan)).toEqual({ date: '2026-10-28', time: '09:30', isDefault: false }) + } + }) + it('does not replace inconsistent or frozen missing dates with defaults', () => { + for (const plan of [{ plannedAt: null, version: -1, planCount: 0 }, { plannedAt: null, version: 0, planCount: 1 }, { plannedAt: null, version: 1, planCount: 0 }]) { + expect(projectWarehouseArrival('2026-10-21T15:49:00', plan).date).toBeNull() + } + for (const arrival of [null, '', 'invalid', '2026-02-30T09:00:00']) expect(projectWarehouseArrival(arrival, undefined).date).toBeNull() + }) +}) diff --git a/src/modules/container-loading/warehouseArrival.ts b/src/modules/container-loading/warehouseArrival.ts new file mode 100644 index 00000000..417f1b9c --- /dev/null +++ b/src/modules/container-loading/warehouseArrival.ts @@ -0,0 +1,20 @@ +export interface WarehouseArrivalPlan { + plannedAt: string | null + version: number + planCount: number +} + +/** 默认值只有当地日期,不能作为人工维护的日期时间提交。 */ +export function projectWarehouseArrival(arrivalAt: string | null | undefined, plan: WarehouseArrivalPlan | undefined) { + const canDefault = !plan || plan.version === 0 && plan.planCount === 0 + let date = plan?.plannedAt?.slice(0, 10) || null + if (!date && canDefault && arrivalAt) { + const day = arrivalAt.slice(0, 10) + const calendarDate = new Date(`${day}T12:00:00Z`) + if (/^\d{4}-\d{2}-\d{2}$/.test(day) && !Number.isNaN(calendarDate.getTime()) && calendarDate.toISOString().slice(0, 10) === day) { + calendarDate.setUTCDate(calendarDate.getUTCDate() + 2) + date = calendarDate.toISOString().slice(0, 10) + } + } + return { date, time: plan?.plannedAt?.slice(11, 16) || '', isDefault: !plan?.plannedAt && canDefault } +}