From e139c5c87b5ade89c3014ada2d69850008afaa69 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 5 Oct 2026 01:06:13 +0800 Subject: [PATCH] feat(firstleg): show actual and estimated transport nodes by loaded fragment --- src/modules/first-leg/api/unifiedOrders.ts | 18 +++++++ .../components/TransportFactsTable.vue | 32 ++++++++++++ src/modules/first-leg/store/firstLegOrders.ts | 3 +- src/modules/first-leg/types/firstLegOrder.ts | 2 + .../first-leg/utils/transportFacts.test.mjs | 51 +++++++++++++++++++ src/modules/first-leg/utils/transportFacts.ts | 24 +++++++++ .../first-leg/views/FirstLegOrdersView.vue | 2 + 7 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 src/modules/first-leg/components/TransportFactsTable.vue create mode 100644 src/modules/first-leg/utils/transportFacts.test.mjs create mode 100644 src/modules/first-leg/utils/transportFacts.ts diff --git a/src/modules/first-leg/api/unifiedOrders.ts b/src/modules/first-leg/api/unifiedOrders.ts index 8daee30..fdde238 100644 --- a/src/modules/first-leg/api/unifiedOrders.ts +++ b/src/modules/first-leg/api/unifiedOrders.ts @@ -133,7 +133,25 @@ export interface MerchantUnifiedCustomNode { attachmentIds: Array } +export interface MerchantTransportNode { + standardNode: string + nodeSequence: number + providerCode: string | null + estimatedAt: string | null + actualAt: string | null + locationCode: string | null + locationName: string | null +} + +export interface MerchantTransportFragment { + inboundCode: string | null + cabinetNo: string | null + containerNo: string | null + nodes: MerchantTransportNode[] +} + export interface MerchantUnifiedOrderDetail { + transportFragments: MerchantTransportFragment[] summary: MerchantUnifiedOrderRow order: MerchantFirstLegOrderDetail timeline: MerchantUnifiedTimelineEvent[] diff --git a/src/modules/first-leg/components/TransportFactsTable.vue b/src/modules/first-leg/components/TransportFactsTable.vue new file mode 100644 index 0000000..5c6ae83 --- /dev/null +++ b/src/modules/first-leg/components/TransportFactsTable.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/src/modules/first-leg/store/firstLegOrders.ts b/src/modules/first-leg/store/firstLegOrders.ts index a862856..64932d0 100644 --- a/src/modules/first-leg/store/firstLegOrders.ts +++ b/src/modules/first-leg/store/firstLegOrders.ts @@ -198,6 +198,7 @@ function timeline(detail: MerchantFirstLegOrderDetail): FirstLegOrderTimelineIte function businessSummary(summary?: MerchantUnifiedOrderRow): FirstLegOrder['businessSummary'] { return { + transportFragments: [], customerFeeConfirmationRequired: summary?.customerFeeConfirmationRequired ?? false, totalPackageCount: summary?.totalPackageCount ?? null, actualGrossWeightKg: summary?.actualGrossWeightKg ?? null, @@ -357,7 +358,7 @@ function mapUnifiedOrderDetail(detail: MerchantUnifiedOrderDetail, warehouses: P mapped.status = rawStatus === 'CANCELLED' || rawStatus === 'DRAFT' ? rawStatus : detail.summary.mainStatus mapped.fulfillmentType = detail.summary.fulfillmentType ?? mapped.fulfillmentType mapped.lxCode = detail.summary.lxCode ?? mapped.lxCode - mapped.businessSummary = businessSummary(detail.summary) + mapped.businessSummary = { ...businessSummary(detail.summary), transportFragments: detail.transportFragments } mapped.timeline = unifiedTimeline(detail) const summary = mapUnifiedOrderRow(detail.summary, warehouses) mapped.referenceNo = summary.referenceNo diff --git a/src/modules/first-leg/types/firstLegOrder.ts b/src/modules/first-leg/types/firstLegOrder.ts index 21bf849..7a96184 100644 --- a/src/modules/first-leg/types/firstLegOrder.ts +++ b/src/modules/first-leg/types/firstLegOrder.ts @@ -1,3 +1,4 @@ +import type { MerchantTransportFragment } from '../api/unifiedOrders' import type { FirstLegOrderStatus, FirstLegTransportMode, @@ -64,6 +65,7 @@ export interface FirstLegOrderTimelineItem { } export interface FirstLegOrderBusinessSummary { + transportFragments: MerchantTransportFragment[] customerFeeConfirmationRequired: boolean totalPackageCount: number | null actualGrossWeightKg: number | null diff --git a/src/modules/first-leg/utils/transportFacts.test.mjs b/src/modules/first-leg/utils/transportFacts.test.mjs new file mode 100644 index 0000000..9cb128a --- /dev/null +++ b/src/modules/first-leg/utils/transportFacts.test.mjs @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { runInNewContext } from 'node:vm' +import test from 'node:test' +import ts from 'typescript' + +const source = readFileSync(new URL('./transportFacts.ts', import.meta.url), 'utf8') +const javascript = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText +const exports = {} +runInNewContext(javascript, { exports }) +const rows = exports.customerTransportRows + +test('split inbound fragments retain separate cabinet facts and actual/estimated times', () => { + const result = rows([ + { inboundCode: 'NY-1', cabinetNo: 'D1', containerNo: 'TSTU0000001', nodes: [ + { standardNode: 'AT_ORIGIN_PORT', actualAt: '2026-10-05T09:00:00', estimatedAt: null, providerCode: 'MANUAL' }, + { standardNode: 'DEPARTED_ORIGIN_PORT', actualAt: null, estimatedAt: '2026-10-07T08:00:00', providerCode: 'MATSON' }, + ] }, + { inboundCode: 'NY-2', cabinetNo: 'D2', containerNo: 'TSTU0000002', nodes: [ + { standardNode: 'AT_ORIGIN_PORT', actualAt: '2026-10-06T10:00:00', estimatedAt: null, providerCode: 'MATSON' }, + ] }, + ]) + assert.equal(result.length, 3) + assert.equal(result[0].nodeLabel, '已进港') + assert.equal(result[0].source, '人工记录') + assert.equal(result[1].nodeLabel, '预计开航') + assert.equal(result[1].actualAt, null) + assert.equal(result[1].estimatedAt, '2026-10-07T08:00:00') + assert.equal(result[2].cabinetNo, 'D2') + assert.equal(result[2].inboundCode, 'NY-2') + assert.equal(result[2].actualAt, '2026-10-06T10:00:00') +}) + +test('empty actual scope creates no invented nodes or database identifier fallback', () => { + assert.equal(rows([]).length, 0) + assert.equal(rows([{ inboundCode: 'NY-1', nodes: [] }]).length, 0) + const [row] = rows([{ id: 'INTERNAL', nodes: [{ standardNode: 'AT_ORIGIN_PORT', actualAt: null, estimatedAt: '2026-10-05T09:00:00' }] }]) + assert.equal(row.cabinetNo, '—') + assert.equal(row.containerNo, '—') + assert.equal(row.inboundCode, '—') + assert.equal(row.actualAt, null) +}) + +test('detail consumes the existing merchant detail API and displays times separately', () => { + const store = readFileSync(new URL('../store/firstLegOrders.ts', import.meta.url), 'utf8') + const table = readFileSync(new URL('../components/TransportFactsTable.vue', import.meta.url), 'utf8') + assert.match(store, /transportFragments: detail\.transportFragments/) + assert.match(table, /label="实际时间".*time\(row\.actualAt\)/) + assert.match(table, /label="预计时间".*time\(row\.estimatedAt\)/) + assert.doesNotMatch(table, /platform\/|fetch\(|actualAt\s*\|\|\s*.*estimatedAt/) +}) diff --git a/src/modules/first-leg/utils/transportFacts.ts b/src/modules/first-leg/utils/transportFacts.ts new file mode 100644 index 0000000..df523ba --- /dev/null +++ b/src/modules/first-leg/utils/transportFacts.ts @@ -0,0 +1,24 @@ +import type { MerchantTransportFragment } from '../api/unifiedOrders' + +const labels: Readonly> = { + DEPARTED_WAREHOUSE: '已离仓', AT_ORIGIN_PORT: '已进港', LOADED_ON_VESSEL: '已装船', + DEPARTED_ORIGIN_PORT: '已开航', IN_TRANSIT: '海运途中', ARRIVED_DESTINATION_PORT: '船舶已到目的港', + DISCHARGED_FROM_VESSEL: '已卸船', PICKUP_READY: '已清关、可提柜', PICKED_UP: '已提柜', EMPTY_RETURNED: '已还柜', +} +const estimatedLabels: Readonly> = { + DEPARTED_WAREHOUSE: '预计离仓', AT_ORIGIN_PORT: '预计进港', LOADED_ON_VESSEL: '预计装船', + DEPARTED_ORIGIN_PORT: '预计开航', IN_TRANSIT: '预计海运途中', ARRIVED_DESTINATION_PORT: '预计船舶到目的港', + DISCHARGED_FROM_VESSEL: '预计卸船', PICKUP_READY: '预计可提柜', PICKED_UP: '预计提柜', EMPTY_RETURNED: '预计还柜', +} + +export function customerTransportRows(fragments: MerchantTransportFragment[]) { + return fragments.flatMap((fragment, index) => fragment.nodes.map((node, nodeIndex) => ({ + key: `${index}-${nodeIndex}`, + inboundCode: fragment.inboundCode || '—', cabinetNo: fragment.cabinetNo || '—', + containerNo: fragment.containerNo || '—', + nodeLabel: (node.actualAt ? labels : estimatedLabels)[node.standardNode] || '运输节点', + actualAt: node.actualAt, estimatedAt: node.estimatedAt, + location: [node.locationCode, node.locationName].filter(Boolean).join(' · ') || '—', + source: node.providerCode === 'MANUAL' ? '人工记录' : node.providerCode === 'FIRSTLEG' ? '平台记录' : node.providerCode ? '承运轨迹' : '—', + }))) +} diff --git a/src/modules/first-leg/views/FirstLegOrdersView.vue b/src/modules/first-leg/views/FirstLegOrdersView.vue index 1f1e1cf..8fba950 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.vue +++ b/src/modules/first-leg/views/FirstLegOrdersView.vue @@ -12,6 +12,7 @@ import FirstLegOrderEditorShell from '@/modules/first-leg/components/FirstLegOrd import InboundBillingPanel from '@/modules/first-leg/components/InboundBillingPanel.vue' import InboundCodeAssociationDialog from '@/modules/first-leg/components/InboundCodeAssociationDialog.vue' import OrderProgressPanel from '@/modules/first-leg/components/OrderProgressPanel.vue' +import TransportFactsTable from '@/modules/first-leg/components/TransportFactsTable.vue' import CustomsDocumentsDialog from '@/modules/first-leg/components/CustomsDocumentsDialog.vue' import { getMerchantInboundBilling } from '@/modules/first-leg/api/inboundBilling' import { getMerchantCustomsForOrder, isMerchantCustomsUnavailable, type MerchantCustomsContainer } from '@/modules/first-leg/api/customs' @@ -2455,6 +2456,7 @@ async function searchOrders(model: QueryModel) {

尚未分配柜次,后续柜次节点将在这里同步。

+