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
-

提柜、卸柜与还空

确认还柜/还空
+

提柜、卸柜与还空

确认还柜
码头拖车状态
{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}
码头车队
{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}
预约提柜
{{ formatTime(workbenchDetail?.terminalDrayage?.pickupAppointmentAt) }}
实际提柜
{{ formatTime(workbenchDetail?.terminalDrayage?.actualPickupAt) }}
卸柜完成
{{ formatTime(workbenchDetail?.actualFacts?.unloadingCompletedAt as string | null) }}
实际还柜
{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}
@@ -542,7 +542,7 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action - + 选择文件 diff --git a/src/modules/container-loading/pages/ContainersView.test.ts b/src/modules/container-loading/pages/ContainersView.test.ts index 60c9e3f2..52509809 100644 --- a/src/modules/container-loading/pages/ContainersView.test.ts +++ b/src/modules/container-loading/pages/ContainersView.test.ts @@ -80,7 +80,7 @@ describe('ContainersView', () => { await router.push('/'); await router.isReady() const wrapper = mount(ContainersView, { props: { workbench: 'OVERSEAS' }, attachTo: document.body, global: { plugins: [ElementPlus, router] } }) await flushPromises() - const buttons = wrapper.findAll('button').filter((item) => item.text().includes('确认还柜/还空')) + const buttons = wrapper.findAll('button').filter((item) => item.text().includes('确认还柜')) expect(buttons).toHaveLength(1) await buttons[0].trigger('click') await flushPromises() @@ -98,7 +98,7 @@ describe('ContainersView', () => { accessCan.mockImplementation((code) => code !== 'platform:firstleg:terminal-drayage:empty-return:confirm') const wrapper = mount(ContainersView, { props: { workbench: 'OVERSEAS' }, global: { plugins: [ElementPlus, router] } }) await flushPromises() - expect(wrapper.findAll('button').some((item) => item.text().includes('确认还柜/还空'))).toBe(false) + expect(wrapper.findAll('button').some((item) => item.text().includes('确认还柜'))).toBe(false) wrapper.unmount() accessCan.mockReturnValue(true) const allowedWrapper = mount(ContainersView, { props: { workbench: 'OVERSEAS' }, global: { plugins: [ElementPlus, router] } }) @@ -106,7 +106,7 @@ describe('ContainersView', () => { const vm = allowedWrapper.vm as unknown as { rows: Array<{ allowedActions?: string[] }> } vm.rows[0].allowedActions = vm.rows[0].allowedActions?.filter((action) => action !== 'RECORD_EMPTY_RETURN') await allowedWrapper.vm.$nextTick() - expect(allowedWrapper.findAll('button').some((item) => item.text().includes('确认还柜/还空'))).toBe(false) + expect(allowedWrapper.findAll('button').some((item) => item.text().includes('确认还柜'))).toBe(false) }) it('defers form options until their dialogs open and caches successful loads', async () => { @@ -353,7 +353,7 @@ describe('ContainersView', () => { vm.openDrayageAction(vm.rows[0], 'RECORD_EMPTY_RETURN') vm.drayageActionAt = '2026-09-11T13:00:00' await vm.saveDrayageAction() - expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledWith('9001', { expectedVersion: 4, actualAt: '2026-09-11T13:00:00', reason: undefined }) + expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledWith('9001', { expectedVersion: 4, actualAt: '2026-09-11T13:00:00', remark: undefined }) expect(wrapper.text()).toContain('已还柜') wrapper.unmount() }) diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue index 386e584e..425719f1 100644 --- a/src/modules/container-loading/pages/ContainersView.vue +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -227,7 +227,7 @@ function documentStatusText(status: ContainerDocumentStatus | null | undefined) function formatTime(value: string | null | undefined) { return value?.replace('T', ' ').slice(0, 16) || '—' } function drayageFor(containerBookingId: string) { return drayages.value[containerBookingId] } function drayageStatusText(status?: string | null) { return ({ WAITING_FOR_ARRIVAL: '待到港', ARRIVED: '已到港', PICKUP_SCHEDULED: '已约提柜', PICKED_UP: '已提柜', UNLOADED: '已卸柜', EMPTY_RETURNED: '已还柜' } as Record)[status || ''] || status || '—' } -function drayageActionText(action: TerminalDrayageAction) { return ({ SELECT_FLEET: '选择码头车队', SCHEDULE_PICKUP: '预约提柜', RECORD_PICKUP: '确认实际提柜', RECORD_EMPTY_RETURN: '确认还柜/还空' } as Record)[action] } +function drayageActionText(action: TerminalDrayageAction) { return ({ SELECT_FLEET: '选择码头车队', SCHEDULE_PICKUP: '预约提柜', RECORD_PICKUP: '确认实际提柜', RECORD_EMPTY_RETURN: '确认还柜' } as Record)[action] } function hasAction(row: ContainerWorkbenchRow, action: ContainerWorkbenchAction) { return Boolean(row.allowedActions?.includes(action)) } function canDrayage(row: ContainerWorkbenchRow, action: TerminalDrayageAction) { const permission = action === 'SELECT_FLEET' ? canFleetEdit.value : action === 'SCHEDULE_PICKUP' ? canPickupAppointmentEdit.value : action === 'RECORD_PICKUP' ? canPickupConfirm.value : canEmptyReturnConfirm.value @@ -566,8 +566,8 @@ async function saveDrayageAction() { const next = drayageAction.value === 'SCHEDULE_PICKUP' ? await scheduleTerminalDrayagePickup(row.id, { expectedVersion: current.version, pickupAppointmentAt: drayageActionAt.value, reason }) : drayageAction.value === 'RECORD_PICKUP' - ? await recordTerminalDrayagePickup(row.id, { expectedVersion: current.version, actualAt: drayageActionAt.value, reason }) - : await recordTerminalDrayageEmptyReturn(row.id, { expectedVersion: current.version, actualAt: drayageActionAt.value, reason }) + ? await recordTerminalDrayagePickup(row.id, { expectedVersion: current.version, actualAt: drayageActionAt.value, remark: reason }) + : await recordTerminalDrayageEmptyReturn(row.id, { expectedVersion: current.version, actualAt: drayageActionAt.value, remark: reason }) replaceDrayage(next); drayageActionOpen.value = false ElMessage.success(`${drayageActionText(drayageAction.value)}成功`) } catch (error) { @@ -723,7 +723,7 @@ onMounted(async () => {
- + diff --git a/src/modules/loading-lists/components/ContainerFeesPanel.test.ts b/src/modules/loading-lists/components/ContainerFeesPanel.test.ts index 000e4e73..094a685c 100644 --- a/src/modules/loading-lists/components/ContainerFeesPanel.test.ts +++ b/src/modules/loading-lists/components/ContainerFeesPanel.test.ts @@ -48,7 +48,7 @@ describe('ContainerFeesPanel contract', () => { it('is exposed from the domestic container fee tab and derives customer context from real items', () => { expect(detail).not.toContain('ContainerFeesPanel') - expect(containerDetail).toContain('title="头程计费"') + expect(containerDetail).toContain('title="国内段费用(头程)"') expect(containerDetail).toContain(':container-booking-id="detail.id"') expect(containerDetail).toContain(':plan-id="overview.loadingPlanId"') expect(containerDetail).toContain('item.accountId')