Release/test 合并上生产 #2
+128
-2
@@ -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
Reference in New Issue
Block a user