diff --git a/src/modules/container-loading/api/terminalDrayages.test.ts b/src/modules/container-loading/api/terminalDrayages.test.ts index 037d010f..524bf6e6 100644 --- a/src/modules/container-loading/api/terminalDrayages.test.ts +++ b/src/modules/container-loading/api/terminalDrayages.test.ts @@ -39,12 +39,12 @@ describe('terminal drayages API', () => { vi.mocked(http.post).mockResolvedValue({ data: view }) await selectTerminalDrayageFleet('101', { expectedVersion: 3, fleetSupplierId: '501', fleetSupplierName: '港运车队' }) await scheduleTerminalDrayagePickup('101', { expectedVersion: 4, pickupAppointmentAt: '2026-09-07T08:00:00' }) - await recordTerminalDrayagePickup('101', { expectedVersion: 5, actualAt: '2026-09-07T09:00:00' }) - await recordTerminalDrayageEmptyReturn('101', { expectedVersion: 6, actualAt: '2026-09-10T09:00:00' }) + await recordTerminalDrayagePickup('101', { expectedVersion: 5, actualAt: '2026-09-07T09:00:00', remark: '实际提柜' }) + await recordTerminalDrayageEmptyReturn('101', { expectedVersion: 6, actualAt: '2026-09-10T09:00:00', remark: '实际还柜' }) expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/terminal-drayages/101/fleet', expect.objectContaining({ expectedVersion: 3, fleetSupplierId: '501' }), expect.objectContaining({ headers: { 'Idempotency-Key': expect.stringContaining('terminal-drayage-fleet-') } })) expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/terminal-drayages/101/pickup-appointment', expect.objectContaining({ expectedVersion: 4 }), expect.any(Object)) - expect(http.post).toHaveBeenNthCalledWith(3, '/platform/v1/terminal-drayages/101/pickup', expect.objectContaining({ expectedVersion: 5 }), expect.any(Object)) - expect(http.post).toHaveBeenNthCalledWith(4, '/platform/v1/terminal-drayages/101/empty-return', expect.objectContaining({ expectedVersion: 6 }), expect.any(Object)) + expect(http.post).toHaveBeenNthCalledWith(3, '/platform/v1/terminal-drayages/101/pickup', expect.objectContaining({ expectedVersion: 5, remark: '实际提柜' }), expect.objectContaining({ headers: { 'Idempotency-Key': expect.stringContaining('terminal-drayage-pickup-') } })) + expect(http.post).toHaveBeenNthCalledWith(4, '/platform/v1/terminal-drayages/101/empty-return', expect.objectContaining({ expectedVersion: 6, remark: '实际还柜' }), expect.objectContaining({ headers: { 'Idempotency-Key': expect.stringContaining('terminal-drayage-empty-return-') } })) }) it('loads operation history from the container business id', async () => { diff --git a/src/modules/container-loading/api/terminalDrayages.ts b/src/modules/container-loading/api/terminalDrayages.ts index 10e18da4..82b200f8 100644 --- a/src/modules/container-loading/api/terminalDrayages.ts +++ b/src/modules/container-loading/api/terminalDrayages.ts @@ -93,9 +93,9 @@ export function selectTerminalDrayageFleet(containerBookingId: string, body: { e export function scheduleTerminalDrayagePickup(containerBookingId: string, body: { expectedVersion: number; pickupAppointmentAt: string; reason?: string }) { return command(containerBookingId, 'pickup-appointment', body) } -export function recordTerminalDrayagePickup(containerBookingId: string, body: { expectedVersion: number; actualAt: string; reason?: string }) { +export function recordTerminalDrayagePickup(containerBookingId: string, body: { expectedVersion: number; actualAt: string; remark?: string }) { return command(containerBookingId, 'pickup', body) } -export function recordTerminalDrayageEmptyReturn(containerBookingId: string, body: { expectedVersion: number; actualAt: string; reason?: string }) { +export function recordTerminalDrayageEmptyReturn(containerBookingId: string, body: { expectedVersion: number; actualAt: string; remark?: string }) { return command(containerBookingId, 'empty-return', body) } diff --git a/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts b/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts new file mode 100644 index 00000000..83e28ce5 --- /dev/null +++ b/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts @@ -0,0 +1,64 @@ +import ElementPlus from 'element-plus' +import { createPinia } from 'pinia' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +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' + +const canAccess = vi.hoisted(() => vi.fn<(code: string) => boolean>(() => true)) +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('@/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() })) +vi.mock('../api/terminalDrayages', () => ({ recordTerminalDrayageEmptyReturn: vi.fn() })) + +const base = { id: '9001', cabinetNo: 'CB-1', containerNo: 'MSCU-1', planNo: 'LP-1', planStatus: 'COMPLETED', warehouseName: '义乌仓', destinationWarehouseCode: 'LAX01', destinationWarehouseName: '洛杉矶接柜仓', workbenchStage: 'UNLOADED', statusSummary: { terminalDrayageStatus: 'UNLOADED' }, allowedActions: ['RECORD_EMPTY_RETURN'], terminalDrayage: { containerBookingId: '9001', status: 'UNLOADED', version: 3, fleetSupplierId: '501', fleetSupplierName: '港运车队', pickupAppointmentAt: '2026-09-07T08:00:00', actualPickupAt: '2026-09-07T09:00:00', estimatedEmptyReturnAt: '2026-09-10T10:00:00', actualEmptyReturnAt: null, unloadingCompletedAt: '2026-09-07T10:00:00', emptyReturnWarning: { status: 'PENDING', deadlineAt: '2026-09-10T10:00:00', overdueHours: 0, overdueDays: 0 }, allowedActions: ['RECORD_EMPTY_RETURN'], updatedAt: '2026-09-09T10:00:00' }, actualFacts: { unloadingCompletedAt: '2026-09-07T10:00:00' } } +const overview = { container: { id: '9001', version: 1 }, loadingPlanId: null, documents: [], exception: { active: false }, timeline: [] } + +enableAutoUnmount(afterEach) + +async function mountDetail() { + const wrapper = mount(ContainerDetailView, { props: { workbench: 'OVERSEAS' }, attachTo: document.body, global: { plugins: [createPinia(), ElementPlus], stubs: { ContainerOverviewPanel: true, ContainerCustomsPanel: true, ContainerLastLegFeesPanel: true } } }) + await flushPromises() + return wrapper +} + +describe('ContainerDetailView empty return', () => { + beforeEach(() => { + canAccess.mockReset().mockReturnValue(true) + vi.mocked(getContainer).mockReset().mockResolvedValue(base as never) + vi.mocked(getContainerOverview).mockReset().mockResolvedValue(overview as never) + vi.mocked(recordTerminalDrayageEmptyReturn).mockReset().mockResolvedValue({ ...base.terminalDrayage, status: 'EMPTY_RETURNED', version: 4, actualEmptyReturnAt: '2026-09-09T12:00:00', allowedActions: [] } as never) + }) + + it('uses the overseas stage, submits one versioned command and refreshes to returned state', async () => { + vi.mocked(getContainer).mockResolvedValueOnce(base as never).mockResolvedValue({ ...base, workbenchStage: 'EMPTY_RETURNED', statusSummary: { terminalDrayageStatus: 'EMPTY_RETURNED' }, allowedActions: [], terminalDrayage: { ...base.terminalDrayage, status: 'EMPTY_RETURNED', version: 4, actualEmptyReturnAt: '2026-09-09T12:00:00', allowedActions: [] } } as never) + const wrapper = await mountDetail() + expect(wrapper.text()).toContain('待还柜') + expect(wrapper.text()).not.toContain('装柜完成') + await wrapper.findAll('button').find((item) => item.text() === '确认还柜')!.trigger('click') + await flushPromises() + const confirm = [...document.body.querySelectorAll('button')].filter((item) => item.textContent?.trim() === '确认还柜').at(-1) as HTMLButtonElement + confirm.click() + await flushPromises() + expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledTimes(1) + expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledWith('9001', expect.objectContaining({ expectedVersion: 3, actualAt: expect.any(String), remark: undefined })) + expect(getContainer).toHaveBeenCalledTimes(2) + expect(wrapper.text()).toContain('已还柜') + expect(wrapper.find('.detail-section__heading').findAll('button').some((item) => item.text() === '确认还柜')).toBe(false) + }) + + it.each([ + ['缺权限', false, ['RECORD_EMPTY_RETURN'], ['RECORD_EMPTY_RETURN'], 'UNLOADED'], + ['缺服务端动作', true, [], [], 'UNLOADED'], + ['已还柜', true, [], [], 'EMPTY_RETURNED'], + ])('%s时不提供确认还柜', async (_name, allowed, rowActions, drayageActions, status) => { + canAccess.mockImplementation((code) => code !== 'platform:firstleg:terminal-drayage:empty-return:confirm' || allowed) + vi.mocked(getContainer).mockResolvedValue({ ...base, workbenchStage: status, statusSummary: { terminalDrayageStatus: status }, allowedActions: rowActions, terminalDrayage: { ...base.terminalDrayage, status, allowedActions: drayageActions } } as never) + const wrapper = await mountDetail() + expect(wrapper.findAll('button').some((item) => item.text() === '确认还柜')).toBe(false) + }) +}) diff --git a/src/modules/container-loading/pages/ContainerDetailView.test.ts b/src/modules/container-loading/pages/ContainerDetailView.test.ts index d4c7c7c4..7eca8d75 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.test.ts +++ b/src/modules/container-loading/pages/ContainerDetailView.test.ts @@ -60,9 +60,10 @@ describe('ContainerDetailView customs integration', () => { expect(source).toContain("hasAction('RECORD_EMPTY_RETURN')") expect(source).toContain("terminalDrayage?.allowedActions.includes('RECORD_EMPTY_RETURN')") expect(source).toContain('v-if="canRecordEmptyReturn" type="primary"') - expect(source).toContain('>确认还柜/还空') + expect(source).toContain('>确认还柜') expect(source).toContain('expectedVersion: current.version') expect(source).toContain('actualAt: emptyReturnAt.value') + expect(source).toContain('remark: emptyReturnReason.value.trim() || undefined') expect(source).toContain('await load()') }) @@ -83,7 +84,7 @@ describe('ContainerDetailView customs integration', () => { expect(lastLegFeesSource).toContain('尾程计费加载失败') expect(lastLegFeesSource).toContain('empty-text="当前货柜暂无尾程费用"') expect(lastLegFeesSource).toContain('v-loading="loading"') - expect(lastLegFeesSource).toContain('Promise.all([listOverseasContainerFees(props.containerBookingId), listOverseasContainerCustomers(props.containerBookingId), listContainerFeeSupplierOptions(null)])') + expect(lastLegFeesSource).toContain('props.readonly ? Promise.resolve([]) : listContainerFeeSupplierOptions(null)') expect(lastLegFeesSource).toContain("sourceType: 'OVERSEAS_CONTAINER_FEE', businessType: 'OVERSEAS_CONTAINER'") expect(lastLegFeesSource).toContain("form.settlementMode === 'DIRECT_PAID'") expect(lastLegFeesSource).toContain('uploadContainerFeePaymentVoucher(form.customerId, file.raw)') diff --git a/src/modules/container-loading/pages/ContainerDetailView.vue b/src/modules/container-loading/pages/ContainerDetailView.vue index 61c9db12..ac63217f 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.vue +++ b/src/modules/container-loading/pages/ContainerDetailView.vue @@ -243,13 +243,13 @@ async function confirmEmptyReturn() { await recordTerminalDrayageEmptyReturn(containerId.value, { expectedVersion: current.version, actualAt: emptyReturnAt.value, - reason: emptyReturnReason.value.trim() || undefined, + remark: emptyReturnReason.value.trim() || undefined, }) emptyReturnOpen.value = false - ElMessage.success('还柜/还空已确认') + ElMessage.success('还柜已确认') await load() } catch (cause) { - const details = getApiErrorDetails(cause, '确认还柜/还空失败') + const details = getApiErrorDetails(cause, '确认还柜失败') if (details.code === 'A0409') await load() emptyReturnError.value = details } finally { emptyReturnSaving.value = false } @@ -520,7 +520,7 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action