From 74d883a443e409561121a8641dc9f70bc5912890 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 7 Sep 2026 23:58:18 +0800 Subject: [PATCH] feat: add tail order controls and terminal drayage UI --- .../api/terminalDrayages.test.ts | 56 ++++++ .../container-loading/api/terminalDrayages.ts | 101 +++++++++++ .../pages/ContainersView.test.ts | 58 ++++++ .../pages/ContainersView.vue | 167 +++++++++++++++++- .../api/lastMileOrders.test.ts | 29 ++- .../overseas-warehouse/api/lastMileOrders.ts | 109 ++++++++++-- .../lastMilePresentation.ts | 5 + .../layoutAcceptance.test.ts | 2 +- .../pages/LastMileOrdersView.behavior.test.ts | 25 ++- .../pages/LastMileOrdersView.vue | 158 ++++++++++++++--- src/modules/overseas-warehouse/types.ts | 5 + 11 files changed, 664 insertions(+), 51 deletions(-) create mode 100644 src/modules/container-loading/api/terminalDrayages.test.ts create mode 100644 src/modules/container-loading/api/terminalDrayages.ts diff --git a/src/modules/container-loading/api/terminalDrayages.test.ts b/src/modules/container-loading/api/terminalDrayages.test.ts new file mode 100644 index 00000000..037d010f --- /dev/null +++ b/src/modules/container-loading/api/terminalDrayages.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { + getTerminalDrayageHistory, + listTerminalDrayages, + recordTerminalDrayageEmptyReturn, + recordTerminalDrayagePickup, + scheduleTerminalDrayagePickup, + selectTerminalDrayageFleet, +} from './terminalDrayages' + +vi.mock('@/shared/api/http', () => ({ + http: { get: vi.fn(), post: vi.fn() }, + unwrapApiResponse: (response: { data: T }) => response.data, +})) + +const view = { + containerBookingId: '9007199254740993', containerNo: 'MSCU1234567', oceanBookingId: '9007199254740994', + status: 'UNLOADED', version: '3', fleetSupplierId: '9007199254740995', 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: 'OVERDUE', deadlineAt: '2026-09-10T10:00:00', overdueHours: '26', overdueDays: '1' }, + allowedActions: ['SELECT_FLEET', 'RECORD_EMPTY_RETURN'], updatedAt: '2026-09-11T12:00:00', +} + +describe('terminal drayages API', () => { + beforeEach(() => vi.clearAllMocks()) + + it('sends repeated container ids and preserves long identifiers as strings', async () => { + vi.mocked(http.get).mockResolvedValueOnce({ data: [view] }) + const result = await listTerminalDrayages(['9007199254740993', '9007199254740994']) + const config = vi.mocked(http.get).mock.calls[0][1] as { params: URLSearchParams } + expect(config.params.getAll('containerBookingIds')).toEqual(['9007199254740993', '9007199254740994']) + expect(result[0]).toMatchObject({ containerBookingId: '9007199254740993', oceanBookingId: '9007199254740994', fleetSupplierId: '9007199254740995', version: 3 }) + expect(result[0].emptyReturnWarning).toMatchObject({ overdueHours: 26, overdueDays: 1 }) + }) + + it('uses frozen command paths, versions and idempotency keys', async () => { + 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' }) + 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)) + }) + + it('loads operation history from the container business id', async () => { + vi.mocked(http.get).mockResolvedValueOnce({ data: [{ action: 'SELECT_FLEET', fromStatus: 'ARRIVED', toStatus: 'ARRIVED', fromVersion: 0, toVersion: 1, reason: null, operatorId: '9007199254740993', operatorName: '平台管理员', createdAt: '2026-09-07T08:00:00' }] }) + const result = await getTerminalDrayageHistory('101') + expect(http.get).toHaveBeenCalledWith('/platform/v1/terminal-drayages/101/history') + expect(result[0].operatorId).toBe('9007199254740993') + }) +}) diff --git a/src/modules/container-loading/api/terminalDrayages.ts b/src/modules/container-loading/api/terminalDrayages.ts new file mode 100644 index 00000000..10e18da4 --- /dev/null +++ b/src/modules/container-loading/api/terminalDrayages.ts @@ -0,0 +1,101 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + +export type TerminalDrayageStatus = 'WAITING_FOR_ARRIVAL' | 'ARRIVED' | 'PICKUP_SCHEDULED' | 'PICKED_UP' | 'UNLOADED' | 'EMPTY_RETURNED' +export type TerminalDrayageAction = 'SELECT_FLEET' | 'SCHEDULE_PICKUP' | 'RECORD_PICKUP' | 'RECORD_EMPTY_RETURN' +export type EmptyReturnWarningStatus = 'NOT_APPLICABLE' | 'PENDING' | 'OVERDUE' | 'RESOLVED' + +export interface EmptyReturnWarning { + status: EmptyReturnWarningStatus + deadlineAt: string | null + overdueHours: number + overdueDays: number +} + +export interface TerminalDrayageView { + containerBookingId: string + containerNo: string | null + oceanBookingId: string | null + status: TerminalDrayageStatus + version: number + fleetSupplierId: string | null + fleetSupplierName: string | null + pickupAppointmentAt: string | null + actualPickupAt: string | null + estimatedEmptyReturnAt: string | null + actualEmptyReturnAt: string | null + unloadingCompletedAt: string | null + emptyReturnWarning: EmptyReturnWarning + allowedActions: TerminalDrayageAction[] + updatedAt: string | null +} + +export interface TerminalDrayageHistory { + action: TerminalDrayageAction + fromStatus: string + toStatus: string + fromVersion: number + toVersion: number + reason: string | null + operatorId: string + operatorName: string | null + createdAt: string +} + +const path = '/platform/v1/terminal-drayages' +function operationId(action: string) { return `terminal-drayage-${action}-${Date.now()}-${Math.random().toString(36).slice(2)}` } +function id(value: unknown): string { return value == null ? '' : String(value) } +function optionalId(value: unknown): string | null { return value == null ? null : String(value) } +function normalize(raw: TerminalDrayageView): TerminalDrayageView { + return { + ...raw, + containerBookingId: id(raw.containerBookingId), + oceanBookingId: optionalId(raw.oceanBookingId), + fleetSupplierId: optionalId(raw.fleetSupplierId), + version: Number(raw.version || 0), + allowedActions: raw.allowedActions || [], + emptyReturnWarning: { + ...raw.emptyReturnWarning, + overdueHours: Number(raw.emptyReturnWarning?.overdueHours || 0), + overdueDays: Number(raw.emptyReturnWarning?.overdueDays || 0), + }, + } +} + +export async function listTerminalDrayages(containerBookingIds: string[]) { + const params = new URLSearchParams() + containerBookingIds.forEach((containerBookingId) => params.append('containerBookingIds', containerBookingId)) + const response = await http.get, ApiEnvelope>(path, { params }) + return (unwrapApiResponse(response) || []).map(normalize) +} + +export async function getTerminalDrayage(containerBookingId: string) { + const response = await http.get, ApiEnvelope>(`${path}/${encodeURIComponent(containerBookingId)}`) + return normalize(unwrapApiResponse(response)) +} + +export async function getTerminalDrayageHistory(containerBookingId: string) { + const response = await http.get, ApiEnvelope>(`${path}/${encodeURIComponent(containerBookingId)}/history`) + return (unwrapApiResponse(response) || []).map((item) => ({ ...item, operatorId: id(item.operatorId) })) +} + +async function command(containerBookingId: string, action: string, body: object) { + const response = await http.post, ApiEnvelope>( + `${path}/${encodeURIComponent(containerBookingId)}/${action}`, + body, + { headers: { 'Idempotency-Key': operationId(action) } }, + ) + return normalize(unwrapApiResponse(response)) +} + +export function selectTerminalDrayageFleet(containerBookingId: string, body: { expectedVersion: number; fleetSupplierId: string; fleetSupplierName?: string; reason?: string }) { + return command(containerBookingId, 'fleet', body) +} +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 }) { + return command(containerBookingId, 'pickup', body) +} +export function recordTerminalDrayageEmptyReturn(containerBookingId: string, body: { expectedVersion: number; actualAt: string; reason?: string }) { + return command(containerBookingId, 'empty-return', body) +} diff --git a/src/modules/container-loading/pages/ContainersView.test.ts b/src/modules/container-loading/pages/ContainersView.test.ts index 116af506..a112211e 100644 --- a/src/modules/container-loading/pages/ContainersView.test.ts +++ b/src/modules/container-loading/pages/ContainersView.test.ts @@ -11,6 +11,7 @@ import { listPlatformPartners } from '@/modules/master-data' import { listSuppliers } from '@/modules/suppliers' import { getOceanBooking, listOceanBookingLinkCandidates, listOceanBookings } from '@/modules/ocean-bookings' import { getContainer, getContainerOverview, linkContainerOceanBooking, listContainers, updateContainer } from '../api/platformContainerLoading' +import { getTerminalDrayage, getTerminalDrayageHistory, listTerminalDrayages, recordTerminalDrayageEmptyReturn, selectTerminalDrayageFleet } from '../api/terminalDrayages' import AppDialog from '@/shared/components/AppDialog.vue' vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() })) @@ -21,6 +22,15 @@ vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue const accessCan = vi.hoisted(() => vi.fn()) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: accessCan }) })) vi.mock('../api/platformContainerLoading', () => ({ listContainers: vi.fn(), getContainer: vi.fn(), getContainerOverview: vi.fn(), getContainerClearance: vi.fn(), uploadContainerClearance: vi.fn(), completeContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(), downloadContainerDocument: vi.fn(), downloadContainerPdaPhoto: vi.fn(), markContainerException: vi.fn(), cancelContainerException: vi.fn(), createContainerSimulation: vi.fn(), linkContainerOceanBooking: vi.fn(), unlinkContainerOceanBooking: vi.fn(), updateContainer: vi.fn() })) +vi.mock('../api/terminalDrayages', () => ({ listTerminalDrayages: vi.fn(), getTerminalDrayage: vi.fn(), getTerminalDrayageHistory: vi.fn(), selectTerminalDrayageFleet: vi.fn(), scheduleTerminalDrayagePickup: vi.fn(), recordTerminalDrayagePickup: vi.fn(), recordTerminalDrayageEmptyReturn: vi.fn() })) + +const drayage = { + containerBookingId: '9001', containerNo: 'MSCU-1', oceanBookingId: '101', status: 'UNLOADED' as const, 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: 'OVERDUE' as const, deadlineAt: '2026-09-10T10:00:00', overdueHours: 26, overdueDays: 1 }, + allowedActions: ['SELECT_FLEET', 'RECORD_EMPTY_RETURN'] as const, updatedAt: '2026-09-11T12:00:00', +} function createTestRouter() { return createRouter({ history: createMemoryHistory(), routes: [ @@ -41,6 +51,11 @@ describe('ContainersView', () => { vi.mocked(listOceanBookings).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 }) vi.mocked(listOceanBookingLinkCandidates).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) vi.mocked(getOceanBooking).mockResolvedValue({} as never) + vi.mocked(listTerminalDrayages).mockResolvedValue([{ ...drayage, allowedActions: [...drayage.allowedActions] }]) + vi.mocked(getTerminalDrayage).mockResolvedValue({ ...drayage, allowedActions: [...drayage.allowedActions] }) + vi.mocked(getTerminalDrayageHistory).mockResolvedValue([]) + vi.mocked(selectTerminalDrayageFleet).mockResolvedValue({ ...drayage, fleetSupplierId: '502', fleetSupplierName: '新车队', version: 4, allowedActions: [...drayage.allowedActions] }) + vi.mocked(recordTerminalDrayageEmptyReturn).mockResolvedValue({ ...drayage, status: 'EMPTY_RETURNED', actualEmptyReturnAt: '2026-09-11T13:00:00', version: 4, emptyReturnWarning: { status: 'RESOLVED', deadlineAt: '2026-09-10T10:00:00', overdueHours: 0, overdueDays: 0 }, allowedActions: ['SELECT_FLEET'] }) vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 }) vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }) vi.mocked(getContainerOverview).mockResolvedValue({ container: { id: '9001', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'WAIT_LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: null, vesselName: null, version: 0 }, loadingPlanId: null, pda: null, documents: [], exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [] }) @@ -57,6 +72,9 @@ describe('ContainersView', () => { expect(wrapper.text()).toContain('MSCU-1') expect(wrapper.text()).toContain('宁波 → 长滩') expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(true) + expect(listTerminalDrayages).toHaveBeenCalledWith(['9001']) + expect(wrapper.text()).toContain('超时 1 天') + expect(wrapper.text()).toContain('2026-09-10 10:00') }) it('keeps the cross-warehouse list usable when warehouse form options fail', async () => { @@ -243,6 +261,46 @@ describe('ContainersView', () => { expect(source).toContain(':loading="loading" stripe preference-key="container-loading-table"') }) + it('updates an ACTIVE TRUCKING fleet inline and records actual empty return once', async () => { + vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '502', name: '新车队', category: 'TRUCKING', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 }) + const router = createTestRouter() + await router.push('/'); await router.isReady() + const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } }) + await flushPromises() + + const vm = wrapper.vm as unknown as { + rows: Array> + selectRowFleet: (row: Record, supplierId: string) => Promise + openDrayageAction: (row: Record, action: 'RECORD_EMPTY_RETURN') => void + drayageActionAt: string + saveDrayageAction: () => Promise + } + await vm.selectRowFleet(vm.rows[0], '502') + expect(selectTerminalDrayageFleet).toHaveBeenCalledWith('9001', { expectedVersion: 3, fleetSupplierId: '502', fleetSupplierName: '新车队' }) + + 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(wrapper.text()).toContain('已还柜') + wrapper.unmount() + }) + + it('loads terminal drayage detail and history only when requested', async () => { + vi.mocked(getTerminalDrayageHistory).mockResolvedValueOnce([{ action: 'SELECT_FLEET', fromStatus: 'ARRIVED', toStatus: 'ARRIVED', fromVersion: 0, toVersion: 1, reason: '到港安排', operatorId: '88', operatorName: '管理员', createdAt: '2026-09-07T08:00:00' }]) + const router = createTestRouter() + await router.push('/'); await router.isReady() + const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } }) + await flushPromises() + const vm = wrapper.vm as unknown as { rows: Array>; openDrayageHistory: (row: Record) => Promise } + await vm.openDrayageHistory(vm.rows[0]) + await flushPromises() + expect(getTerminalDrayage).toHaveBeenCalledWith('9001') + expect(getTerminalDrayageHistory).toHaveBeenCalledWith('9001') + expect(wrapper.text()).toContain('到港安排') + wrapper.unmount() + }) + it('opens the existing released-booking workflow when entered from container detail', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8') expect(source).toContain('route.query.linkContainerId') diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue index bc578e49..0f6631cd 100644 --- a/src/modules/container-loading/pages/ContainersView.vue +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -19,6 +19,18 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone import { CONTAINER_SPECIFICATIONS, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { createContainerSimulation, getContainer, linkContainerOceanBooking, listContainers, updateContainer } from '../api/platformContainerLoading' +import { + getTerminalDrayage, + getTerminalDrayageHistory, + listTerminalDrayages, + recordTerminalDrayageEmptyReturn, + recordTerminalDrayagePickup, + scheduleTerminalDrayagePickup, + selectTerminalDrayageFleet, + type TerminalDrayageAction, + type TerminalDrayageHistory, + type TerminalDrayageView, +} from '../api/terminalDrayages' import type { ContainerBookingResource, ContainerDocumentStatus, ContainerPlanStatus, ContainerSaveRequest, ContainerSimulationCreateRequest } from '../types' type Filters = QueryModel & { customerId: string; keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] } @@ -68,9 +80,25 @@ const linkContainerNo = ref('') const linkCandidates = ref>([]) const selectedTruckingSupplierId = ref('') const unresolvedTruckingSupplierName = ref(null) +const drayages = ref>({}) +const drayageLoading = ref(false) +const drayageLoadError = ref(null) +const drayageSavingIds = ref([]) +const drayageActionOpen = ref(false) +const drayageAction = ref>('SCHEDULE_PICKUP') +const drayageTarget = ref(null) +const drayageActionAt = ref('') +const drayageReason = ref('') +const drayageActionSaving = ref(false) +const drayageActionError = ref(null) +const drayageHistoryOpen = ref(false) +const drayageHistory = ref([]) +const drayageHistoryLoading = ref(false) +const drayageHistoryError = ref(null) let listSequence = 0 let detailSequence = 0 let linkSequence = 0 +let drayageSequence = 0 function emptyForm(): ContainerForm { return { @@ -140,6 +168,7 @@ const queryFields = computed(() => [ ]) const columns: DataTableColumn[] = [ { key: 'actions', label: '操作', width: 310, fixed: 'left', slotName: 'actions' }, + { key: 'emptyReturnWarning', label: '报空预警', width: 120, slotName: 'emptyReturnWarning' }, { key: 'containerNo', label: '集装箱号', minWidth: 150, slotName: 'containerNo' }, { key: 'planNo', prop: 'planNo', label: '装柜清单号', minWidth: 165, formatter: (row: ContainerBookingResource) => row.planNo || '—' }, { key: 'planStatus', label: '状态', width: 110, slotName: 'status' }, @@ -150,7 +179,11 @@ const columns: DataTableColumn[] = [ { key: 'pickupOrderDocumentStatus', label: 'PU资料', width: 100, formatter: (row: ContainerBookingResource) => documentStatusText(row.pickupOrderDocumentStatus) }, { key: 'oceanBooking', label: '真实订舱', minWidth: 170, slotName: 'oceanBooking' }, { key: 'warehouseName', prop: 'warehouseName', label: '装柜仓库', minWidth: 160 }, - { key: 'terminalTruckingCompany', prop: 'terminalTruckingCompany', label: '码头车队', minWidth: 160 }, + { key: 'terminalTruckingCompany', label: '码头车队', width: 190, slotName: 'terminalTruckingCompany' }, + { key: 'pickupAppointmentAt', label: '预约提柜', width: 165, slotName: 'pickupAppointmentAt' }, + { key: 'actualPickupAt', label: '实际提柜', width: 165, slotName: 'actualPickupAt' }, + { key: 'estimatedEmptyReturnAt', label: '预计还柜', width: 165, slotName: 'estimatedEmptyReturnAt' }, + { key: 'actualEmptyReturnAt', label: '实际还柜', width: 165, slotName: 'actualEmptyReturnAt' }, { key: 'billOfLadingNo', prop: 'billOfLadingNo', label: '提单号', minWidth: 150 }, { key: 'route', label: '航线', minWidth: 220, slotName: 'route' }, { key: 'containerType', prop: 'containerType', label: '柜型', width: 100 }, @@ -163,12 +196,45 @@ const columns: DataTableColumn[] = [ { key: 'estimatedPortArrivalAt', label: '预计到港', minWidth: 160, slotName: 'estimatedPortArrivalAt' }, { key: 'actualPortArrivalAt', label: '实际到港', minWidth: 160, slotName: 'actualPortArrivalAt' }, ] +const drayageHistoryColumns: DataTableColumn[] = [ + { key: 'action', label: '动作', width: 130, formatter: (row: TerminalDrayageHistory) => drayageActionText(row.action) }, + { key: 'status', label: '状态变化', minWidth: 180, formatter: (row: TerminalDrayageHistory) => `${drayageStatusText(row.fromStatus)} → ${drayageStatusText(row.toStatus)}` }, + { key: 'version', label: '版本', width: 100, formatter: (row: TerminalDrayageHistory) => `${row.fromVersion} → ${row.toVersion}` }, + { key: 'operatorName', label: '操作人', width: 130, formatter: (row: TerminalDrayageHistory) => row.operatorName || '—' }, + { key: 'createdAt', label: '操作时间', width: 170, formatter: (row: TerminalDrayageHistory) => formatTime(row.createdAt) }, + { key: 'reason', label: '原因', minWidth: 200 }, +] function optional(value: string | null) { return value?.trim() || null } function normalizedContainerNo(value: string | null | undefined) { return String(value || '').trim().toUpperCase() } function statusText(status: ContainerPlanStatus) { return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] } function documentStatusText(status: ContainerDocumentStatus | null | undefined) { return status ? ({ MISSING: '未上传', UPLOADED: '已上传', COMPLETED: '已完成' })[status] : '—' } 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 canDrayage(row: ContainerBookingResource, action: TerminalDrayageAction) { return Boolean(canEdit.value && drayageFor(row.id)?.allowedActions.includes(action)) } +function drayageWarningText(row: ContainerBookingResource) { + const warning = drayageFor(row.id)?.emptyReturnWarning + if (!warning || warning.status === 'NOT_APPLICABLE') return '—' + if (warning.status === 'RESOLVED') return '已还柜' + if (warning.status === 'OVERDUE') return warning.overdueDays > 0 ? `超时 ${warning.overdueDays} 天` : `超时 ${warning.overdueHours} 小时` + return '待还柜' +} +function drayageWarningType(row: ContainerBookingResource) { + const status = drayageFor(row.id)?.emptyReturnWarning.status + return status === 'OVERDUE' ? 'danger' : status === 'RESOLVED' ? 'success' : status === 'PENDING' ? 'warning' : 'info' +} +function drayageWarningTitle(row: ContainerBookingResource) { + const warning = drayageFor(row.id)?.emptyReturnWarning + if (!warning) return '' + const deadline = warning.deadlineAt ? `还柜截止:${formatTime(warning.deadlineAt)}` : '' + return [drayageWarningText(row), deadline].filter(Boolean).join(';') +} +function localDateTimeValue() { + const now = new Date(Date.now() - new Date().getTimezoneOffset() * 60_000) + return now.toISOString().slice(0, 19) +} function containerViewLabel(row: ContainerBookingResource) { const reference = [row.cabinetNo, row.containerNo, row.bookingNo] .find((value) => typeof value === 'string' && value.trim()) @@ -408,6 +474,81 @@ async function saveLink() { } catch (error) { linkError.value = getApiErrorDetails(error, '关联真实订舱柜失败') } finally { linkSaving.value = false } } +function replaceDrayage(value: TerminalDrayageView) { + drayages.value = { ...drayages.value, [value.containerBookingId]: value } +} +async function loadDrayages(targetRows = rows.value) { + const sequence = ++drayageSequence + if (!targetRows.length) { drayages.value = {}; drayageLoadError.value = null; return } + drayageLoading.value = true; drayageLoadError.value = null + try { + const values = await listTerminalDrayages(targetRows.map((row) => row.id)) + if (sequence !== drayageSequence) return + drayages.value = Object.fromEntries(values.map((value) => [value.containerBookingId, value])) + } catch (error) { + if (sequence !== drayageSequence) return + drayages.value = {} + drayageLoadError.value = getApiErrorDetails(error, '码头拖车信息加载失败,请重试') + } finally { if (sequence === drayageSequence) drayageLoading.value = false } +} +async function refreshDrayage(containerBookingId: string) { + const value = await getTerminalDrayage(containerBookingId) + replaceDrayage(value) + return value +} +async function selectRowFleet(source: object, supplierIdValue: unknown) { + const row = source as ContainerBookingResource + const current = drayageFor(row.id) + const supplier = truckingCompanies.value.find((item) => item.id === String(supplierIdValue || '')) + if (!current || !supplier || !canDrayage(row, 'SELECT_FLEET') || drayageSavingIds.value.includes(row.id)) return + drayageSavingIds.value = [...drayageSavingIds.value, row.id] + try { + replaceDrayage(await selectTerminalDrayageFleet(row.id, { expectedVersion: current.version, fleetSupplierId: supplier.id, fleetSupplierName: supplier.name })) + ElMessage.success('码头车队已更新') + } catch (error) { + const details = getApiErrorDetails(error, '码头车队更新失败') + if (details.code === 'A0409') { + try { await refreshDrayage(row.id) } catch { /* 保留原始冲突证据 */ } + ElMessage.warning('运输信息已被其他人修改,已刷新当前柜最新状态') + } else ElMessage.error(details.message) + } finally { drayageSavingIds.value = drayageSavingIds.value.filter((id) => id !== row.id) } +} +function openDrayageAction(row: ContainerBookingResource, action: Exclude) { + if (!canDrayage(row, action)) return + drayageTarget.value = row; drayageAction.value = action; drayageActionAt.value = localDateTimeValue(); drayageReason.value = '' + drayageActionError.value = null; drayageActionOpen.value = true +} +async function saveDrayageAction() { + const row = drayageTarget.value + const current = row ? drayageFor(row.id) : null + if (!row || !current || !drayageActionAt.value || !canDrayage(row, drayageAction.value) || drayageActionSaving.value) return + drayageActionSaving.value = true; drayageActionError.value = null + try { + const reason = drayageReason.value.trim() || undefined + 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 }) + replaceDrayage(next); drayageActionOpen.value = false + ElMessage.success(`${drayageActionText(drayageAction.value)}成功`) + } catch (error) { + const details = getApiErrorDetails(error, `${drayageActionText(drayageAction.value)}失败`) + if (details.code === 'A0409') { + try { await refreshDrayage(row.id) } catch { /* 保留原始冲突证据 */ } + drayageActionError.value = { ...details, message: '运输信息已被其他人修改,已刷新最新状态;请重新确认后提交' } + } else drayageActionError.value = details + } finally { drayageActionSaving.value = false } +} +async function openDrayageHistory(row: ContainerBookingResource) { + drayageTarget.value = row; drayageHistory.value = []; drayageHistoryError.value = null; drayageHistoryOpen.value = true; drayageHistoryLoading.value = true + try { + const [current, history] = await Promise.all([getTerminalDrayage(row.id), getTerminalDrayageHistory(row.id)]) + if (drayageTarget.value?.id !== row.id) return + replaceDrayage(current); drayageHistory.value = history + } catch (error) { if (drayageTarget.value?.id === row.id) drayageHistoryError.value = getApiErrorDetails(error, '码头拖车记录加载失败') } + finally { if (drayageTarget.value?.id === row.id) drayageHistoryLoading.value = false } +} async function loadRows() { const sequence = ++listSequence loading.value = true; loadError.value = null @@ -421,6 +562,7 @@ async function loadRows() { }) if (sequence !== listSequence) return rows.value = result.records; total.value = result.total + await loadDrayages(result.records) } catch (error) { if (sequence !== listSequence) return rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '货柜列表加载失败,请稍后重试') @@ -435,6 +577,8 @@ function handleMoreAction(command: string, source: object) { const row = source as ContainerBookingResource if (command === 'link') { void openLink(row); return } if (command === 'overview') { openDetail(row); return } + if (command === 'drayage-history') { void openDrayageHistory(row); return } + if (command.startsWith('drayage:')) { openDrayageAction(row, command.slice('drayage:'.length) as Exclude); return } if (['exception', 'documents'].includes(command)) void router.push({ name: 'container-detail', params: { containerId: row.id }, query: { action: command } }) } async function openEdit(source: object) { @@ -534,12 +678,19 @@ onMounted(async () => {
+ 重新加载码头拖车信息 - + + + + + + + @@ -595,12 +746,22 @@ onMounted(async () => { + + + + + + + + + +