From c83d03e775afa7df31ceff32fc752b20617945e9 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 12:50:39 +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 --- ...nerDetailView.emptyReturn.behavior.test.ts | 130 +++++++++++++++++- .../pages/ContainersView.test.ts | 63 ++++++++- .../api/deliveryOrders.test.ts | 2 +- .../overseas-warehouse/api/deliveryOrders.ts | 28 +++- .../DeliveryDocumentsDialog.test.ts | 37 ++++- .../pages/DeliveryOrdersView.test.ts | 108 ++++++++++++++- .../pages/DeliveryOrdersView.vue | 52 +++++-- 7 files changed, 395 insertions(+), 25 deletions(-) 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 ece4fed0..e24d513c 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts +++ b/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts @@ -5,11 +5,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import ContainerDetailView from './ContainerDetailView.vue' import { getContainer, getContainerOverview } from '../api/platformContainerLoading' import { recordTerminalDrayageEmptyReturn } from '../api/terminalDrayages' +import { getConfirmedLoadingList, getPreloadPlan } from '@/modules/loading-lists' +import DomesticContainerDetailView from './DomesticContainerDetailView.vue' const canAccess = vi.hoisted(() => vi.fn<(code: string) => boolean>(() => true)) +const routeState = vi.hoisted(() => ({ params: { containerId: '9001' }, query: {} as Record })) +const routerPush = vi.hoisted(() => vi.fn()) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canAccess }) })) -vi.mock('vue-router', () => ({ useRoute: () => ({ params: { containerId: '9001' }, query: {} }), useRouter: () => ({ push: vi.fn() }) })) -vi.mock('@/modules/loading-lists', () => ({ ContainerFeesPanel: { template: '
' }, LoadingListDetailDrawer: { template: '
' }, getConfirmedLoadingList: vi.fn(), getPreloadPlan: vi.fn() })) +vi.mock('vue-router', () => ({ useRoute: () => routeState, useRouter: () => ({ push: routerPush }) })) +vi.mock('@/modules/loading-lists', () => ({ ContainerFeesPanel: { template: '
' }, LoadingListDetailDrawer: { name: 'LoadingListDetailDrawer', props: ['detail', 'error', 'loading'], template: '
{{ detail?.planNo }}
' }, getConfirmedLoadingList: vi.fn(), getPreloadPlan: vi.fn() })) vi.mock('@/modules/outbound-dispatch', () => ({ confirmContainerOutboundDispatch: vi.fn(), getContainerOutboundDispatch: vi.fn() })) vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '
' } })) vi.mock('../api/platformContainerLoading', () => ({ getContainer: vi.fn(), getContainerOverview: vi.fn(), cancelContainerException: vi.fn(), completeContainerClearance: vi.fn(), downloadContainerClearance: vi.fn(), downloadContainerDocument: vi.fn(), getContainerClearance: vi.fn(), markContainerException: vi.fn(), uploadContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(), updateContainerPickupCode: vi.fn() })) @@ -28,6 +32,8 @@ async function mountDetail(workbench: 'DOMESTIC' | 'OVERSEAS' = 'OVERSEAS') { describe('ContainerDetailView empty return', () => { beforeEach(() => { + routeState.query = {} + routerPush.mockReset() canAccess.mockReset().mockReturnValue(true) vi.mocked(getContainer).mockReset().mockResolvedValue(base as never) vi.mocked(getContainerOverview).mockReset().mockResolvedValue(overview as never) @@ -78,3 +84,123 @@ describe('ContainerDetailView empty return', () => { expect(wrapper.findAll('button').some((item) => item.text() === '确认还柜')).toBe(false) }) }) + +describe('domestic container loading list and booking link', () => { + const domestic = { ...base, containerNo: null, warehouseId: '8', sourceType: 'SIMULATION', oceanBookingId: null, planId: '7001', planStatus: 'EDITING', version: 1, allowedActions: ['EDIT_CONTAINER'], terminalDrayage: null } + const loadingDetail = { planNo: 'LP-1', items: [] } + + beforeEach(() => { + routeState.query = {} + routerPush.mockReset() + canAccess.mockReset().mockImplementation((code) => ['platform:firstleg:container:list', 'platform:firstleg:container:edit', 'platform:firstleg:preload:list', 'platform:firstleg:loading-list:list'].includes(code)) + vi.mocked(getContainer).mockReset().mockResolvedValue(domestic as never) + vi.mocked(getContainerOverview).mockReset().mockResolvedValue({ ...overview, loadingPlanId: '7001' } as never) + vi.mocked(getPreloadPlan).mockReset().mockResolvedValue(loadingDetail as never) + vi.mocked(getConfirmedLoadingList).mockReset().mockResolvedValue(loadingDetail as never) + }) + + async function mountDomestic() { + const wrapper = mount(DomesticContainerDetailView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus], stubs: { ContainerOverviewPanel: true, ContainerCustomsPanel: true, ContainerLastLegFeesPanel: true } } }) + await flushPromises() + expect(getContainer).toHaveBeenLastCalledWith('9001', 'DOMESTIC') + return wrapper + } + + it.each(['WAIT_LOADING', 'EDITING', 'LOADING', 'COMPLETED'])('opens the loading-list tab using the correct API for %s', async (planStatus) => { + vi.mocked(getContainer).mockResolvedValue({ ...domestic, planStatus } as never) + const wrapper = await mountDomestic() + expect(getPreloadPlan).not.toHaveBeenCalled() + expect(getConfirmedLoadingList).not.toHaveBeenCalled() + const tab = wrapper.findAll('[role="tab"]').find((item) => item.text() === '装柜清单')! + expect(tab).toBeDefined() + await tab.trigger('click') + await flushPromises() + expect(tab.attributes('aria-selected')).toBe('true') + expect(wrapper.get('[data-testid="loading-detail"]').text()).toBe('LP-1') + expect(planStatus === 'COMPLETED' ? getConfirmedLoadingList : getPreloadPlan).toHaveBeenCalledExactlyOnceWith('7001', '8') + expect(planStatus === 'COMPLETED' ? getPreloadPlan : getConfirmedLoadingList).not.toHaveBeenCalled() + }) + + it.each(['WAIT_LOADING', 'EDITING', 'LOADING', 'COMPLETED'])('enforces the matching read permission for a %s deep link', async (planStatus) => { + for (const preload of [false, true]) { + for (const confirmed of [false, true]) { + vi.mocked(getPreloadPlan).mockClear() + vi.mocked(getConfirmedLoadingList).mockClear() + routeState.query = { tab: 'loading-list' } + canAccess.mockImplementation((code) => code === 'platform:firstleg:preload:list' ? preload : code === 'platform:firstleg:loading-list:list' ? confirmed : code === 'platform:firstleg:container:list') + vi.mocked(getContainer).mockResolvedValue({ ...domestic, planStatus } as never) + const wrapper = await mountDomestic() + const permitted = planStatus === 'COMPLETED' ? confirmed : preload + const tabs = wrapper.findAll('[role="tab"]') + expect(tabs.some((tab) => tab.text() === '装柜清单')).toBe(permitted) + expect(tabs.find((tab) => tab.attributes('aria-selected') === 'true')?.text()).toBe(permitted ? '装柜清单' : '送柜报关') + expect(getPreloadPlan).toHaveBeenCalledTimes(permitted && planStatus !== 'COMPLETED' ? 1 : 0) + expect(getConfirmedLoadingList).toHaveBeenCalledTimes(permitted && planStatus === 'COMPLETED' ? 1 : 0) + wrapper.unmount() + } + } + }) + + it('does not expose the domestic loading-list deep link in the overseas workbench', async () => { + routeState.query = { tab: 'loading-list' } + vi.mocked(getContainer).mockResolvedValue(base as never) + const wrapper = await mountDetail('OVERSEAS') + expect(wrapper.findAll('[role="tab"]').map((tab) => tab.text())).not.toContain('装柜清单') + expect(wrapper.findAll('[role="tab"]').find((tab) => tab.attributes('aria-selected') === 'true')?.text()).toBe('清关提柜') + expect(getPreloadPlan).not.toHaveBeenCalled() + expect(getConfirmedLoadingList).not.toHaveBeenCalled() + }) + + it('keeps the tab and reports a loading failure without inventing a successful list', async () => { + routeState.query = { tab: 'loading-list' } + vi.mocked(getPreloadPlan).mockRejectedValue(new Error('清单读取失败')) + const wrapper = await mountDomestic() + const list = wrapper.findComponent({ name: 'LoadingListDetailDrawer' }) + expect(list.props('detail')).toBeNull() + expect(list.props('error')).toMatchObject({ message: '装柜清单加载失败' }) + expect(list.props('loading')).toBe(false) + expect(wrapper.findAll('[role="tab"]').find((tab) => tab.attributes('aria-selected') === 'true')?.text()).toBe('装柜清单') + }) + + it.each(['WAIT_LOADING', 'EDITING'])('retains the unlinked booking entry with an existing plan in %s', async (planStatus) => { + vi.mocked(getContainer).mockResolvedValue({ ...domestic, planStatus, version: 2 } as never) + const wrapper = await mountDomestic() + const link = wrapper.findAll('button').find((button) => button.text() === '关联已放舱订舱')! + expect(link).toBeDefined() + await link.trigger('click') + expect(routerPush).toHaveBeenCalledExactlyOnceWith({ name: 'domestic-container-workbench', query: { linkContainerId: '9001' } }) + }) + + it('keeps the same cabinet link before and after an inbound assignment snapshot refresh', async () => { + // Assignment changes the version and plan items, not the booking association or EDITING status. + for (const [version, items] of [[1, []], [2, [{ inboundCode: 'IN-001' }]]] as const) { + routeState.query = { tab: 'loading-list' } + vi.mocked(getContainer).mockResolvedValue({ ...domestic, version } as never) + vi.mocked(getPreloadPlan).mockResolvedValue({ ...loadingDetail, items } as never) + const wrapper = await mountDomestic() + expect(wrapper.findComponent({ name: 'LoadingListDetailDrawer' }).props('detail').items).toEqual(items) + await wrapper.findAll('[role="tab"]').find((tab) => tab.text() === '送柜报关')!.trigger('click') + await flushPromises() + const link = wrapper.findAll('button').find((button) => button.text() === '关联已放舱订舱')! + expect(link.isVisible()).toBe(true) + routerPush.mockClear() + await link.trigger('click') + expect(routerPush).toHaveBeenCalledExactlyOnceWith({ name: 'domestic-container-workbench', query: { linkContainerId: '9001' } }) + wrapper.unmount() + } + }) + + it.each([ + { label: 'already linked simulation', patch: { oceanBookingId: '8001' }, edit: true }, + { label: 'picking started', patch: { planStatus: 'LOADING' }, edit: true }, + { label: 'completed', patch: { planStatus: 'COMPLETED' }, edit: true }, + { label: 'server denies editing', patch: { allowedActions: [] }, edit: true }, + { label: 'account lacks edit permission', patch: {}, edit: false }, + ])('hides the booking link when $label', async ({ patch, edit }) => { + canAccess.mockImplementation((code) => code === 'platform:firstleg:container:list' || (edit && code === 'platform:firstleg:container:edit')) + vi.mocked(getContainer).mockResolvedValue({ ...domestic, ...patch } as never) + const wrapper = await mountDomestic() + expect(wrapper.findAll('button').some((button) => button.text() === '关联已放舱订舱')).toBe(false) + expect(routerPush).not.toHaveBeenCalled() + }) +}) diff --git a/src/modules/container-loading/pages/ContainersView.test.ts b/src/modules/container-loading/pages/ContainersView.test.ts index 1e2ba78c..9675af1d 100644 --- a/src/modules/container-loading/pages/ContainersView.test.ts +++ b/src/modules/container-loading/pages/ContainersView.test.ts @@ -1,6 +1,6 @@ import ElementPlus from 'element-plus' import { createPinia, setActivePinia } from 'pinia' -import { createMemoryHistory, createRouter } from 'vue-router' +import { createMemoryHistory, createRouter, RouterView } from 'vue-router' import { flushPromises, mount } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import { readFileSync } from 'node:fs' @@ -13,6 +13,7 @@ import { getOceanBooking, listOceanBookingLinkCandidates, listOceanBookings } fr import { getContainer, getContainerOverview, linkContainerOceanBooking, listContainers, updateContainer } from '../api/platformContainerLoading' import { getTerminalDrayage, getTerminalDrayageHistory, recordTerminalDrayageEmptyReturn, selectTerminalDrayageFleet } from '../api/terminalDrayages' import AppDialog from '@/shared/components/AppDialog.vue' +import { containerLoadingRoutes } from '../routes' vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() })) vi.mock('@/modules/master-data', () => ({ listPlatformPartners: vi.fn() })) @@ -42,6 +43,18 @@ function createTestRouter() { ] }) } +async function mountOverseasRoute() { + const router = createRouter({ + history: createMemoryHistory(), + routes: containerLoadingRoutes.map((route) => ({ ...route, path: `/${route.path}` })), + }) + await router.push('/container-loading/overseas') + await router.isReady() + const wrapper = mount(RouterView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } }) + await flushPromises() + return wrapper +} + describe('ContainersView', () => { beforeEach(() => { setActivePinia(createPinia()) @@ -76,10 +89,8 @@ describe('ContainersView', () => { }) it('shows one direct empty-return action only when permission and server actions allow it', async () => { - const router = createTestRouter() - await router.push('/'); await router.isReady() - const wrapper = mount(ContainersView, { props: { workbench: 'OVERSEAS' }, attachTo: document.body, global: { plugins: [ElementPlus, router] } }) - await flushPromises() + const wrapper = await mountOverseasRoute() + expect(listContainers).toHaveBeenCalledWith(expect.objectContaining({ scope: 'ACTIVE' }), 'OVERSEAS') const buttons = wrapper.findAll('button').filter((item) => item.text().includes('确认还柜')) expect(buttons).toHaveLength(1) await buttons[0].trigger('click') @@ -101,6 +112,48 @@ describe('ContainersView', () => { wrapper.unmount() }) + it.each(['permission', 'row action', 'nested action', 'nested object', 'already returned', 'historical unloaded snapshot'] as const)( + 'keeps empty return unavailable on the real overseas route: %s', + async (missing) => { + const page = await listContainers({ page: 1, size: 20 }, 'OVERSEAS') + vi.mocked(listContainers).mockClear() + const nested = missing === 'nested object' ? null : { + ...drayage, + allowedActions: missing === 'nested action' || missing === 'already returned' ? [] : [...drayage.allowedActions], + ...(missing === 'already returned' ? { status: 'EMPTY_RETURNED' as const, actualEmptyReturnAt: '2026-09-11T13:00:00' } : {}), + ...(missing === 'historical unloaded snapshot' ? { status: 'ARRIVED' as const, version: 0, actualPickupAt: null, fleetSupplierId: null, fleetSupplierName: null, allowedActions: ['SELECT_FLEET' as const] } : {}), + } + vi.mocked(listContainers).mockResolvedValue({ ...page, records: page.records.map((row) => ({ + ...row, + allowedActions: missing === 'row action' || missing === 'already returned' ? [] : ['RECORD_EMPTY_RETURN'], + terminalDrayage: nested, + ...(missing === 'historical unloaded snapshot' ? { workbenchStage: 'UNLOADED', allowedActions: ['MANAGE_DESTINATION_DOCUMENTS' as const, 'SELECT_FLEET' as const] } : {}), + })) }) + if (missing === 'permission') { + accessCan.mockImplementation((code) => code !== 'platform:firstleg:terminal-drayage:empty-return:confirm') + } + const wrapper = await mountOverseasRoute() + try { + expect(listContainers).toHaveBeenCalledTimes(1) + expect(listContainers).toHaveBeenCalledWith(expect.objectContaining({ scope: 'ACTIVE' }), 'OVERSEAS') + expect(wrapper.text()).toContain('MSCU-1') + expect(wrapper.findAll('.row-actions button').some((button) => button.text() === '确认还柜')).toBe(false) + expect(wrapper.findAllComponents(AppDialog).some((dialog) => dialog.props('title') === '确认还柜' && dialog.props('modelValue'))).toBe(false) + expect(recordTerminalDrayageEmptyReturn).not.toHaveBeenCalled() + expect(getTerminalDrayage).not.toHaveBeenCalled() + if (missing === 'historical unloaded snapshot') { + const fleet = wrapper.findAllComponents({ name: 'ElSelect' }).find((select) => + select.props('placeholder') === '请选择车队' && select.element.closest('.el-table__body'), + ) + expect(fleet).toBeDefined() + expect(fleet!.props('disabled')).toBe(false) + } + } finally { + wrapper.unmount() + } + }, + ) + it('cancels empty return without writing and preserves input on failure while preventing duplicate requests', async () => { const router = createTestRouter() await router.push('/'); await router.isReady() diff --git a/src/modules/overseas-warehouse/api/deliveryOrders.test.ts b/src/modules/overseas-warehouse/api/deliveryOrders.test.ts index cee766b0..3d073756 100644 --- a/src/modules/overseas-warehouse/api/deliveryOrders.test.ts +++ b/src/modules/overseas-warehouse/api/deliveryOrders.test.ts @@ -28,7 +28,7 @@ describe('delivery order API', () => { await listDeliveryReturnInboundOptions('DEL/1', '1001') expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/return-inbound-options', { params: { customerId: '1001' } }) - const payload = { inboundOrderId: '9', reason: '客户拒收', returnDirection: 'OVERSEAS_WAREHOUSE', packageCount: 2 } + const payload = { inboundOrderId: '9', reason: '客户拒收', returnDirection: 'OVERSEAS_WAREHOUSE' as const, items: [{ outboundPalletItemId: '9007199254740995', inventoryPositionId: '9007199254740996', packageCount: 2 }], idempotencyKey: 'return-1' } vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: { id: '19', exceptionNo: 'EXC-1', status: 'PENDING' } }) await markDeliveryReturn('DEL/1', '1001', payload) expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/mark-return', payload, { params: { customerId: '1001' } }) diff --git a/src/modules/overseas-warehouse/api/deliveryOrders.ts b/src/modules/overseas-warehouse/api/deliveryOrders.ts index b3291944..4e2bd314 100644 --- a/src/modules/overseas-warehouse/api/deliveryOrders.ts +++ b/src/modules/overseas-warehouse/api/deliveryOrders.ts @@ -1,7 +1,6 @@ import { overseasHttp } from '@/shared/api/overseasHttp' import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders' -import type { ExceptionRelation } from './exceptionOrders' import type { DeliveryDocumentSummary } from './deliveryDocuments' export interface DeliveryOrderQuery { @@ -63,6 +62,29 @@ export interface DeliveryFeeRow { id: string; feeDirection: string; feeType: str export interface DeliveryOperationRow { id: string; operatedAt: string; operationNode: string; operatorName?: string | null; result: string } export interface DeliveryOrderDetail { order: DeliveryOrderRow; skus: DeliverySkuRow[]; fees: DeliveryFeeRow[]; operations: DeliveryOperationRow[] } +export interface DeliveryReturnSource { + outboundPalletItemId: string + inventoryPositionId: string + palletNo?: string | null + skuCode?: string | null + maxReturnPackages: number +} +export interface DeliveryReturnRelation { + inboundOrderId: string + inboundCode?: string | null + lastMileOrderNo?: string | null + customerId: string + returnSources: DeliveryReturnSource[] +} +export interface MarkDeliveryReturnPayload { + inboundOrderId: string + reason: string + returnDirection: 'OVERSEAS_WAREHOUSE' | 'CUSTOMER' | 'OTHER' + returnLogisticsNo?: string + items: Array<{ outboundPalletItemId: string; inventoryPositionId: string; packageCount: number }> + idempotencyKey: string +} + export async function listDeliveryOrders(query: DeliveryOrderQuery) { const { customerId, ...params } = query const response = await overseasHttp.get>, ApiEnvelope>>( @@ -79,13 +101,13 @@ export async function getDeliveryOrder(deliveryNo: string, customerId: string) { } export async function listDeliveryReturnInboundOptions(deliveryNo: string, customerId: string) { - const response = await overseasHttp.get, ApiEnvelope>( + const response = await overseasHttp.get, ApiEnvelope>( `/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}/return-inbound-options`, { params: { customerId } }, ) return unwrapApiResponse(response) } -export async function markDeliveryReturn(deliveryNo: string, customerId: string, payload: { inboundOrderId: string; reason: string; returnDirection: string; returnLogisticsNo?: string; packageCount?: number; skuCode?: string }) { +export async function markDeliveryReturn(deliveryNo: string, customerId: string, payload: MarkDeliveryReturnPayload) { const response = await overseasHttp.post, ApiEnvelope<{ id: string; exceptionNo: string; status: string }>>( `/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}/mark-return`, payload, { params: { customerId } }, ) diff --git a/src/modules/overseas-warehouse/components/DeliveryDocumentsDialog.test.ts b/src/modules/overseas-warehouse/components/DeliveryDocumentsDialog.test.ts index cb6c22a4..29b92160 100644 --- a/src/modules/overseas-warehouse/components/DeliveryDocumentsDialog.test.ts +++ b/src/modules/overseas-warehouse/components/DeliveryDocumentsDialog.test.ts @@ -1,6 +1,6 @@ import ElementPlus from 'element-plus' import { defineComponent, h } from 'vue' -import { flushPromises, mount } from '@vue/test-utils' +import { DOMWrapper, flushPromises, mount } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import DeliveryDocumentsDialog from './DeliveryDocumentsDialog.vue' import { commonPhase5Stubs } from '../pages/phase5TestStubs' @@ -50,6 +50,41 @@ describe('delivery document drafts', () => { expect(wrapper.emitted('saved')).toHaveLength(1) wrapper.unmount() }) + it('uses real upload controls to add, replace, delete and reload the saved three-category snapshot', async () => { + const receipt = { ...originalFile, fileId: 'receipt-1', fileName: 'pod.pdf' } + const courier = { ...originalFile, fileId: 'receipt-2', fileName: 'courier.pdf' } + const replacement = { ...originalFile, fileId: 'bol-2', fileName: 'signed-bol.pdf' } + vi.mocked(uploadDeliveryDocument).mockResolvedValueOnce(receipt).mockResolvedValueOnce(courier).mockResolvedValueOnce(replacement) + const wrapper = mount(DeliveryDocumentsDialog, { attachTo: document.body, props: { modelValue: true, deliveryNo: 'DL/1', customerId: '100' }, global: { plugins: [ElementPlus] } }) + const body = new DOMWrapper(document.body) + try { + await flushPromises() + const dialog = body.find('[role="dialog"]') + expect(dialog.findAll('[data-category]')).toHaveLength(3) + await choose(dialog.find('[data-category="RECEIPT"] header input[type="file"]'), new File(['pod'], 'pod.pdf')) + await choose(dialog.find('[data-category="RECEIPT"] header input[type="file"]'), new File(['courier'], 'courier.pdf')) + expect(dialog.text()).toContain('2/3 已上传') + await choose(dialog.find('[data-category="BOL"] .document-file input[type="file"]'), new File(['signed'], 'signed-bol.pdf')) + await dialog.find('[data-category="RECEIPT"] .document-file').findAll('button').find(button => button.text() === '删除')!.trigger('click') + expect(dialog.text()).not.toContain('pod.pdf') + expect(dialog.text()).toContain('courier.pdf') + expect(saveDeliveryDocuments).not.toHaveBeenCalled() + await dialog.findAll('button').find(button => button.text() === '保存资料')!.trigger('click'); await flushPromises() + expect(saveDeliveryDocuments).toHaveBeenCalledWith('DL/1', '100', { expectedVersion: 9, idempotencyKey: expect.any(String), bolNo: 'BOL-1', documents: [{ category: 'LOADING_PHOTO', fileIds: [] }, { category: 'BOL', fileIds: ['bol-2'] }, { category: 'RECEIPT', fileIds: ['receipt-2'] }] }) + expect(wrapper.emitted('saved')).toHaveLength(1) + const persisted = response() + persisted.version = 10; persisted.uploadedCategoryCount = 2 + persisted.categories = persisted.categories.map(group => ({ ...group, files: group.category === 'BOL' ? [replacement] : group.category === 'RECEIPT' ? [courier] : [], fileCount: group.category === 'LOADING_PHOTO' ? 0 : 1, status: group.category === 'LOADING_PHOTO' ? 'MISSING' : 'UPLOADED' })) + vi.mocked(getDeliveryDocuments).mockResolvedValue(persisted) + await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises() + const reopened = body.find('[role="dialog"]') + expect(reopened.text()).toContain('2/3 已上传') + expect(reopened.text()).toContain('signed-bol.pdf') + expect(reopened.text()).toContain('courier.pdf') + expect(reopened.text()).not.toContain('original.pdf') + expect(getDeliveryDocuments).toHaveBeenCalledTimes(2) + } finally { wrapper.unmount() } + }) it('discards unsaved uploads on cancel and reloads persisted categories on reopening', async () => { const wrapper = mountDialog(); await flushPromises() await choose(wrapper.find('[data-category="RECEIPT"] input[type="file"]'), new File(['proof'], 'new.pdf')) diff --git a/src/modules/overseas-warehouse/pages/DeliveryOrdersView.test.ts b/src/modules/overseas-warehouse/pages/DeliveryOrdersView.test.ts index 9a45cbac..d71e4e3e 100644 --- a/src/modules/overseas-warehouse/pages/DeliveryOrdersView.test.ts +++ b/src/modules/overseas-warehouse/pages/DeliveryOrdersView.test.ts @@ -1,6 +1,6 @@ import ElementPlus from 'element-plus' import { defineComponent, h, type PropType } from 'vue' -import { flushPromises, mount } from '@vue/test-utils' +import { DOMWrapper, flushPromises, mount } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import { getDeliveryOrder, listDeliveryOrders, listDeliveryReturnInboundOptions, markDeliveryReturn } from '../api/deliveryOrders' import { updateDeliveryOrderStatus } from '../api/customerVisits' @@ -17,7 +17,7 @@ vi.mock('../api/pallets', () => ({ listReceivingOverseasWarehouses: vi.fn() })) vi.mock('../api/deliveryDocuments', async importOriginal => ({ ...await importOriginal(), getDeliveryDocuments: vi.fn() })) const row = { - version: 2, allowedActions: ['START_DELIVERY', 'COMPLETE_DELIVERY', 'REJECT_DELIVERY', 'CANCEL_DELIVERY'], + version: 2, allowedActions: ['START_DELIVERY', 'COMPLETE_DELIVERY', 'REJECT_DELIVERY', 'CANCEL_DELIVERY', 'MARK_RETURN'], id: '1', customerId: '100', deliveryNo: 'DEL/1', outboundNo: 'OUT-1', dispatchNo: 'PC-1', status: 'WAIT_DELIVERY', palletCount: 1, packageCount: 2, createdAt: '2026-09-01T10:00:00', } @@ -32,7 +32,7 @@ describe('DeliveryOrdersView permissions and commands', () => { vi.mocked(getDeliveryDocuments).mockReset() vi.mocked(getDeliveryOrder).mockReset().mockResolvedValue({ order: { ...row, version: 7 }, skus: [], fees: [], operations: [] }) vi.mocked(listDeliveryOrders).mockReset().mockResolvedValue({ records: [row], page: 1, size: 10, total: 1, pages: 1 }) - vi.mocked(listDeliveryReturnInboundOptions).mockReset().mockResolvedValue([{ inboundOrderId: '9', inboundCode: 'IN-1', lastMileOrderNo: 'LM-1', customerId: '100' }]) + vi.mocked(listDeliveryReturnInboundOptions).mockReset().mockResolvedValue([{ inboundOrderId: '9', inboundCode: 'IN-1', lastMileOrderNo: 'LM-1', customerId: '100', returnSources: [{ outboundPalletItemId: '9007199254740995', inventoryPositionId: '9007199254740996', palletNo: 'PL-1', skuCode: 'SKU-1', maxReturnPackages: 3 }] }]) vi.mocked(markDeliveryReturn).mockReset().mockResolvedValue({ id: '19', exceptionNo: 'EXC-1', status: 'PENDING' }) vi.mocked(updateDeliveryOrderStatus).mockReset().mockResolvedValue({ deliveryNo: 'DEL/1', deliveryStatus: 'WAIT_DELIVERY', replayed: false }) }) @@ -63,11 +63,13 @@ describe('DeliveryOrdersView permissions and commands', () => { await flushPromises() expect(listDeliveryReturnInboundOptions).toHaveBeenCalledWith('DEL/1', '100') await wrapper.find('[data-dialog="标记退货"] textarea').setValue(' 客户拒收 ') + await wrapper.find('[data-dialog="标记退货"] input[type="checkbox"]').setValue(true) + await wrapper.find('[data-dialog="标记退货"] input[type="number"]').setValue(2) await wrapper.find('[data-dialog="标记退货"] .dialog-confirm').trigger('click') await flushPromises() expect(markDeliveryReturn).toHaveBeenCalledWith('DEL/1', '100', { inboundOrderId: '9', reason: '客户拒收', returnDirection: 'OVERSEAS_WAREHOUSE', - returnLogisticsNo: undefined, packageCount: undefined, skuCode: undefined, + returnLogisticsNo: undefined, items: [{ outboundPalletItemId: '9007199254740995', inventoryPositionId: '9007199254740996', packageCount: 2 }], idempotencyKey: expect.any(String), }) wrapper.unmount() }) @@ -116,6 +118,71 @@ describe('DeliveryOrdersView permissions and commands', () => { wrapper.unmount() }) + it('keeps return hidden without the server action even when the user has permission', async () => { + canPermission.mockImplementation(code => code.endsWith(':return')) + vi.mocked(listDeliveryOrders).mockResolvedValue({ records: [{ ...row, status: 'RETURNED', allowedActions: [] }], page: 1, size: 10, total: 1, pages: 1 }) + const wrapper = mountView(); await flushPromises() + expect(wrapper.find('[data-testid="data-table"]').text()).not.toContain('标记退货') + expect(listDeliveryReturnInboundOptions).not.toHaveBeenCalled() + wrapper.unmount() + }) + + it('validates selected return sources and reuses the idempotency key after a failed request', async () => { + canPermission.mockImplementation(code => code.endsWith(':return')) + const wrapper = mountView(); await flushPromises() + try { + await wrapper.findAll('button').find(button => button.text().includes('标记退货'))!.trigger('click'); await flushPromises() + const dialog = wrapper.find('[data-dialog="标记退货"]') + expect(dialog.text()).toContain('PL-1 · SKU-1(最多 3 箱)') + expect(dialog.text()).not.toContain('9007199254740995') + expect(dialog.text()).not.toContain('可自由填写') + await dialog.find('textarea').setValue('拒收') + await dialog.find('.dialog-confirm').trigger('click') + expect(markDeliveryReturn).not.toHaveBeenCalled() + await dialog.find('input[type="checkbox"]').setValue(true) + for (const invalid of [0, 4, 1.5]) { + await dialog.find('input[type="number"]').setValue(invalid) + await dialog.find('.dialog-confirm').trigger('click') + expect(markDeliveryReturn).not.toHaveBeenCalled() + } + await dialog.find('input[type="number"]').setValue(2) + let reject!: (error: Error) => void + vi.mocked(markDeliveryReturn).mockReturnValueOnce(new Promise((_resolve, fail) => { reject = fail })) + await dialog.find('.dialog-confirm').trigger('click'); await dialog.find('.dialog-confirm').trigger('click') + expect(markDeliveryReturn).toHaveBeenCalledTimes(1) + const first = vi.mocked(markDeliveryReturn).mock.calls[0]![2] + reject(new Error('网络异常')); await flushPromises() + expect(dialog.text()).toContain('标记退货失败') + expect((dialog.find('textarea').element as HTMLTextAreaElement).value).toBe('拒收') + await dialog.find('.dialog-confirm').trigger('click'); await flushPromises() + expect(vi.mocked(markDeliveryReturn).mock.calls[1]![2]).toEqual(first) + expect(first.items).toEqual([{ outboundPalletItemId: '9007199254740995', inventoryPositionId: '9007199254740996', packageCount: 2 }]) + expect(first).not.toHaveProperty('skuCode') + expect(first).not.toHaveProperty('packageCount') + } finally { wrapper.unmount() } + }) + + it('clears the selected source when a different inbound order is chosen', async () => { + canPermission.mockImplementation(code => code.endsWith(':return')) + vi.mocked(listDeliveryReturnInboundOptions).mockResolvedValue([ + { inboundOrderId: '9', inboundCode: 'IN-1', customerId: '100', returnSources: [{ outboundPalletItemId: '11', inventoryPositionId: '12', palletNo: 'PL-1', skuCode: 'SKU-1', maxReturnPackages: 3 }] }, + { inboundOrderId: '10', inboundCode: 'IN-2', customerId: '100', returnSources: [] }, + ]) + const wrapper = mountView(); await flushPromises() + try { + await wrapper.findAll('button').find(button => button.text().includes('标记退货'))!.trigger('click'); await flushPromises() + const dialog = wrapper.find('[data-dialog="标记退货"]') + await dialog.find('select').setValue('9') + await dialog.find('input[type="checkbox"]').setValue(true) + await dialog.find('input[type="number"]').setValue(2) + await dialog.find('textarea').setValue('拒收') + await dialog.find('select').setValue('10') + expect(dialog.find('input[type="checkbox"]').exists()).toBe(false) + await dialog.find('.dialog-confirm').trigger('click') + expect(markDeliveryReturn).not.toHaveBeenCalled() + } finally { wrapper.unmount() } + }) + it('hides status editing when the server supplies no actions', async () => { canPermission.mockReturnValue(true) vi.mocked(listDeliveryOrders).mockResolvedValue({ records: [{ ...row, status: 'DELIVERED', allowedActions: [] }], page: 1, size: 10, total: 1, pages: 1 }) @@ -125,6 +192,39 @@ describe('DeliveryOrdersView permissions and commands', () => { wrapper.unmount() }) + it('selects completion through the real dialog and select, then retries a conflict with the refreshed version', async () => { + canPermission.mockImplementation(code => code.endsWith(':status')) + vi.mocked(getDeliveryOrder).mockResolvedValue({ order: { ...row, version: 7, status: 'DELIVERING', allowedActions: ['COMPLETE_DELIVERY'] }, skus: [], fees: [], operations: [] }) + vi.mocked(updateDeliveryOrderStatus).mockRejectedValueOnce({ isAxiosError: true, response: { status: 409, data: { code: 'A0409', message: '版本冲突' } } }) + const wrapper = mount(DeliveryOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus], stubs: { ...commonPhase5Stubs, AppDialog: false, ElInput: false, ElSelect: false, ElOption: false } } }) + const body = new DOMWrapper(document.body) + try { + await flushPromises() + await wrapper.findAll('button').find(button => button.text().includes('编辑状态'))!.trigger('click') + await flushPromises() + const dialog = body.find('[role="dialog"][aria-label="编辑派送状态"]') + const save = () => dialog.findAll('button').find(button => button.text() === '保存状态')! + expect(save().attributes('disabled')).toBeDefined() + await dialog.find('.el-select__wrapper').trigger('click') + await flushPromises() + const options = body.findAll('.el-select-dropdown__item') + expect(options.map(option => option.text())).toEqual(['派送完成']) + await options[0]!.trigger('click') + await dialog.find('textarea').setValue('已交付') + await save().trigger('click'); await flushPromises() + expect(updateDeliveryOrderStatus).toHaveBeenLastCalledWith('DEL/1', '100', { status: 'DELIVERED', expectedVersion: 7, remark: '已交付' }) + expect(save().attributes('disabled')).toBeDefined() + expect(dialog.text()).toContain('请刷新最新状态并重新确认') + vi.mocked(getDeliveryOrder).mockResolvedValue({ order: { ...row, version: 8, status: 'DELIVERING', allowedActions: ['COMPLETE_DELIVERY'] }, skus: [], fees: [], operations: [] }) + await dialog.findAll('button').find(button => button.text().includes('重新加载'))!.trigger('click') + await flushPromises() + expect((dialog.find('textarea').element as HTMLTextAreaElement).value).toBe('已交付') + await save().trigger('click'); await flushPromises() + expect(updateDeliveryOrderStatus).toHaveBeenLastCalledWith('DEL/1', '100', { status: 'DELIVERED', expectedVersion: 8, remark: '已交付' }) + expect(listDeliveryOrders).toHaveBeenCalledTimes(2) + } finally { wrapper.unmount() } + }) + it('renders category counts from the list without per-row document requests', async () => { canPermission.mockReturnValue(true) const documentSummary = { uploadedCategoryCount: 1, totalCategoryCount: 3, categories: deliveryDocumentCategories.map(item => ({ category: item.category, status: item.category === 'RECEIPT' ? 'UPLOADED' as const : 'MISSING' as const, fileCount: item.category === 'RECEIPT' ? 3 : 0 })) } diff --git a/src/modules/overseas-warehouse/pages/DeliveryOrdersView.vue b/src/modules/overseas-warehouse/pages/DeliveryOrdersView.vue index a1278f8c..0b99014e 100644 --- a/src/modules/overseas-warehouse/pages/DeliveryOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/DeliveryOrdersView.vue @@ -1,5 +1,5 @@ - + - +