fix(order): show authoritative progress and billing in customer detail

This commit is contained in:
2026-10-15 19:39:35 +08:00
parent 4d7b6eb6dc
commit d89830fe41
13 changed files with 139 additions and 11 deletions
@@ -151,6 +151,7 @@ export interface MerchantTransportFragment {
}
export interface MerchantUnifiedOrderDetail {
progress: MerchantOrderProgress
transportFragments: MerchantTransportFragment[]
summary: MerchantUnifiedOrderRow
order: MerchantFirstLegOrderDetail
@@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs'
import ts from 'typescript'
import * as vue from 'vue'
import * as pinia from 'pinia'
import { requireOrderProgress } from '../utils/orderProgress.ts'
function compile(path, dependencies) {
const source = readFileSync(new URL(path, import.meta.url), 'utf8')
@@ -31,10 +32,11 @@ function harness(t) {
vue, pinia,
'@/modules/first-leg/api/firstLeg': api,
'@/modules/first-leg/api/unifiedOrders': {
getMerchantUnifiedOrder: async () => ({ summary: { orderId: 'one', mainStatus: null }, order: { order: { id: 'one', status: behavior.status, version: behavior.version, receivingWarehouseId: 'warehouse', receivingWarehouseAddress: behavior.receivingWarehouseAddress } }, timeline: [] }),
getMerchantUnifiedOrder: async () => ({ progress: { orderId: 'one', stage: 'none', partial: false }, summary: { orderId: 'one', mainStatus: null }, order: { order: { id: 'one', status: behavior.status, version: behavior.version, receivingWarehouseId: 'warehouse', receivingWarehouseAddress: behavior.receivingWarehouseAddress } }, timeline: [] }),
},
'@/modules/first-leg/utils/firstLegOrder': { canClientEditFirstLegOrder: status => status === 'DRAFT', getFirstLegStatusLabel: value => value },
'@/modules/first-leg/utils/warehousePagination': {},
'@/modules/first-leg/utils/orderProgress': { requireOrderProgress },
})
const store = module.useFirstLegOrdersStore()
store.warehouses = [{ id: 'warehouse', name: '国内仓', warehouseType: 'DOMESTIC' }]
@@ -8,6 +8,32 @@ const storeSource = readFileSync(new URL('./firstLegOrders.ts', import.meta.url)
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('detail fetches authoritative progress on direct entry and refresh, and clears stale progress on failure', async () => {
const functionSource = storeSource.slice(storeSource.indexOf(' async function loadDetail('), storeSource.indexOf(' async function loadWarehouseDetail('))
const progressSource = readFileSync(new URL('../utils/orderProgress.ts', import.meta.url), 'utf8')
const validationSource = progressSource.slice(progressSource.indexOf('export function requireOrderProgress(')).replace('export ', '')
const javascript = ts.transpileModule(`${validationSource}\n${functionSource}`, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
const id = '2110663764278312961'
const orderProgress = { value: {} }
let calls = 0
let response = { progress: { orderId: id, stage: 'scheduled', partial: true } }
const loadDetail = runInNewContext(`${javascript}; loadDetail`, {
orderProgress, warehouses: { value: [] },
getMerchantUnifiedOrder: async requested => { assert.equal(requested, id); calls++; return response },
mapUnifiedOrderDetail: detail => ({ id, detail }), upsertOrder: order => order,
})
await loadDetail(id)
assert.equal(orderProgress.value[id].stage, 'scheduled')
assert.equal(orderProgress.value[id].partial, true)
response = { progress: { orderId: id, stage: 'delivering', partial: false } }
await loadDetail(id)
assert.equal(calls, 2)
assert.equal(orderProgress.value[id].stage, 'delivering')
response = { progress: { orderId: 'another-order', stage: 'delivered', partial: false } }
await assert.rejects(loadDetail(id), /订单履约进度不完整/)
assert.equal(orderProgress.value[id], undefined)
})
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
@@ -32,6 +32,7 @@ import type { FirstLegPartySnapshot } from '@/modules/first-leg/data/firstLegPar
import type { FirstLegOrder, FirstLegOrderDraft, FirstLegOrderTimelineItem } from '@/modules/first-leg/types/firstLegOrder'
import { canClientEditFirstLegOrder, getFirstLegStatusLabel } from '@/modules/first-leg/utils/firstLegOrder'
import { loadAllWarehouseRecords } from '@/modules/first-leg/utils/warehousePagination'
import { requireOrderProgress } from '@/modules/first-leg/utils/orderProgress'
export type ClientWarehouseType = 'DOMESTIC' | 'FBA' | 'OVERSEAS'
export type ClientWarehouse = PlatformReceivingWarehouse & {
@@ -507,8 +508,12 @@ export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', ()
}
async function loadDetail(id: string) {
delete orderProgress.value[id]
const detail = await getMerchantUnifiedOrder(id)
return upsertOrder(mapUnifiedOrderDetail(detail, warehouses.value))
const progress = requireOrderProgress(id, detail.progress)
const mapped = mapUnifiedOrderDetail(detail, warehouses.value)
orderProgress.value[id] = progress
return upsertOrder(mapped)
}
async function loadWarehouseDetail(id: string) {
@@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs'
import ts from 'typescript'
import * as vue from 'vue'
import * as pinia from 'pinia'
import { requireOrderProgress } from '../utils/orderProgress.ts'
const source = readFileSync(new URL('./firstLegOrders.ts', import.meta.url), 'utf8')
const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText
@@ -27,11 +28,12 @@ function harness(t, read) {
getMerchantOrderWorkbench: query => { calls.push(structuredClone(query)); return read(query) },
getMerchantUnifiedOrder: async id => {
details.push(id)
return { summary: row(id, 'DRAFT'), order: { order: { id, status: 'DRAFT', version: 7 } }, timeline: [] }
return { progress: { orderId: id, stage: 'none', partial: false }, summary: row(id, 'DRAFT'), order: { order: { id, status: 'DRAFT', version: 7 } }, timeline: [] }
},
},
'@/modules/first-leg/utils/firstLegOrder': { getFirstLegStatusLabel: value => value },
'@/modules/first-leg/utils/warehousePagination': { loadAllWarehouseRecords: async type => { warehouseCalls.push(type); return [] } },
'@/modules/first-leg/utils/orderProgress': { requireOrderProgress },
}
const exports = {}
new Function('require', 'exports', code)(id => { assert.ok(id in deps, id); return deps[id] }, exports)
@@ -0,0 +1,17 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { orderBillingMetric } from './orderDetailSummary.ts'
test('detail uses the published billing quantity without substituting physical gross weight', () => {
const billing = { inbound: { summary: { actualGrossWeightKg: 23, chargeableWeightKg: null } },
billingBasis: { chargeUnit: 'KG', chargeableQuantity: 17.0006, chargeableWeightKg: null } }
assert.deepEqual(orderBillingMetric(billing, null), { label: '计费重量', quantity: 17.0006, unit: 'kg' })
assert.equal(orderBillingMetric(billing, null).quantity.toLocaleString('zh-CN'), '17.001')
})
test('volume charging retains its real unit and missing billing does not invent a quantity', () => {
assert.deepEqual(orderBillingMetric({ billingBasis: { chargeUnit: 'CBM', chargeableQuantity: 0.102 } }, 23),
{ label: '计费体积', quantity: 0.102, unit: 'm³' })
assert.equal(orderBillingMetric(null, null).quantity, null)
assert.equal(orderBillingMetric(null, 0).quantity, 0)
})
@@ -0,0 +1,14 @@
import type { MerchantInboundBilling } from '../types/inboundBilling'
/** Published billing quantity and physical gross weight have different meanings. */
export function orderBillingMetric(billing: MerchantInboundBilling | null, measuredChargeableWeight: number | null) {
if (billing) {
const basis = billing.billingBasis
return basis.chargeUnit === 'KG'
? { label: '计费重量', quantity: basis.chargeableQuantity, unit: 'kg' }
: basis.chargeUnit === 'CBM'
? { label: '计费体积', quantity: basis.chargeableQuantity, unit: 'm³' }
: { label: '计费量', quantity: null, unit: '' }
}
return { label: '计费重量', quantity: measuredChargeableWeight, unit: 'kg' }
}
@@ -13,3 +13,13 @@ test('prototype queues preserve order and distinguish partial progress from comp
assert.equal(exports.orderProgressLabel({ stage: 'none', partial: false }), '—')
assert.equal(exports.orderProgressLabel(), '—')
})
test('detail progress must belong to the requested order and retain partial fulfillment', () => {
const progress = { orderId: '2110663764278312961', stage: 'scheduled', partial: true }
assert.equal(exports.requireOrderProgress(progress.orderId, progress), progress)
assert.equal(exports.orderProgressLabel(exports.requireOrderProgress(progress.orderId, progress)), '部分已排车')
assert.throws(() => exports.requireOrderProgress('other-order', progress), /订单履约进度不完整/)
assert.throws(() => exports.requireOrderProgress(progress.orderId, undefined), /订单履约进度不完整/)
assert.throws(() => exports.requireOrderProgress(progress.orderId, { ...progress, stage: 'UNKNOWN' }), /订单履约进度不完整/)
assert.throws(() => exports.requireOrderProgress(progress.orderId, { ...progress, partial: null }), /订单履约进度不完整/)
})
@@ -18,3 +18,12 @@ export function orderProgressLabel(progress?: MerchantOrderProgress) {
const label = ORDER_QUEUES.find(q => q.key === progress.stage)?.label ?? '—'
return `${progress.partial ? '部分' : ''}${label}`
}
export function requireOrderProgress(orderId: string, progress: MerchantOrderProgress) {
if (!progress || String(progress.orderId) !== orderId
|| !['none', 'loaded', 'declared', 'cleared', 'scheduled', 'delivering', 'delivered'].includes(progress.stage)
|| typeof progress.partial !== 'boolean') {
throw new Error('订单履约进度不完整,请重新加载')
}
return progress
}
@@ -223,7 +223,8 @@ test('shows one unified progress line and C02 business summaries without detail
assert.match(source, /progressSummaryItems/)
assert.match(source, /summary\.preloadStatus/)
assert.match(source, /summary\.expectedPickCount/)
assert.match(source, /summary\.deliveryTrackingNo/)
assert.match(source, /label: '后续履约', value: orderProgressLabel\(store\.orderProgress\[order\.id\]\)/)
assert.doesNotMatch(source, /待海外同步/)
assert.match(progressSource, /FIRST_LEG_PROGRESS_STAGES/)
assert.match(progressSource, /订单主节点与关联柜次统一展示/)
assert.match(progressSource, /履约进度/)
@@ -60,6 +60,7 @@ import {
validateFirstLegOrderDraft,
} from '@/modules/first-leg/utils/firstLegOrder'
import { ORDER_QUEUES, orderProgressLabel } from '../utils/orderProgress'
import { orderBillingMetric } from '../utils/orderDetailSummary'
import type { OrderQueue } from '../api/unifiedOrders'
import { validateProductImageFile } from '@/modules/first-leg/utils/productImage'
import { createPriceEstimateBreakdown } from '@/modules/first-leg/utils/priceEstimate'
@@ -603,8 +604,10 @@ const pagedDetailCargoItems = computed(() => {
const progressSummaryItems = computed(() => {
const order = detailOrder.value
if (!order || order.status === 'CANCELLED') return []
const inbound = inboundBilling.value?.inbound.summary
const billing = inboundBillingOrderId.value === order.id ? inboundBilling.value : null
const inbound = billing?.inbound.summary
const summary = order.businessSummary
const billingMetric = orderBillingMetric(billing, summary.chargeableWeightKg)
const formatMetric = (value: number | null | undefined, unit = '') => value == null ? '待更新' : `${value.toLocaleString()}${unit ? ` ${unit}` : ''}`
const preloadStatus = ({ PENDING: '待确认', CONFIRMING: '确认中', CONFIRMED: '已确认' } as Record<string, string>)[summary.preloadStatus] ?? summary.preloadStatus
const transportReference = [summary.vesselName, summary.voyageNo].filter(Boolean).join(' · ')
@@ -615,12 +618,12 @@ const progressSummaryItems = computed(() => {
{ label: '实际箱数', value: formatMetric(summary.totalPackageCount ?? inbound?.totalPackageCount, '箱') },
{ label: '实测毛重', value: formatMetric(summary.actualGrossWeightKg ?? inbound?.actualGrossWeightKg, 'kg') },
{ label: '实测体积', value: formatMetric(summary.actualVolumeCbm ?? inbound?.actualVolumeCbm, 'm³') },
{ label: '预装清单', value: preloadStatus ? `${preloadStatus}${summary.preloadItemCount == null ? '' : ` · ${summary.preloadItemCount} 项`}` : '待更新' },
{ label: '预装确认', value: preloadStatus || '待更新' },
{ label: '拣货进度', value: pickingProgress },
{ label: '关联柜次', value: summary.cabinetNo || summary.containerNo || '—' },
{ label: '船期摘要', value: transportReference || '待更新' },
{ label: '计费重量', value: formatMetric(summary.chargeableWeightKg, 'kg') },
{ label: '后续履约', value: summary.deliveryTrackingNo || (summary.deliveryCompletedAt ? `海外回传于 ${formatDateTime(summary.deliveryCompletedAt)}` : '待海外同步') },
{ label: billingMetric.label, value: formatMetric(billingMetric.quantity, billingMetric.unit) },
{ label: '后续履约', value: orderProgressLabel(store.orderProgress[order.id]) },
]
})
const cabinetProgressItems = computed(() => {