资料版本历史
每份当前资料展示服务端版本、关联票和上传时间;覆盖及删除操作由服务端保留不可变历史。
@@ -358,6 +384,11 @@ function formatSize(bytes: number) {
.customs-locked strong { color: var(--navy); font-size: 12px; }
.customs-locked span { margin-top: 3px; font-size: 11px; }
.customs-section { margin-top: 18px; }
+.clearance-result-list { display:grid; gap:10px; }
+.clearance-result-row { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:6px 14px; padding:12px 14px; border:1px solid var(--border); background:#fbfcfe; }
+.clearance-result-row strong,.clearance-result-row span { display:block; }
+.clearance-result-row span { margin-top:4px; color:var(--muted); font-size:11px; }
+.clearance-result-row p { grid-column:1 / -1; margin:0; color:var(--navy); font-size:12px; }
.customs-section-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 12px; }
.customs-section-title h3 { margin: 0; color: var(--navy); font-size: 14px; }
.customs-section-title p { margin: 5px 0 0; color: var(--muted); font-size: 11px; line-height: 1.55; }
From 983df11081f16b3003b6f972be40e9090925cb74 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Mon, 28 Sep 2026 07:45:57 +0800
Subject: [PATCH 29/36] fix(client): render estimated sailing fields from
unified orders
---
src/modules/first-leg/api/unifiedOrders.ts | 10 +++++++--
.../first-leg/store/firstLegOrders.test.mjs | 21 +++++++++++++++++++
src/modules/first-leg/store/firstLegOrders.ts | 4 ++--
src/modules/first-leg/types/firstLegOrder.ts | 4 ++--
.../views/FirstLegOrdersView.test.mjs | 2 +-
.../first-leg/views/FirstLegOrdersView.vue | 8 +++----
6 files changed, 38 insertions(+), 11 deletions(-)
diff --git a/src/modules/first-leg/api/unifiedOrders.ts b/src/modules/first-leg/api/unifiedOrders.ts
index 8101851..d1e3d51 100644
--- a/src/modules/first-leg/api/unifiedOrders.ts
+++ b/src/modules/first-leg/api/unifiedOrders.ts
@@ -48,8 +48,14 @@ export interface MerchantUnifiedTransportSummary {
cabinetNo: string | null
vesselName: string | null
voyageNo: string | null
- sailingAt: string | null
- portArrivalAt: string | null
+ estimatedSailingAt: string | null
+ actualSailingAt: string | null
+ effectiveSailingAt: string | null
+ effectiveSailingAtEstimated: boolean | null
+ estimatedPortArrivalAt: string | null
+ actualPortArrivalAt: string | null
+ effectivePortArrivalAt: string | null
+ effectivePortArrivalAtEstimated: boolean | null
}
export interface MerchantUnifiedOrderRow {
diff --git a/src/modules/first-leg/store/firstLegOrders.test.mjs b/src/modules/first-leg/store/firstLegOrders.test.mjs
index 6f6c561..0985d23 100644
--- a/src/modules/first-leg/store/firstLegOrders.test.mjs
+++ b/src/modules/first-leg/store/firstLegOrders.test.mjs
@@ -1,11 +1,32 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
+import { runInNewContext } from 'node:vm'
+import ts from 'typescript'
const storeSource = readFileSync(new URL('./firstLegOrders.ts', import.meta.url), 'utf8')
const apiSource = readFileSync(new URL('../api/firstLeg.ts', import.meta.url), 'utf8')
const unifiedApiSource = readFileSync(new URL('../api/unifiedOrders.ts', import.meta.url), 'utf8')
+test('renders estimated sailing from the current API without substituting actual departure', () => {
+ const mapper = storeSource.slice(storeSource.indexOf('function businessSummary('), storeSource.indexOf('\nconst unifiedActionLabels'))
+ const javascript = ts.transpileModule(mapper, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
+ const map = runInNewContext(`${javascript}; businessSummary`)
+ const transport = {
+ vesselName: 'REASSIGNED', voyageNo: 'V02',
+ estimatedSailingAt: '2026-10-07T08:00:00', actualSailingAt: '2026-10-08T09:00:00',
+ effectiveSailingAt: '2026-10-08T09:00:00', effectiveSailingAtEstimated: false,
+ estimatedPortArrivalAt: '2026-10-27T08:00:00', actualPortArrivalAt: null,
+ }
+ const mapped = map({ transport })
+ assert.equal(mapped.estimatedSailingAt, transport.estimatedSailingAt)
+ assert.equal(mapped.estimatedPortArrivalAt, transport.estimatedPortArrivalAt)
+ assert.equal(mapped.vesselName, 'REASSIGNED')
+ assert.equal(mapped.voyageNo, 'V02')
+ assert.equal(map().estimatedSailingAt, '')
+ assert.equal(map({ transport: null }).estimatedPortArrivalAt, '')
+})
+
test('maps the first-leg aggregate list fields without loading detail rows', () => {
assert.match(apiSource, /interface MerchantFirstLegOrderRow/)
assert.match(apiSource, /skuNos: string \| string\[\] \| null/)
diff --git a/src/modules/first-leg/store/firstLegOrders.ts b/src/modules/first-leg/store/firstLegOrders.ts
index 96ce9c5..edd4530 100644
--- a/src/modules/first-leg/store/firstLegOrders.ts
+++ b/src/modules/first-leg/store/firstLegOrders.ts
@@ -220,8 +220,8 @@ function businessSummary(summary?: MerchantUnifiedOrderRow): FirstLegOrder['busi
cabinetNo: summary?.transport?.cabinetNo ?? '',
vesselName: summary?.transport?.vesselName ?? '',
voyageNo: summary?.transport?.voyageNo ?? '',
- sailingAt: summary?.transport?.sailingAt ?? '',
- portArrivalAt: summary?.transport?.portArrivalAt ?? '',
+ estimatedSailingAt: summary?.transport?.estimatedSailingAt ?? '',
+ estimatedPortArrivalAt: summary?.transport?.estimatedPortArrivalAt ?? '',
inboundId: summary?.inboundId == null ? '' : String(summary.inboundId),
inboundVersion: summary?.inboundVersion ?? null,
inboundCode: summary?.inboundCode ?? '',
diff --git a/src/modules/first-leg/types/firstLegOrder.ts b/src/modules/first-leg/types/firstLegOrder.ts
index 3d7abce..21bf849 100644
--- a/src/modules/first-leg/types/firstLegOrder.ts
+++ b/src/modules/first-leg/types/firstLegOrder.ts
@@ -87,8 +87,8 @@ export interface FirstLegOrderBusinessSummary {
cabinetNo: string
vesselName: string
voyageNo: string
- sailingAt: string
- portArrivalAt: string
+ estimatedSailingAt: string
+ estimatedPortArrivalAt: string
inboundId: string
inboundVersion: number | null
inboundCode: string
diff --git a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs
index 7f227ab..5c6964e 100644
--- a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs
+++ b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs
@@ -729,7 +729,7 @@ test('shows authoritative warehouse, loading and sailing fields on desktop and n
}
assert.match(source, /businessSummary\.cabinetNo \|\| .*businessSummary\.containerNo/)
assert.match(source, /businessSummary\.vesselName.*businessSummary\.voyageNo/s)
- assert.match(source, /businessSummary\.sailingAt/)
+ assert.match(source, /businessSummary\.estimatedSailingAt/)
})
test('loads complete order detail before editing and rejects an unavailable historical warehouse', () => {
diff --git a/src/modules/first-leg/views/FirstLegOrdersView.vue b/src/modules/first-leg/views/FirstLegOrdersView.vue
index 5010781..af9dc8c 100644
--- a/src/modules/first-leg/views/FirstLegOrdersView.vue
+++ b/src/modules/first-leg/views/FirstLegOrdersView.vue
@@ -144,7 +144,7 @@ const selectableOrderColumns: DataTableColumn[] = [
{ key: 'expectedShipDate', label: '期望出运', width: 132, align: 'center', headerAlign: 'center', slotName: 'expectedShipDate' },
{ key: 'cabinetNo', label: '柜号 / 柜次', width: 150, align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'cabinetNo' },
{ key: 'vesselVoyage', label: '船名 / 航次', width: 160, align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'vesselVoyage' },
- { key: 'sailingAt', label: '预计船期', width: 164, align: 'center', headerAlign: 'center', slotName: 'sailingAt' },
+ { key: 'estimatedSailingAt', label: '预计船期', width: 164, align: 'center', headerAlign: 'center', slotName: 'estimatedSailingAt' },
{ key: 'createdAt', label: '创建时间', width: 164, align: 'center', headerAlign: 'center', slotName: 'createdAt' },
{ key: 'updatedAt', label: '更新时间', width: 164, align: 'center', headerAlign: 'center', slotName: 'updatedAt' },
]
@@ -572,7 +572,7 @@ const progressSummaryItems = computed(() => {
const cabinetProgressItems = computed(() => {
const summary = detailOrder.value?.businessSummary
if (!summary || (!summary.cabinetNo && !summary.containerNo)) return []
- return [{ cabinetId: summary.cabinetNo || summary.containerNo, cabinetNo: summary.cabinetNo || summary.containerNo, status: summary.loadingStatus || '待更新', vesselName: summary.vesselName, voyageNo: summary.voyageNo, sailingAt: summary.sailingAt }]
+ return [{ cabinetId: summary.cabinetNo || summary.containerNo, cabinetNo: summary.cabinetNo || summary.containerNo, status: summary.loadingStatus || '待更新', vesselName: summary.vesselName, voyageNo: summary.voyageNo, estimatedSailingAt: summary.estimatedSailingAt }]
})
const selectedWarehouse = computed(() => availableWarehouses.value.find((item) => item.id === form.receivingWarehouseId))
const selectedDestinationWarehouse = computed(() => store.warehouses.find((item) => item.id === form.destinationWarehouseId && item.warehouseType === form.destinationType))
@@ -2147,7 +2147,7 @@ async function searchOrders(model: QueryModel) {
{{ (row as FirstLegOrder).expectedDepartureDate ? formatDateTime((row as FirstLegOrder).expectedDepartureDate) : '-' }}
{{ displayListValue((row as FirstLegOrder).businessSummary.cabinetNo || (row as FirstLegOrder).businessSummary.containerNo) }}
{{ displayListValue([(row as FirstLegOrder).businessSummary.vesselName, (row as FirstLegOrder).businessSummary.voyageNo].filter(Boolean).join(' · ')) }}
-
{{ (row as FirstLegOrder).businessSummary.sailingAt ? formatDateTime((row as FirstLegOrder).businessSummary.sailingAt) : '-' }}
+
{{ (row as FirstLegOrder).businessSummary.estimatedSailingAt ? formatDateTime((row as FirstLegOrder).businessSummary.estimatedSailingAt) : '-' }}
{{ formatDateTime((row as FirstLegOrder).createdAt) }}
{{ formatDateTime((row as FirstLegOrder).updatedAt) }}
@@ -2395,7 +2395,7 @@ async function searchOrders(model: QueryModel) {
{{ row.cabinetNo || '—' }}
{{ [row.vesselName, row.voyageNo].filter(Boolean).join(' · ') || '待更新' }}
- {{ row.sailingAt ? formatDateTime(row.sailingAt) : '待更新' }}
+ {{ row.estimatedSailingAt ? formatDateTime(row.estimatedSailingAt) : '待更新' }}
{{ row.status || '待更新' }}
尚未分配柜次,后续柜次节点将在这里同步。
From 5ebfa712480cbe8db016b7f94cc55cd75d3f0b70 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Mon, 28 Sep 2026 07:59:40 +0800
Subject: [PATCH 30/36] test(client): align delivery idempotency assertion
---
src/modules/order-management/deliveryOrders.test.mjs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/modules/order-management/deliveryOrders.test.mjs b/src/modules/order-management/deliveryOrders.test.mjs
index 20b3d6d..b759efe 100644
--- a/src/modules/order-management/deliveryOrders.test.mjs
+++ b/src/modules/order-management/deliveryOrders.test.mjs
@@ -92,7 +92,7 @@ test('submits the complete create contract with stable retry idempotency and rec
for (const model of ['dispatchNo', 'plannedDeliveryAt', 'deliveryMethod', 'pickupMethod', 'loadingType', 'waybillNo', 'carrierName', 'vehicleType', 'vehicleSize', 'driverName', 'driverPhone', 'vehiclePlate', 'appointmentAccount', 'sellerName', 'remark']) {
assert.match(createSource, new RegExp(`v-model="form\\.${model}"`))
}
- assert.match(createSource, /if \(!pendingIdempotencyKey\) pendingIdempotencyKey = crypto\.randomUUID\(\)/)
+ assert.match(createSource, /if \(!pendingIdempotencyKey\) pendingIdempotencyKey = createIdempotencyKey\(\)/)
assert.match(createSource, /createDeliveryOrder\(requestPayload\(\)\)/)
assert.match(createSource, /fees: form\.fees\.map/)
assert.match(createSource, /if \(form\.fees\.length === 0\)/)
From 3e2847814ac617d894cf40123dd690652e137a42 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Mon, 28 Sep 2026 09:49:53 +0800
Subject: [PATCH 31/36] feat(client): show customs receipt history
---
src/modules/first-leg/api/customs.ts | 8 ++++++++
.../first-leg/components/CustomsDocumentsDialog.vue | 5 +++++
2 files changed, 13 insertions(+)
diff --git a/src/modules/first-leg/api/customs.ts b/src/modules/first-leg/api/customs.ts
index 59070f2..3c7d976 100644
--- a/src/modules/first-leg/api/customs.ts
+++ b/src/modules/first-leg/api/customs.ts
@@ -14,6 +14,13 @@ export interface MerchantCustomsFile {
uploadedAt: string
}
+export interface MerchantCustomsReceiptVersion {
+ version: number
+ file: MerchantCustomsFile
+ operatorName: string | null
+ createdAt: string
+}
+
export interface MerchantCustomsInbound {
inboundId: string
orderId: string
@@ -36,6 +43,7 @@ export interface MerchantCustomsDocument {
receiptStatus: 'PENDING' | 'CONFIRMED' | 'REJECTED' | null
receiptRejectReason: string | null
finalDocument: MerchantCustomsFile | null
+ receiptHistory?: MerchantCustomsReceiptVersion[]
}
export interface MerchantCustomsContainer {
diff --git a/src/modules/first-leg/components/CustomsDocumentsDialog.vue b/src/modules/first-leg/components/CustomsDocumentsDialog.vue
index 89ff1df..9ab6534 100644
--- a/src/modules/first-leg/components/CustomsDocumentsDialog.vue
+++ b/src/modules/first-leg/components/CustomsDocumentsDialog.vue
@@ -246,6 +246,10 @@ function formatDateTime(value: string | null | undefined) {
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
}
+function receiptHistoryLabel(document: MerchantCustomsDocument) {
+ return (document.receiptHistory || []).map((item) => `V${item.version}`).join('、') || '—'
+}
+
function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
@@ -340,6 +344,7 @@ function clearanceResultStatusLabel(value: MerchantClearanceResult) {
{{ item.source.fileName }}
版本 V{{ item.source.version }} · {{ formatSize(item.source.sizeBytes) }} · {{ formatDateTime(item.source.uploadedAt) }}
+ 报关行回执历史:{{ receiptHistoryLabel(item) }}
关联入仓码:{{ item.inboundCodes.join('、') || '—' }}
From bea42ccca3fbb76431bd2716041833004684704c Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Mon, 28 Sep 2026 10:48:25 +0800
Subject: [PATCH 32/36] fix(client): pass quote charge mode
---
src/modules/first-leg/api/firstLeg.ts | 1 +
.../first-leg/views/FirstLegOrdersView.vue | 1 +
.../first-leg/views/SailingPricesView.vue | 21 +++++++++++++------
3 files changed, 17 insertions(+), 6 deletions(-)
diff --git a/src/modules/first-leg/api/firstLeg.ts b/src/modules/first-leg/api/firstLeg.ts
index a65d1f7..1ae92a3 100644
--- a/src/modules/first-leg/api/firstLeg.ts
+++ b/src/modules/first-leg/api/firstLeg.ts
@@ -441,6 +441,7 @@ export interface MerchantPriceEstimateRequest {
destinationWarehouseId?: number | string
destinationName?: string
pricingTaxMode: ApiPricingTaxMode
+ chargeMode?: 'KG' | 'CBM'
actualWeightKg: number
actualVolumeCbm: number
}
diff --git a/src/modules/first-leg/views/FirstLegOrdersView.vue b/src/modules/first-leg/views/FirstLegOrdersView.vue
index af9dc8c..3226742 100644
--- a/src/modules/first-leg/views/FirstLegOrdersView.vue
+++ b/src/modules/first-leg/views/FirstLegOrdersView.vue
@@ -977,6 +977,7 @@ function createEstimateRequest(): MerchantPriceEstimateRequest | null {
destinationWarehouseId: form.destinationType === 'OVERSEAS' ? form.destinationWarehouseId : undefined,
destinationName: form.destinationWarehouseName || undefined,
pricingTaxMode: form.pricingTaxMode,
+ chargeMode: form.pricingTaxMode === 'TAX_INCLUDED' ? 'KG' : 'CBM',
actualWeightKg: summary.grossWeightKg,
actualVolumeCbm: summary.volumeCbm,
}
diff --git a/src/modules/first-leg/views/SailingPricesView.vue b/src/modules/first-leg/views/SailingPricesView.vue
index 329b877..6272e43 100644
--- a/src/modules/first-leg/views/SailingPricesView.vue
+++ b/src/modules/first-leg/views/SailingPricesView.vue
@@ -17,9 +17,18 @@ const hasInput = computed(() => Boolean(originWarehouseId.value && destinationWa
// every channel on the same calculation path when one field is omitted.
const normalizedWeightKg = computed(() => weight.value || (volume.value ? volume.value * 167 : 0))
const normalizedVolumeCbm = computed(() => volume.value || (weight.value ? weight.value / 167 : 0))
-const quoteCards = computed(() => quotes.value.map((quote) => ({ quote, channel: channels.value.find((item) => item.channelCode === quote.channelCode) })).filter((item): item is { quote: MerchantPriceEstimate; channel: MerchantPricingChannel } => Boolean(item.channel)).sort((a, b) => a.quote.estimatedAmount - b.quote.estimatedAmount).slice(0, 4))
-const priceWinner = computed(() => quoteCards.value.reduce((winner, item) => !winner || item.quote.estimatedAmount < winner.quote.estimatedAmount ? item : winner, undefined as (typeof quoteCards.value)[number] | undefined))
-const timeWinner = computed(() => quoteCards.value.reduce((winner, item) => { const days = transitDays(item.channel.referenceTransit); if (days === null) return winner; return !winner || days < transitDays(winner.channel.referenceTransit)! ? item : winner }, undefined as (typeof quoteCards.value)[number] | undefined))
+type QuoteCard = { quote: MerchantPriceEstimate; channel: MerchantPricingChannel }
+const allQuoteCards = computed