From 0181656b4fc83e650d8f64c33da020e622bc7ebc Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 16:42:32 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lastMileContainerPlanning.test.ts | 36 -------- .../lastMileContainerPlanning.ts | 49 ----------- .../pages/LastMileOrdersView.behavior.test.ts | 84 ++++--------------- .../pages/LastMileOrdersView.vue | 14 +++- 4 files changed, 25 insertions(+), 158 deletions(-) delete mode 100644 src/modules/overseas-warehouse/lastMileContainerPlanning.test.ts diff --git a/src/modules/overseas-warehouse/lastMileContainerPlanning.test.ts b/src/modules/overseas-warehouse/lastMileContainerPlanning.test.ts deleted file mode 100644 index 9e422fe1..00000000 --- a/src/modules/overseas-warehouse/lastMileContainerPlanning.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { groupPendingOrdersByContainer } from './lastMileContainerPlanning' -import type { LastMileOrderListItem } from './types' - -function order(overrides: Partial): LastMileOrderListItem { - return { - id: '1', customerId: '100', sourceContainerBookingId: 'BOOKING-1', orderNo: 'LMO-1', warning: '—', orderType: '头程订单', rejectedAt: '—', inboundCode: 'IN-1', - owner: '客户', warehouse: '—', transferWarehouse: '—', fbaWarehouse: '—', containerNo: 'CONT-1', containerStatus: '—', abnormal: '否', held: '否', - pallets: 1, cartons: 10, volume: 2.5, quantity: '—', volumeWeight: '—', portEta: '—', portAta: '—', warehouseEta: '—', inboundOrderNo: '—', outboundOrderNo: '—', dispatchOrderNo: '—', relabelOrderNo: '—', relabelStatus: '—', consolidated: '—', status: '待分配', internalRemark: '—', customerRemark: '—', warehouseRemark: '—', createdAt: '—', - ...overrides, - } -} - -describe('last-mile container planning groups', () => { - it('groups the whole container while preserving one independent plan per order', () => { - const [group] = groupPendingOrdersByContainer([ - order({ id: '1', customerId: '100', inboundCode: 'IN-1', cartons: 10, pallets: 1, volume: 2.5 }), - order({ id: '2', customerId: '200', inboundCode: 'IN-2', cartons: 5, pallets: 2, volume: 1.25 }), - ]) - expect(group).toMatchObject({ - containerNo: 'CONT-1', orderCount: 2, inboundCodeCount: 2, customerCount: 2, - totalCartons: 15, totalPallets: 3, totalVolumeCbm: 3.75, eligible: true, - planNotice: '将为 2 个尾程订单生成 2 个独立入库计划,PDA仍按同柜作业', - orderIds: ['1', '2'], customerIds: ['100', '200'], - }) - }) - - it('fails closed for missing container/customer facts and never treats unknown totals as zero', () => { - const groups = groupPendingOrdersByContainer([ - order({ id: '1', sourceContainerBookingId: '', containerNo: '—', cartons: '—' }), - order({ id: '2', containerNo: 'CONT-2', customerId: '', pallets: '—' }), - ]) - expect(groups.find((group) => group.id === 'missing:1')).toMatchObject({ eligible: false, totalCartons: '—' }) - expect(groups.find((group) => group.containerNo === 'CONT-2')).toMatchObject({ eligible: false, totalPallets: '—' }) - }) -}) diff --git a/src/modules/overseas-warehouse/lastMileContainerPlanning.ts b/src/modules/overseas-warehouse/lastMileContainerPlanning.ts index 5a69a5ea..fded1bb7 100644 --- a/src/modules/overseas-warehouse/lastMileContainerPlanning.ts +++ b/src/modules/overseas-warehouse/lastMileContainerPlanning.ts @@ -1,5 +1,3 @@ -import type { LastMileOrderListItem } from './types' - type DisplayTotal = number | '—' export interface LastMileContainerPlanRow { @@ -18,50 +16,3 @@ export interface LastMileContainerPlanRow { orderIds: string[] customerIds: string[] } - -function sumComplete(values: Array): DisplayTotal { - return values.every((value): value is number => typeof value === 'number') - ? values.reduce((sum, value) => sum + value, 0) - : '—' -} - -export function groupPendingOrdersByContainer(rows: LastMileOrderListItem[]): LastMileContainerPlanRow[] { - const groups = new Map() - rows.forEach((row) => { - const key = row.sourceContainerBookingId || `missing:${row.id}` - groups.set(key, [...(groups.get(key) || []), row]) - }) - - return [...groups.entries()].map(([key, orders]) => { - const sourceContainerBookingId = key.startsWith('missing:') ? '' : key - const containerValues = [...new Set(orders.map((order) => order.containerNo).filter((value) => value !== '—'))] - const containerNo = containerValues.length === 1 ? containerValues[0] : '—' - const customerIds = [...new Set(orders.map((order) => order.customerId.trim()).filter(Boolean))] - const inboundCodes = new Set(orders.map((order) => order.inboundCode).filter((value) => value !== '—')) - const hasCompleteCustomerScope = orders.every((order) => Boolean(order.customerId.trim())) - const eligible = Boolean(sourceContainerBookingId) && containerNo !== '—' && hasCompleteCustomerScope - const eligibility = !sourceContainerBookingId - ? '缺少来源柜身份,不可生成' - : containerNo === '—' - ? '柜号不一致或缺失,不可生成' - : !hasCompleteCustomerScope - ? '存在缺失客户,不可生成' - : '可生成' - return { - id: key, - sourceContainerBookingId, - containerNo, - orderCount: orders.length, - inboundCodeCount: inboundCodes.size, - customerCount: customerIds.length, - totalCartons: sumComplete(orders.map((order) => order.cartons)), - totalPallets: sumComplete(orders.map((order) => order.pallets)), - totalVolumeCbm: sumComplete(orders.map((order) => order.volume)), - eligibility, - planNotice: `将为 ${orders.length} 个尾程订单生成 ${orders.length} 个独立入库计划,PDA仍按同柜作业`, - eligible, - orderIds: orders.map((order) => order.id), - customerIds, - } - }).sort((left, right) => left.containerNo.localeCompare(right.containerNo)) -} diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts index e09e981a..2be8131a 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts @@ -2,102 +2,46 @@ import ElementPlus from 'element-plus' import { createPinia } from 'pinia' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { ApiResponseError } from '@/shared/api/http' import { convertLastMileContainerToInboundPlans, convertLastMileOrderToInboundPlan, listLastMileOrders } from '../api/lastMileOrders' import LastMileOrdersView from './LastMileOrdersView.vue' vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) })) vi.mock('../api/lastMileOrders', () => ({ - listLastMileOrders: vi.fn(), - convertLastMileContainerToInboundPlans: vi.fn(), - convertLastMileOrderToInboundPlan: vi.fn(), - transferLastMileOrders: vi.fn(), + listLastMileOrders: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), + convertLastMileOrderToInboundPlan: vi.fn(), transferLastMileOrders: vi.fn(), })) class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} } function memoryStorage(): Storage { const values = new Map() return { - get length() { return values.size }, clear: () => values.clear(), - getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, - removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value), + get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, + key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value), } } enableAutoUnmount(afterEach) afterAll(() => vi.unstubAllGlobals()) -async function mountView() { - const wrapper = mount(LastMileOrdersView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } }) - await flushPromises() - return wrapper -} - -const containerRecords = [ - { id: '1', customerId: '100', sourceContainerBookingId: '2092873463568597001', orderNo: 'LMO-1', inboundCode: 'IN-1', containerNo: 'CONT-1', packageCount: 10, palletCount: 1, volumeCbm: 2, currentStatus: 'PENDING_ASSIGNMENT' }, - { id: '2', customerId: '200', sourceContainerBookingId: '2092873463568597001', orderNo: 'LMO-2', inboundCode: 'IN-2', containerNo: 'CONT-1', packageCount: 5, palletCount: 2, volumeCbm: 1, currentStatus: 'PENDING_ASSIGNMENT' }, -] - -function plan(id: string, customerId: string) { - return { id, planNo: `LIP-${id}`, customerId, sourceOrderId: id, containerNo: 'CONT-1', status: 'PENDING_ASSIGNMENT', lineCount: 1, palletCount: 1, totalVolumeCbm: 1, lines: [], pallets: [], allocations: [] } -} - -async function openPendingContainers(wrapper: Awaited>) { - const pendingTab = wrapper.findAll('.el-radio-button').find((item) => item.text().includes('待分配')) - expect(pendingTab).toBeDefined() - await pendingTab!.trigger('click') - await flushPromises() -} - -describe('LastMileOrdersView inbound-plan conversion', () => { +describe('LastMileOrdersView container boundary', () => { beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) vi.stubGlobal('localStorage', memoryStorage()) vi.mocked(listLastMileOrders).mockReset().mockResolvedValue({ records: [], page: 1, size: 10, total: 0, pages: 0 }) - vi.mocked(convertLastMileOrderToInboundPlan).mockReset().mockResolvedValue({}) - vi.mocked(convertLastMileContainerToInboundPlans).mockReset().mockResolvedValue([plan('1', '100'), plan('2', '200')]) + vi.mocked(convertLastMileContainerToInboundPlans).mockReset() + vi.mocked(convertLastMileOrderToInboundPlan).mockReset() }) - it('calls the atomic container endpoint once and refreshes all affected orders', async () => { - vi.mocked(listLastMileOrders) - .mockResolvedValueOnce({ records: [], page: 1, size: 10, total: 0, pages: 0 }) - .mockResolvedValueOnce({ records: containerRecords, page: 1, size: 10, total: 2, pages: 1 }) - .mockResolvedValueOnce({ records: [], page: 1, size: 10, total: 0, pages: 0 }) - const wrapper = await mountView() - await openPendingContainers(wrapper) - expect(wrapper.text()).toContain('CONT-1') - expect(wrapper.text()).toContain('将为 2 个尾程订单生成 2 个独立入库计划,PDA仍按同柜作业') - const button = wrapper.findAll('button').find((item) => item.text().includes('生成整柜入库计划')) - expect(button).toBeDefined() - expect(button!.attributes('disabled')).toBeUndefined() - await button!.trigger('click') + it('fails closed instead of grouping the current order page as a whole container', async () => { + const wrapper = mount(LastMileOrdersView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } }) await flushPromises() - expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledTimes(1) - expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledWith({ sourceContainerBookingId: '2092873463568597001' }) - expect(convertLastMileOrderToInboundPlan).not.toHaveBeenCalled() - expect(listLastMileOrders).toHaveBeenCalledTimes(3) - expect(document.body.textContent).toContain('已为2个尾程订单生成2个入库计划') - }) - - it.each([ - ['A0404', 'Container orders not found'], - ['A0409', 'Container conversion conflict'], - ])('keeps %s request evidence after the atomic operation fails', async (code, message) => { - vi.mocked(listLastMileOrders) - .mockResolvedValueOnce({ records: [], page: 1, size: 10, total: 0, pages: 0 }) - .mockResolvedValueOnce({ records: containerRecords, page: 1, size: 10, total: 2, pages: 1 }) - vi.mocked(convertLastMileContainerToInboundPlans).mockRejectedValueOnce(new ApiResponseError( - message, code, 200, undefined, `request-${code}`, `trace-${code}`, - )) - const wrapper = await mountView() - await openPendingContainers(wrapper) - const button = wrapper.findAll('button').find((item) => item.text().includes('生成整柜入库计划')) - await button!.trigger('click') + const pendingTab = wrapper.findAll('.el-radio-button').find((item) => item.text().includes('待分配')) + await pendingTab!.trigger('click') await flushPromises() - expect(wrapper.text()).toContain(code) - expect(wrapper.text()).toContain(`request-${code}`) - expect(wrapper.text()).toContain(`trace-${code}`) + expect(wrapper.text()).toContain('已停止从订单分页本地聚合整柜') + expect(listLastMileOrders).toHaveBeenCalledTimes(1) + expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled() expect(convertLastMileOrderToInboundPlan).not.toHaveBeenCalled() }) }) diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue index fae38324..15c897f8 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue @@ -17,7 +17,7 @@ import OrderReferenceText from '../components/OrderReferenceText.vue' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { overseasWarehousePermissions as permission } from '../permissions' import { toLastMileOrderListItem } from '../lastMilePresentation' -import { groupPendingOrdersByContainer, type LastMileContainerPlanRow } from '../lastMileContainerPlanning' +import type { LastMileContainerPlanRow } from '../lastMileContainerPlanning' import type { LastMileOrderListItem } from '../types' const rows = ref([]) @@ -29,6 +29,7 @@ const page = ref(1) const pageSize = ref(10) const total = ref(0) const selectedRows = ref([]) +const containerRows = ref([]) const actionLoading = ref(false) const actionError = ref(null) const transferOpen = ref(false) @@ -106,12 +107,19 @@ const containerColumns: DataTableColumn[] = [ { key: 'eligibility', prop: 'eligibility', label: '资格', minWidth: 160, slotName: 'eligibility' }, { key: 'planNotice', prop: 'planNotice', label: '生成说明', minWidth: 320 }, ] -const containerRows = computed(() => groupPendingOrdersByContainer(rows.value)) async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model) loading.value = true loadError.value = null + if (statusTab.value === '待分配') { + rows.value = [] + containerRows.value = [] + total.value = 0 + loadError.value = { message: '柜级待分配摘要分页接口尚未在当前 test 后端提供,已停止从订单分页本地聚合整柜' } + loading.value = false + return + } try { const dateRange = Array.isArray(filters.value.createdAt) ? filters.value.createdAt.map(String) : [] const booleanValue = (value: unknown) => value === 'true' ? true : value === 'false' ? false : undefined @@ -124,7 +132,7 @@ async function load(model: QueryModel = filters.value) { inboundCode: String(filters.value.inboundCode || '').trim() || undefined, sourceOrderNo: String(filters.value.sourceOrderNo || '').trim() || undefined, containerNo: String(filters.value.containerNo || '').trim() || undefined, - currentStatus: statusTab.value === '待分配' ? 'PENDING_ASSIGNMENT' : String(filters.value.status || '').trim() || undefined, + currentStatus: String(filters.value.status || '').trim() || undefined, transferWarehouseCode: String(filters.value.transferWarehouseCode || '').trim() || undefined, abnormal: booleanValue(filters.value.abnormal), detained: booleanValue(filters.value.detained),