feat(firstleg): show actual and estimated transport nodes by loaded fragment
This commit is contained in:
@@ -133,7 +133,25 @@ export interface MerchantUnifiedCustomNode {
|
|||||||
attachmentIds: Array<number | string>
|
attachmentIds: Array<number | string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
export interface MerchantUnifiedOrderDetail {
|
||||||
|
transportFragments: MerchantTransportFragment[]
|
||||||
summary: MerchantUnifiedOrderRow
|
summary: MerchantUnifiedOrderRow
|
||||||
order: MerchantFirstLegOrderDetail
|
order: MerchantFirstLegOrderDetail
|
||||||
timeline: MerchantUnifiedTimelineEvent[]
|
timeline: MerchantUnifiedTimelineEvent[]
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { MerchantTransportFragment } from '../api/unifiedOrders'
|
||||||
|
import { customerTransportRows } from '../utils/transportFacts'
|
||||||
|
|
||||||
|
const props = defineProps<{ fragments: MerchantTransportFragment[] }>()
|
||||||
|
const rows = computed(() => customerTransportRows(props.fragments))
|
||||||
|
function time(value: string | null) { return value ? value.replace('T', ' ') : '—' }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="transport-facts" aria-label="实际装载片段运输节点">
|
||||||
|
<strong>运输节点</strong>
|
||||||
|
<el-table v-if="rows.length" :data="rows" row-key="key" border size="small">
|
||||||
|
<el-table-column prop="inboundCode" label="入仓码" min-width="155" />
|
||||||
|
<el-table-column prop="cabinetNo" label="柜次" width="90" />
|
||||||
|
<el-table-column prop="containerNo" label="集装箱号" min-width="145" />
|
||||||
|
<el-table-column prop="nodeLabel" label="节点" min-width="150" />
|
||||||
|
<el-table-column label="实际时间" min-width="175"><template #default="{ row }">{{ time(row.actualAt) }}</template></el-table-column>
|
||||||
|
<el-table-column label="预计时间" min-width="175"><template #default="{ row }">{{ time(row.estimatedAt) }}</template></el-table-column>
|
||||||
|
<el-table-column prop="location" label="地点" min-width="130" />
|
||||||
|
<el-table-column prop="source" label="来源" width="105" />
|
||||||
|
</el-table>
|
||||||
|
<p v-else>暂无实际装载片段的运输节点。预计时间不表示实际发生。</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.transport-facts { margin-top: 9px; }
|
||||||
|
.transport-facts > strong { display: block; padding: 7px 0 5px; color: var(--navy); font-size: 11px; }
|
||||||
|
.transport-facts > p { color: var(--muted); font-size: 11px; }
|
||||||
|
</style>
|
||||||
@@ -198,6 +198,7 @@ function timeline(detail: MerchantFirstLegOrderDetail): FirstLegOrderTimelineIte
|
|||||||
|
|
||||||
function businessSummary(summary?: MerchantUnifiedOrderRow): FirstLegOrder['businessSummary'] {
|
function businessSummary(summary?: MerchantUnifiedOrderRow): FirstLegOrder['businessSummary'] {
|
||||||
return {
|
return {
|
||||||
|
transportFragments: [],
|
||||||
customerFeeConfirmationRequired: summary?.customerFeeConfirmationRequired ?? false,
|
customerFeeConfirmationRequired: summary?.customerFeeConfirmationRequired ?? false,
|
||||||
totalPackageCount: summary?.totalPackageCount ?? null,
|
totalPackageCount: summary?.totalPackageCount ?? null,
|
||||||
actualGrossWeightKg: summary?.actualGrossWeightKg ?? 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.status = rawStatus === 'CANCELLED' || rawStatus === 'DRAFT' ? rawStatus : detail.summary.mainStatus
|
||||||
mapped.fulfillmentType = detail.summary.fulfillmentType ?? mapped.fulfillmentType
|
mapped.fulfillmentType = detail.summary.fulfillmentType ?? mapped.fulfillmentType
|
||||||
mapped.lxCode = detail.summary.lxCode ?? mapped.lxCode
|
mapped.lxCode = detail.summary.lxCode ?? mapped.lxCode
|
||||||
mapped.businessSummary = businessSummary(detail.summary)
|
mapped.businessSummary = { ...businessSummary(detail.summary), transportFragments: detail.transportFragments }
|
||||||
mapped.timeline = unifiedTimeline(detail)
|
mapped.timeline = unifiedTimeline(detail)
|
||||||
const summary = mapUnifiedOrderRow(detail.summary, warehouses)
|
const summary = mapUnifiedOrderRow(detail.summary, warehouses)
|
||||||
mapped.referenceNo = summary.referenceNo
|
mapped.referenceNo = summary.referenceNo
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { MerchantTransportFragment } from '../api/unifiedOrders'
|
||||||
import type {
|
import type {
|
||||||
FirstLegOrderStatus,
|
FirstLegOrderStatus,
|
||||||
FirstLegTransportMode,
|
FirstLegTransportMode,
|
||||||
@@ -64,6 +65,7 @@ export interface FirstLegOrderTimelineItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface FirstLegOrderBusinessSummary {
|
export interface FirstLegOrderBusinessSummary {
|
||||||
|
transportFragments: MerchantTransportFragment[]
|
||||||
customerFeeConfirmationRequired: boolean
|
customerFeeConfirmationRequired: boolean
|
||||||
totalPackageCount: number | null
|
totalPackageCount: number | null
|
||||||
actualGrossWeightKg: number | null
|
actualGrossWeightKg: number | null
|
||||||
|
|||||||
@@ -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/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { MerchantTransportFragment } from '../api/unifiedOrders'
|
||||||
|
|
||||||
|
const labels: Readonly<Record<string, string>> = {
|
||||||
|
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<Record<string, string>> = {
|
||||||
|
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 ? '承运轨迹' : '—',
|
||||||
|
})))
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import FirstLegOrderEditorShell from '@/modules/first-leg/components/FirstLegOrd
|
|||||||
import InboundBillingPanel from '@/modules/first-leg/components/InboundBillingPanel.vue'
|
import InboundBillingPanel from '@/modules/first-leg/components/InboundBillingPanel.vue'
|
||||||
import InboundCodeAssociationDialog from '@/modules/first-leg/components/InboundCodeAssociationDialog.vue'
|
import InboundCodeAssociationDialog from '@/modules/first-leg/components/InboundCodeAssociationDialog.vue'
|
||||||
import OrderProgressPanel from '@/modules/first-leg/components/OrderProgressPanel.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 CustomsDocumentsDialog from '@/modules/first-leg/components/CustomsDocumentsDialog.vue'
|
||||||
import { getMerchantInboundBilling } from '@/modules/first-leg/api/inboundBilling'
|
import { getMerchantInboundBilling } from '@/modules/first-leg/api/inboundBilling'
|
||||||
import { getMerchantCustomsForOrder, isMerchantCustomsUnavailable, type MerchantCustomsContainer } from '@/modules/first-leg/api/customs'
|
import { getMerchantCustomsForOrder, isMerchantCustomsUnavailable, type MerchantCustomsContainer } from '@/modules/first-leg/api/customs'
|
||||||
@@ -2455,6 +2456,7 @@ async function searchOrders(model: QueryModel) {
|
|||||||
<el-table-column prop="pickingStatusLabel" label="拣货状态" width="120"><template #default="{ row }"><el-tag size="small" effect="plain">{{ row.pickingStatusLabel }}</el-tag></template></el-table-column>
|
<el-table-column prop="pickingStatusLabel" label="拣货状态" width="120"><template #default="{ row }"><el-tag size="small" effect="plain">{{ row.pickingStatusLabel }}</el-tag></template></el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
<p v-else class="detail-inline-empty">尚未分配柜次,后续柜次节点将在这里同步。</p>
|
<p v-else class="detail-inline-empty">尚未分配柜次,后续柜次节点将在这里同步。</p>
|
||||||
|
<TransportFactsTable :fragments="detailOrder.businessSummary.transportFragments" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</OrderProgressPanel>
|
</OrderProgressPanel>
|
||||||
|
|||||||
Reference in New Issue
Block a user