diff --git a/src/modules/first-leg/api/unifiedOrders.ts b/src/modules/first-leg/api/unifiedOrders.ts index e7fd13b..4752cc9 100644 --- a/src/modules/first-leg/api/unifiedOrders.ts +++ b/src/modules/first-leg/api/unifiedOrders.ts @@ -151,6 +151,7 @@ export interface MerchantTransportFragment { } export interface MerchantUnifiedOrderDetail { + progress: MerchantOrderProgress transportFragments: MerchantTransportFragment[] summary: MerchantUnifiedOrderRow order: MerchantFirstLegOrderDetail diff --git a/src/modules/first-leg/store/firstLegDraftCommands.test.mjs b/src/modules/first-leg/store/firstLegDraftCommands.test.mjs index 9847ce6..8b012cf 100644 --- a/src/modules/first-leg/store/firstLegDraftCommands.test.mjs +++ b/src/modules/first-leg/store/firstLegDraftCommands.test.mjs @@ -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' }] diff --git a/src/modules/first-leg/store/firstLegOrders.test.mjs b/src/modules/first-leg/store/firstLegOrders.test.mjs index 8a369db..1ee3866 100644 --- a/src/modules/first-leg/store/firstLegOrders.test.mjs +++ b/src/modules/first-leg/store/firstLegOrders.test.mjs @@ -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 diff --git a/src/modules/first-leg/store/firstLegOrders.ts b/src/modules/first-leg/store/firstLegOrders.ts index ef3d09d..bbc120f 100644 --- a/src/modules/first-leg/store/firstLegOrders.ts +++ b/src/modules/first-leg/store/firstLegOrders.ts @@ -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) { diff --git a/src/modules/first-leg/store/firstLegWorkbench.test.mjs b/src/modules/first-leg/store/firstLegWorkbench.test.mjs index 3a7ecc0..6fe6040 100644 --- a/src/modules/first-leg/store/firstLegWorkbench.test.mjs +++ b/src/modules/first-leg/store/firstLegWorkbench.test.mjs @@ -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) diff --git a/src/modules/first-leg/utils/orderDetailSummary.test.mjs b/src/modules/first-leg/utils/orderDetailSummary.test.mjs new file mode 100644 index 0000000..c573501 --- /dev/null +++ b/src/modules/first-leg/utils/orderDetailSummary.test.mjs @@ -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) +}) diff --git a/src/modules/first-leg/utils/orderDetailSummary.ts b/src/modules/first-leg/utils/orderDetailSummary.ts new file mode 100644 index 0000000..76494fd --- /dev/null +++ b/src/modules/first-leg/utils/orderDetailSummary.ts @@ -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' } +} diff --git a/src/modules/first-leg/utils/orderProgress.test.mjs b/src/modules/first-leg/utils/orderProgress.test.mjs index efc8626..0433c1e 100644 --- a/src/modules/first-leg/utils/orderProgress.test.mjs +++ b/src/modules/first-leg/utils/orderProgress.test.mjs @@ -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 }), /订单履约进度不完整/) +}) diff --git a/src/modules/first-leg/utils/orderProgress.ts b/src/modules/first-leg/utils/orderProgress.ts index fe3ce02..73346d1 100644 --- a/src/modules/first-leg/utils/orderProgress.ts +++ b/src/modules/first-leg/utils/orderProgress.ts @@ -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 +} diff --git a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs index 4d498ff..28a59ff 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs +++ b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs @@ -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, /履约进度/) diff --git a/src/modules/first-leg/views/FirstLegOrdersView.vue b/src/modules/first-leg/views/FirstLegOrdersView.vue index f56abf9..13f4e72 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.vue +++ b/src/modules/first-leg/views/FirstLegOrdersView.vue @@ -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)[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(() => { diff --git a/src/modules/overseas-warehouse/permissions.test.mjs b/src/modules/overseas-warehouse/permissions.test.mjs index d055d90..67ef313 100644 --- a/src/modules/overseas-warehouse/permissions.test.mjs +++ b/src/modules/overseas-warehouse/permissions.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' +import ts from 'typescript' const routes = readFileSync(new URL('./routes.ts', import.meta.url), 'utf8') const services = readFileSync(new URL('./views/WarehouseServiceView.vue', import.meta.url), 'utf8') @@ -41,13 +42,31 @@ test('opens warehouse service and return receipt details as dedicated protected }) test('returns to the labeling list after a labeling application is submitted', () => { - assert.match(services, /ElMessage\.success\(`\$\{config\.value\.title\}提交成功`\)[\s\S]*?router\.push\(\{ name: serviceListRouteName\(props\.service\) \}\)/) + assert.match(services, /ElMessage\.success\(`\$\{config\.value\.title\}提交成功`\)[\s\S]*?await goToServiceList\(\)/) // Return-carton sources use the unified relabel application and its file endpoint. assert.match(services, /returnCartonId: item\.returnCartonId/) - assert.match(services, /uploadRelabelFile\(created\.application\.relabelNo,[\s\S]*?router\.push\(\{ name: serviceListRouteName\(props\.service\) \}\)/) + assert.match(services, /uploadRelabelFile\(created\.application\.relabelNo,[\s\S]*?await goToServiceList\(\)/) assert.match(services, /申请已创建,附件待补传/) }) +test('service list return navigates standalone pages and refreshes embedded lists', async () => { + const source = services.slice(services.indexOf('function serviceListRouteName('), services.indexOf('async function openEdit(')) + const javascript = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText + for (const props of [ + { service: 'labeling', createPage: true, detailPage: false }, + { service: 'labeling', createPage: false, detailPage: true }, + { service: 'labeling', createPage: false, detailPage: false }, + ]) { + const calls = [], mode = { value: 'create' } + const goToServiceList = new Function('props', 'router', 'mode', 'loadPage', `${javascript}; return goToServiceList`)( + props, { push: async route => calls.push(route) }, mode, async () => calls.push('refresh'), + ) + await goToServiceList() + assert.deepEqual(calls, props.createPage || props.detailPage ? [{ name: 'overseas-labeling' }] : ['refresh']) + if (!props.createPage && !props.detailPage) assert.equal(mode.value, 'list') + } +}) + test('keeps overseas warehouse writes behind operation-specific button permissions', () => { for (const permission of [ 'merchant:overseas-warehouse:relabel:create', diff --git a/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs b/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs index a776c14..d53c8d1 100644 --- a/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs +++ b/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' +import ts from 'typescript' const view = readFileSync(new URL('./ReturnOrdersView.vue', import.meta.url), 'utf8') const api = readFileSync(new URL('../api/returnOrders.ts', import.meta.url), 'utf8') @@ -254,7 +255,25 @@ test('待审核退货可编辑,完成入库后从独立新增页发起贴标', assert.match(view, /name: 'overseas-labeling-create'/) assert.match(view, /query: \{ returnNo: order\.returnNo, warehouseId: String\(order\.overseasWarehouseId\)/) assert.match(view, /cartonMark \? \{ cartonMark \}/) - assert.match(view, /openRelabel\(selected\.order, current\.carton\.cartonMark\)/) + assert.match(view, /v-if="canCreateRelabel && canApplyRelabel\(selected\.order\)"[^>]*@click="openRelabel\(selected\.order\)">贴标申请/) assert.match(view, /v-for="application in row\.relabelApplications \|\| \[\]"/) assert.match(view, /relabelStatusLabel\(application\.status\)/) }) + +test('贴标入口保留完成入库门禁与退货来源,箱号只作为目标页库存筛选', () => { + const source = view.slice(view.indexOf('function canApplyRelabel('), view.indexOf('async function submit()')) + const javascript = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText + const routes = [] + const { canApplyRelabel, openRelabel } = new Function('router', `${javascript}; return { canApplyRelabel, openRelabel }`)({ push: route => routes.push(route) }) + const order = { returnNo: 'RT-TEST', overseasWarehouseId: 'WH-TEST', status: 'COMPLETED', needRelabel: true } + assert.equal(canApplyRelabel(order), true) + assert.equal(canApplyRelabel({ ...order, status: 'PENDING_RECEIPT' }), false) + assert.equal(canApplyRelabel({ ...order, needRelabel: false }), false) + assert.equal(canApplyRelabel({ ...order, relabelNo: 'RL-EXISTS' }), false) + openRelabel(order) + openRelabel(order, 'RC-TEST') + assert.deepEqual(routes, [ + { name: 'overseas-labeling-create', query: { returnNo: 'RT-TEST', warehouseId: 'WH-TEST' } }, + { name: 'overseas-labeling-create', query: { returnNo: 'RT-TEST', warehouseId: 'WH-TEST', cartonMark: 'RC-TEST' } }, + ]) +})