diff --git a/src/modules/container-loading/index.ts b/src/modules/container-loading/index.ts index aaf8634f..5c0c3039 100644 --- a/src/modules/container-loading/index.ts +++ b/src/modules/container-loading/index.ts @@ -1,4 +1,5 @@ export * from './api/platformContainerLoading' +export { listContainerArrivalTimes, type ContainerArrivalTime } from './api/containerArrivalTimes' export * from './types' export { projectWarehouseArrival } from './warehouseArrival' export { getSmartLoadingChannelGroup, isSeaRailChannel, SMART_LOADING_CHANNEL_GROUPS } from './smartLoadingChannelGroups' diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts index 6f1b4e9b..03d58bc2 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts @@ -11,6 +11,7 @@ import DataTable from '@/shared/components/DataTable.vue' import AppDialog from '@/shared/components/AppDialog.vue' import QueryToolbar from '@/shared/components/QueryToolbar.vue' import { listAllPlatformWarehouses } from '@/modules/warehouses' +import { listContainerArrivalTimes } from '@/modules/container-loading' import { changeContainerReceivingWarehouse, createManualInboundPlan, @@ -35,6 +36,10 @@ vi.mock('@/shared/downloadCsv', () => ({ saveCsv: vi.fn() })) const routerPush = vi.hoisted(() => vi.fn()) vi.mock('vue-router', () => ({ useRouter: () => ({ push: routerPush }) })) vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() })) +vi.mock('@/modules/container-loading', async () => ({ + ...await import('../../container-loading/warehouseArrival'), + listContainerArrivalTimes: vi.fn(), +})) vi.mock('../api/lastMileOrders', () => ({ getReturnOrderStatistics: vi.fn(), listLastMileOrders: vi.fn(), listContainerPlanProgress: vi.fn(), getContainerPlanProgress: vi.fn(), @@ -54,6 +59,10 @@ function memoryStorage(): Storage { } const bookingId = '2092873463568597001' +const arrivalTime = { + sourceContainerBookingId: bookingId, estimatedWarehouseArrivalAt: '2026-09-01T08:00:00', + actualWarehouseArrivalAt: null, version: 1, planCount: 0, plannedDate: '2026-09-01', isDefault: false, +} const emptyReturnStatistics = { totals: { orderCount: 0, skuTypes: 0, expectedQuantity: 0, receivedQuantity: 0, remainingQuantity: 0, receivedBoxCount: 0, receivedParcelCount: 0, receivedPalletCount: 0 }, pageDimensions: [] } beforeEach(() => { vi.mocked(getReturnOrderStatistics).mockReset().mockResolvedValue(emptyReturnStatistics) @@ -171,6 +180,12 @@ async function selectRows(wrapper: Awaited>, rows: await wrapper.vm.$nextTick() } +/** 定向验证入库弹框,独立于列表页签和批量操作入口。 */ +async function openArrivalPlan(wrapper: Awaited>, source = selectedRow()) { + ;(wrapper.vm as unknown as { openInboundPlan(source: object): void }).openInboundPlan(source) + await flushPromises() +} + function button(wrapper: Awaited>, text: string) { return wrapper.findAll('button').find((item) => item.text().includes(text)) } @@ -359,6 +374,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { orders: [{ orderId: '2092873463568596994', customerId, orderNo: 'LMO-1', inboundCode: 'IN-1', orderStatus: 'INBOUND_PLANNED', planId: '2092873463568597010', planNo: 'LIP-1', planStatus: 'PENDING_RECEIVING', planValid: true, plannedArrivalAt: '2026-09-01T08:00:00' }], }) vi.mocked(createManualInboundPlan).mockReset().mockResolvedValue({} as never) + vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([arrivalTime]) vi.mocked(getContainerInboundPlanPreview).mockReset().mockResolvedValue(preview as never) vi.mocked(getLastMileOrderManualNodes).mockReset().mockResolvedValue({ orderId: '2092873463568596994', manualOverrideEnabled: false, nodes: [], version: 0 }) vi.mocked(updateLastMileOrderHold).mockReset().mockResolvedValue({ orderId: '2092873463568596994', held: true, chargeable: true, version: 1 }) @@ -627,11 +643,35 @@ describe('LastMileOrdersView pending order and container conversion', () => { expect(confirm).toBeUndefined() }) - it('blocks manual plan creation when the cabinet has no frozen expected arrival time', async () => { + it.each([ + [{ ...arrivalTime, estimatedWarehouseArrivalAt: '2026-10-25T09:30:00', plannedDate: '2026-10-25' }, '2026-10-25 09:30'], + [{ ...arrivalTime, estimatedWarehouseArrivalAt: null, version: 0, plannedDate: '2026-11-01', isDefault: true }, '2026-11-01(默认推算)'], + [{ ...arrivalTime, estimatedWarehouseArrivalAt: null, version: 0, planCount: 1, plannedDate: '2026-11-02' }, '2026-11-02'], + ])('reads the latest cabinet arrival on opening and permits planning with %s', async (arrival, expectedText) => { + vi.mocked(listContainerArrivalTimes).mockResolvedValue([arrival]) const wrapper = await openPending() - await selectRows(wrapper, [selectedRow({ estimatedWarehouseAt: '', warehouseEta: '—' })]) - await button(wrapper, '入库')!.trigger('click') + await openArrivalPlan(wrapper, arrival.estimatedWarehouseArrivalAt ? selectedRow() : selectedRow({ estimatedWarehouseAt: '', warehouseEta: '—' })) + + expect(listContainerArrivalTimes).toHaveBeenCalledWith([bookingId]) + const source = wrapper.get('[aria-label="入库来源"]') + expect(source.text()).toContain(expectedText) + if (!arrival.estimatedWarehouseArrivalAt) expect(source.text()).not.toContain('00:00') + const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))! + expect(dialog.props('confirmDisabled')).toBe(false) + dialog.vm.$emit('confirm') await flushPromises() + expect(createManualInboundPlan).toHaveBeenCalledTimes(1) + expect(vi.mocked(createManualInboundPlan).mock.calls[0][0]).not.toHaveProperty('plannedArrivalDate') + }) + + it.each([ + { arrivals: [] }, + { arrivals: [{ ...arrivalTime, estimatedWarehouseArrivalAt: null, plannedDate: null }] }, + { arrivals: [{ ...arrivalTime, version: -1 }] }, + ])('blocks manual planning when the authoritative cabinet arrival is missing or inconsistent: $arrivals', async ({ arrivals }) => { + vi.mocked(listContainerArrivalTimes).mockResolvedValue(arrivals) + const wrapper = await openPending() + await openArrivalPlan(wrapper) const confirm = [...document.body.querySelectorAll('button')] .find((item) => item.textContent?.includes('保存并生成入库单')) as HTMLButtonElement @@ -639,6 +679,37 @@ describe('LastMileOrdersView pending order and container conversion', () => { expect(createManualInboundPlan).not.toHaveBeenCalled() }) + it('shows arrival read failures and does not reuse the list timestamp', async () => { + vi.mocked(listContainerArrivalTimes).mockRejectedValue(new ApiResponseError('到仓来源暂不可读', 'A0503', 503)) + const wrapper = await openPending() + await openArrivalPlan(wrapper) + const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))! + expect(dialog.text()).toContain('到仓来源暂不可读') + expect(dialog.get('[aria-label="入库来源"]').text()).not.toContain('2026-09-01') + expect(dialog.props('confirmDisabled')).toBe(true) + dialog.vm.$emit('confirm'); await flushPromises() + expect(createManualInboundPlan).not.toHaveBeenCalled() + }) + + it('keeps confirmation blocked during arrival loading and ignores an older opening of the same order', async () => { + let resolveOld!: (value: typeof arrivalTime[]) => void + vi.mocked(listContainerArrivalTimes).mockReturnValueOnce(new Promise(resolve => { resolveOld = resolve })) + const wrapper = await openPending() + await openArrivalPlan(wrapper) + const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))! + expect(dialog.props('confirmDisabled')).toBe(true) + dialog.vm.$emit('confirm'); await flushPromises() + expect(createManualInboundPlan).not.toHaveBeenCalled() + dialog.vm.$emit('update:modelValue', false) + await wrapper.vm.$nextTick() + vi.mocked(listContainerArrivalTimes).mockResolvedValueOnce([{ ...arrivalTime, estimatedWarehouseArrivalAt: '2026-10-26T10:00:00', plannedDate: '2026-10-26' }]) + await openArrivalPlan(wrapper) + expect(dialog.get('[aria-label="入库来源"]').text()).toContain('2026-10-26 10:00') + resolveOld([arrivalTime]); await flushPromises() + expect(dialog.get('[aria-label="入库来源"]').text()).toContain('2026-10-26 10:00') + expect(dialog.props('confirmDisabled')).toBe(false) + }) + it('locks one-click dropship manual plans to the frozen source pallet count', async () => { vi.mocked(getContainerInboundPlanPreview).mockResolvedValue({ ...preview, diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue index 39b9f717..07ab23d6 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue @@ -7,6 +7,7 @@ import { ChevronDown, ChevronUp, Download, Pencil, PackagePlus, SlidersHorizonta import { ElMessage, ElMessageBox } from 'element-plus' import { useRouter } from 'vue-router' import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses' +import { listContainerArrivalTimes, projectWarehouseArrival, type ContainerArrivalTime } from '@/modules/container-loading' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import AdminPage from '@/shared/components/AdminPage.vue' import AppDialog from '@/shared/components/AppDialog.vue' @@ -110,12 +111,23 @@ const inboundPlanSourcePalletCount = ref(null) const inboundPlanPreviewLoading = ref(false) const inboundPlanPreviewError = ref('') const inboundPlanIsDropship = computed(() => actionTarget.value?.orderTypeCode === 'ONE_CLICK_DROPSHIP') -const inboundPlanSubmitDisabled = computed(() => !actionTarget.value?.estimatedWarehouseAt +const inboundPlanArrival = ref(null) +const inboundPlanArrivalLoading = ref(false) +const inboundPlanArrivalError = ref('') +let inboundPlanArrivalRequest = 0 +const inboundPlanArrivalProjection = computed(() => { + const arrival = inboundPlanArrival.value + return projectWarehouseArrival(arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined) +}) +const inboundPlanSubmitDisabled = computed(() => inboundPlanArrivalLoading.value || !inboundPlanArrivalProjection.value.date || (inboundPlanIsDropship.value && (inboundPlanPreviewLoading.value || inboundPlanSourcePalletCount.value == null || inboundPlanSourcePalletCount.value <= 0))) const inboundPlanForm = reactive({ inboundCode: '', palletCount: 1, palletType: 'WOOD' as 'STANDARD' | 'WOOD' | 'PAPER', assigned: false, mergeRequired: false, urgent: false, remark: '' }) -const inboundPlanExpectedArrival = computed(() => actionTarget.value?.warehouseEta || '—') +const inboundPlanExpectedArrival = computed(() => { + const { date, time, isDefault } = inboundPlanArrivalProjection.value + return date ? `${date}${time ? ` ${time}` : ''}${isDefault ? '(默认推算)' : ''}` : '—' +}) const holdOpen = ref(false) const holdForm = reactive({ held: true, chargeable: true, reason: '' }) const manualNodesOpen = ref(false) @@ -633,9 +645,37 @@ function openInboundPlan(source?: object) { inboundPlanPreviewError.value = '' Object.assign(inboundPlanForm, { inboundCode: target.inboundCode === '—' ? '' : target.inboundCode, palletCount: inboundPlanMinimumPalletCount.value, palletType: 'WOOD', assigned: false, mergeRequired: false, urgent: false, remark: '' }) actionError.value = null + inboundPlanArrival.value = null + inboundPlanArrivalError.value = '' inboundPlanOpen.value = true + void refreshInboundPlanArrival(target) void refreshManualPalletMinimum(target) } +/** 每次打开重新读取柜级权威日期,避免使用列表旧值或在页面推算默认日期。 */ +async function refreshInboundPlanArrival(target: LastMileOrderListItem) { + const request = ++inboundPlanArrivalRequest + inboundPlanArrivalLoading.value = true + try { + if (!target.sourceContainerBookingId) { + inboundPlanArrivalError.value = '尾程订单未绑定来源柜,无法取得预计到仓时间' + return + } + const arrivals = await listContainerArrivalTimes([target.sourceContainerBookingId]) + if (request !== inboundPlanArrivalRequest || !inboundPlanOpen.value) return + const arrival = arrivals.find(item => item.sourceContainerBookingId === target.sourceContainerBookingId) + if (!arrival || arrival.version < 0) { + inboundPlanArrivalError.value = '来源柜预计到仓日期缺失或不一致,请先核对柜级时间' + return + } + inboundPlanArrival.value = arrival + } catch (error) { + if (request === inboundPlanArrivalRequest && inboundPlanOpen.value) { + inboundPlanArrivalError.value = getApiErrorDetails(error, '预计到仓时间加载失败,请重新打开入库计划重试').message + } + } finally { + if (request === inboundPlanArrivalRequest) inboundPlanArrivalLoading.value = false + } +} async function refreshManualPalletMinimum(target: LastMileOrderListItem) { if (!target.sourceContainerBookingId) return inboundPlanPreviewLoading.value = true @@ -671,7 +711,7 @@ async function submitInboundPlan() { ElMessage.warning('一件代发必须按头程已封托托盘数整体生成入库计划') return } - if (!row || !row.overseasWarehouseId || !row.estimatedWarehouseAt || !frozenInboundCode || frozenInboundCode === '—' || inboundPlanForm.palletCount < inboundPlanMinimumPalletCount.value) { + if (!row || !row.overseasWarehouseId || inboundPlanArrivalLoading.value || !inboundPlanArrivalProjection.value.date || !frozenInboundCode || frozenInboundCode === '—' || inboundPlanForm.palletCount < inboundPlanMinimumPalletCount.value) { ElMessage.warning('当前柜须已分配接柜仓、具备预计到仓时间,并完整填写托数') return } @@ -1279,11 +1319,12 @@ onMounted(() => { void load() }) - - + + + -

入库来源 取整柜预计到仓时间,同柜所有入库计划保持一致;已生成计划后锁定。接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。

+

入库来源 优先使用整柜人工维护的预计到仓时间;未维护时,按到港当地日期加两个自然日默认推算。首个入库计划生成后冻结日期,同柜计划保持一致。接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。

预计到仓时间{{ inboundPlanExpectedArrival }} 前往货柜维护预计到仓
计划入库仓{{ inboundPlanWarehouseLabel || '—' }}
入仓码{{ inboundPlanForm.inboundCode || '—' }}