Merge remote-tracking branch 'origin/release/test' into dev

# Conflicts:
#	src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts
#	src/modules/container-loading/pages/ContainersView.test.ts
This commit is contained in:
2026-09-09 13:02:53 +08:00
7 changed files with 395 additions and 25 deletions
@@ -7,11 +7,15 @@ import { getContainer, getContainerOverview } from '../api/platformContainerLoad
import { recordTerminalDrayageEmptyReturn } from '../api/terminalDrayages'
import { getContainerOutboundDispatch } from '@/modules/outbound-dispatch'
import { ref } from 'vue'
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<string, string> }))
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: '<div />' }, LoadingListDetailDrawer: { template: '<div />' }, getConfirmedLoadingList: vi.fn(), getPreloadPlan: vi.fn() }))
vi.mock('vue-router', () => ({ useRoute: () => routeState, useRouter: () => ({ push: routerPush }) }))
vi.mock('@/modules/loading-lists', () => ({ ContainerFeesPanel: { template: '<div />' }, LoadingListDetailDrawer: { name: 'LoadingListDetailDrawer', props: ['detail', 'error', 'loading'], template: '<div data-testid="loading-detail">{{ detail?.planNo }}</div>' }, getConfirmedLoadingList: vi.fn(), getPreloadPlan: vi.fn() }))
vi.mock('@/modules/outbound-dispatch', () => ({ confirmContainerOutboundDispatch: vi.fn(), getContainerOutboundDispatch: vi.fn() }))
vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '<div />' } }))
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() }))
@@ -30,6 +34,8 @@ async function mountDetail(workbench: 'DOMESTIC' | 'OVERSEAS' = 'OVERSEAS') {
describe('ContainerDetailView empty return', () => {
beforeEach(() => {
routeState.query = {}
routerPush.mockReset()
canAccess.mockReset().mockReturnValue(true)
vi.mocked(getContainerOutboundDispatch).mockReset().mockResolvedValue({ aggregate: { dispatchStatus: 'WAITING_FOR_LOADING', gate: { inbounds: [] } }, timeline: [] } as never)
vi.mocked(getContainer).mockReset().mockResolvedValue(base as never)
@@ -103,3 +109,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()
})
})
@@ -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 { defineComponent } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -14,6 +14,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() }))
@@ -43,6 +44,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())
@@ -77,10 +90,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')
@@ -102,6 +113,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()
@@ -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' } })
@@ -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<OverseasPagePayload<DeliveryOrderRow>>, ApiEnvelope<OverseasPagePayload<DeliveryOrderRow>>>(
@@ -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<ExceptionRelation[]>, ApiEnvelope<ExceptionRelation[]>>(
const response = await overseasHttp.get<ApiEnvelope<DeliveryReturnRelation[]>, ApiEnvelope<DeliveryReturnRelation[]>>(
`/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 }>, ApiEnvelope<{ id: string; exceptionNo: string; status: string }>>(
`/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}/mark-return`, payload, { params: { customerId } },
)
@@ -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'))
@@ -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<typeof import('../api/deliveryDocuments')>(), 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 })) }
File diff suppressed because one or more lines are too long