From fbdf58c23c31bfc31b2978b8729ec1b01cd7f31f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 4 Nov 2026 14:23:41 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E6=B5=B7=E5=A4=96=E4=BB=93?= =?UTF-8?q?=E5=92=8C=E7=A7=81=E4=BA=BA=E5=9C=B0=E5=9D=80=E6=AD=A3=E5=B8=B8?= =?UTF-8?q?=E5=8F=8A=E7=9F=AD=E5=B0=91=E6=94=B6=E8=B4=A7=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E9=AA=8C=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/admin-test-env-modules/README.md | 2 +- .../dropship-pallet-matrix-contract.test.mjs | 22 +++- .../dropship-pallet-matrix.mjs | 91 ++++++++++++++-- ...und-measurement-box-code-contract.test.mjs | 34 +++++- .../inbound-measurement.mjs | 25 +++-- ...ixed-container-receiving-contract.test.mjs | 92 +++++++++++++++- .../mixed-container-receiving.mjs | 102 +++++++++++++++++- 7 files changed, 341 insertions(+), 27 deletions(-) diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index ca6a893a8..073e6300c 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -264,7 +264,7 @@ export async function run(context) { 混柜收货的每一步均区分同键同载荷重放、同键异载荷冲突和同货物新键再次扫码:普通海外仓重复箱返回冲突,FBA 相同累计数和代发同托新键返回零增量;随后回查完整会话,确保三票事实均不变。 -`mixed-container-receiving.mjs` 通过总入口 `--modules mixed-container-receiving` 执行,依赖客户资质准备。新建一票双托一件代发、一票普通 FBA、一票普通海外仓订单,固定同一起运仓和渠道,经正式接口混装同柜、完成国内装柜和船节点、按柜生成海外计划。使用本轮 PDA 身份交替执行 FBA 累计收货、代发整托收货、普通海外仓逐箱收货;每步核对全部票进度、同键重放和未齐正常完成拒绝。终点为 `MIXED_CONTAINER_INBOUND_COMPLETED`,不创建后续出库或派送,不代表仓储转运单混装。报告复用 `createdResources.dropshipPalletMatrix`;失败保留阻断,成功数据保留,finally 停用本轮 PDA 操作员。真实运行须环境授权,离线合同 `mixed-container-receiving-contract.test.mjs` 不能代替真实 HTTP。 +`mixed-container-receiving.mjs` 通过总入口 `--modules mixed-container-receiving` 执行,依赖客户资质准备。新建一票双托一件代发、一票普通 FBA、一票普通海外仓订单,固定同一起运仓和渠道,经正式接口混装同柜、完成国内装柜和船节点、按柜生成海外计划。普通海外仓四箱通过人工计划预留两托,验证计划二加二可按现场三加一收货,并下载两张真实预留托盘标签PDF;使用本轮 PDA 身份交替执行 FBA 累计收货、代发整托收货、普通海外仓逐箱收货,每步核对全部票进度、同键重放和未齐正常完成拒绝。另建一票私人地址四箱订单,测量审核重放按实测初算结果核对,UNPRICED时核对费用快照待生成且审核重放保持事实一致。随后通过人工报价接口按1 CNY测试单价生成初始头程费用并核对原键重放,通过预装费用门禁后核对其进入普通海外收货模式,实收三箱后带差异结束,验证非预留托盘提前拒绝、后台短少确认及同键重放、原应收保留和三箱可用库存。另建一票私人地址四箱全收,验证正常完成及原键重放、完成计划和四箱可用库存。候选与prepare均按稳定柜次和集装箱号定位。终点为 `MIXED_CONTAINER_INBOUND_COMPLETED`,不创建后续出库或派送,不代表仓储转运单混装。报告复用 `createdResources.dropshipPalletMatrix`;失败保留阻断,成功数据保留,finally 退出本轮固定 PDA 操作员会话。真实运行须环境授权,离线合同 `mixed-container-receiving-contract.test.mjs` 不能代替真实 HTTP。 一件代发矩阵出库前运行 `dropship-outbound-edge-cases.mjs` 的候选查询探针:先模拟平台首次打开页面只传海外仓,再传客户筛选,要求本轮每票全部托盘、箱数、仓位及 SKU 事实一致,并核对仓库与指定客户隔离。缺托、错误客户/仓库、A0400 或筛选后仓位漂移均阻断;候选达到服务端上限导致本轮数据不可见也不能按空场景通过。该探针不证明跨客户混柜造数已覆盖。 diff --git a/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs b/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs index 65ea1283e..497e8f623 100644 --- a/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs +++ b/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs @@ -2,9 +2,23 @@ import test from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' -import { sameOutboundTask } from './dropship-pallet-matrix.mjs' +import { loadingUnitTypesMatch, sameOutboundTask } from './dropship-pallet-matrix.mjs' import { receivingProgressMatches, outboundCandidateGuards, probePartialPick } from './dropship-outbound-edge-cases.mjs' +test('domestic loading units follow dropship, ordinary and mixed fixture composition', () => { + const dropship = { order: { fulfillmentType: 'ONE_CLICK_DROPSHIP' } } + const ordinary = { order: { fulfillmentType: 'STANDARD', destinationType: 'PRIVATE' } } + const sealed = { unitType: 'SEALED_PALLET' } + const packages = { unitType: 'PACKAGE' } + assert.equal(loadingUnitTypesMatch([dropship], [sealed]), true) + assert.equal(loadingUnitTypesMatch([ordinary], [packages]), true) + assert.equal(loadingUnitTypesMatch([dropship, ordinary], [sealed, packages]), true) + assert.equal(loadingUnitTypesMatch([ordinary], [sealed]), false) + assert.equal(loadingUnitTypesMatch([dropship, ordinary], [packages]), false) + assert.equal(loadingUnitTypesMatch([ordinary], []), false) + assert.equal(loadingUnitTypesMatch([ordinary], [{ unitType: 'UNKNOWN' }]), false) +}) + test('loading confirmation assigns the authenticated PDA operator from the target warehouse', () => { const source = readFileSync(new URL('./dropship-pallet-matrix.mjs', import.meta.url), 'utf8') assert.match(source, /domestic-pda-operators\?warehouseId=\$\{summary\.warehouseId\}/) @@ -249,6 +263,12 @@ test('overseas plans preserve per-inbound ownership and shared physical identity assert.match(source, /JSON.stringify\(replay\) === JSON.stringify\(plans\)/) }) +test('private receiving mode is verified from the authoritative tail-order detail', () => { + assert.match(source, /读取私人地址尾程收货类型/) + assert.match(source, /detail.order.destinationType === 'OVERSEAS' && detail.order.orderType === 'FIRST_LEG'/) + assert.doesNotMatch(source, /line => line.destinationType === 'OVERSEAS'/) +}) + test('overseas receipt uses scoped PDA identity and scans each physical pallet once', () => { assert.match(source, /await createPicker\(context/) assert.match(source, /await disablePicker\(context/) diff --git a/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs b/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs index b5d7d1856..aceb26f20 100644 --- a/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs +++ b/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs @@ -12,7 +12,7 @@ import { run as verifyCarrierQuery } from './phase3-carrier-schedule-queries.mjs import { runUntilPickupReady } from './phase4-carrier-nodes.mjs' import { resolvePickerAccess, createPicker, disablePicker, uploadPhotos } from './phase4-overseas-pda.mjs' import { receivingProgressMatches, outboundCandidateGuards, outboundCreationGuards, outboundTaskGuards, probePartialPick } from './dropship-outbound-edge-cases.mjs' -import { receiveMixedContainer } from './mixed-container-receiving.mjs' +import { receiveMixedContainer, receiveOverseasComplete, receiveOverseasShortage } from './mixed-container-receiving.mjs' import { exerciseOldCabinets } from './pda-loading-link-regression.mjs' import { selectSessionPdaOperator } from './domestic-pda-operator.mjs' @@ -20,6 +20,14 @@ const MODULE = 'dropship-pallet-matrix' const BASE = '/api/platform/v1/first-leg/pda-dropship' const OPTIONS = { scenario: MODULE, deploymentBlocker: true } +export function loadingUnitTypesMatch(group, items) { + const expected = new Set(group.map(record => record.order.fulfillmentType === 'ONE_CLICK_DROPSHIP' + ? 'SEALED_PALLET' : 'PACKAGE')) + const actual = new Set(items.map(item => item.unitType)) + return items.length > 0 && actual.size === expected.size + && [...actual].every(type => expected.has(type)) +} + export function sameOutboundTask(left, right) { const databaseTime = (value) => { const match = typeof value === 'string' && value.match(/^(.*T\d{2}:\d{2}:\d{2})(?:\.(\d+))?$/) @@ -155,6 +163,25 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals const pallets = [...new Map(group.flatMap((item) => item.task?.pallets || []) .map((pallet) => [String(pallet.palletId), pallet])).values()] for (const record of group) { + if (record.order.destinationType === 'PRIVATE') { + const path = `/api/platform/v1/inbounds/${record.source.inboundId}/fees/manual-quote?tenantId=${encodeURIComponent(record.order.tenantId)}` + const body = { + selectedWeightKg: Number(record.summary.actualNetWeightKg || record.summary.actualGrossWeightKg), + selectedVolumeCbm: Number(record.summary.actualVolumeCbm), unitPrice: 1, + reason: `${marker}私人地址验收测试人工报价`, + } + check('私人地址人工报价采用实际测量重量和体积', null, + body.selectedWeightKg > 0 && body.selectedVolumeCbm > 0) + const headers = { 'Idempotency-Key': `${marker}-${record.label}-manual-quote` } + const quoted = await command('私人地址人工头程报价', 'POST', path, body, headers) + check('私人地址报价形成待分柜正式费用快照', null, + quoted.rateCode === 'MANUAL_QUOTE' && quoted.feeStatus === 'CALCULATED' + && quoted.nextProcessStatus === 'WAIT_SPLIT' && Number(quoted.unitPrice) === body.unitPrice) + const replay = await command('私人地址人工报价原键重放', 'POST', path, body, headers) + check('私人地址报价重放保持同一费用版本', null, JSON.stringify(replay) === JSON.stringify(quoted)) + record.manualQuote = { feeId: quoted.feeId, feeVersion: quoted.feeVersion, + unitPrice: body.unitPrice, purpose: 'TEST_ACCEPTANCE' } + } const fee = await ensureInitialCalculated(context, { ...record.measurement, tenantId: String(record.order.tenantId), inboundId: record.source.inboundId, finalStatus: 'APPROVED', processStatus: record.summary.processStatus }, OPTIONS) @@ -296,10 +323,7 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals ? { action: 'PICK_PALLET', palletNo: item.palletNo } : { action: 'PICK', pickedPackageCount: Number(item.plannedPackageCount) }) })), } - check(`${label}PDA全部任务均为封托单元`, null, - claimed.items.length > 0 && (mixedContainer - ? claimed.items.some(item => item.unitType === 'SEALED_PALLET') && claimed.items.some(item => item.unitType !== 'SEALED_PALLET') - : claimed.items.every((item) => item.unitType === 'SEALED_PALLET'))) + check(`${label}PDA任务单元与实际履约类型一致`, null, loadingUnitTypesMatch(group, claimed.items)) let picked = claimed if (linkRegression) { for (const palletNo of [...new Set(claimed.items.map(item => item.palletNo))]) { @@ -426,6 +450,16 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals summary.orders.some((order) => order.inboundCode === record.source.inboundCode && String(order.customerId) === String(record.order.tenantId))), `containerNo=${loading.containerNo}, expectedOrders=${group.length}, actualOrders=${summary?.orders?.length ?? 'unavailable'}`) + if (mixedContainer && group.length === 3) { + const overseas = group.find(record => record.order.fulfillmentType === 'STANDARD' && record.order.destinationType === 'OVERSEAS') + const order = summary.orders.find(item => item.inboundCode === overseas.source.inboundCode) + const detail = await call('读取普通海外仓订单当前版本', 'GET', `${base}/orders/${order.orderId}?customerId=${encodeURIComponent(order.customerId)}`) + check('普通海外仓人工计划采用当前订单版本', null, Number.isInteger(detail.order?.version)) + await call('普通海外仓四箱预留两托', 'POST', `${base}/inbound-plans/manual`, { + customerId: String(order.customerId), orderId: String(order.orderId), expectedOrderVersion: detail.order.version, + palletCount: 2, palletType: 'WOOD', assigned: false, mergeRequired: false, urgent: false, remark: marker, + }, { 'Idempotency-Key': `${marker}-${label}-overseas-two-pallets` }) + } const preview = await call('入库计划预览', 'GET', `${base}/inbound-plans/container-preview?sourceContainerBookingId=${bookingId}`) check(`${label}全柜入库计划就绪`, null, preview.ready === true && Number(preview.totalOrderCount) === group.length) const body = { sourceContainerBookingId: bookingId, snapshotVersion: Number(preview.snapshotVersion), @@ -437,6 +471,13 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals const matching = plans.filter((plan) => plan.lines?.some((line) => line.inboundCode === record.source.inboundCode)) check(`${record.label}计划精确关联来源入仓码`, null, matching.length === 1) const target = matching[0] + if (record.order.destinationType === 'PRIVATE') { + const source = summary.orders.find(order => order.inboundCode === record.source.inboundCode) + const detail = await call('读取私人地址尾程收货类型', 'GET', + `${base}/orders/${source.orderId}?customerId=${encodeURIComponent(source.customerId)}`) + check(`${record.label}私人地址进入普通海外收货模式`, null, + detail.order.destinationType === 'OVERSEAS' && detail.order.orderType === 'FIRST_LEG') + } const expectedNos = record.task?.pallets.map((pallet) => pallet.palletNo).sort() const actualNos = (target.pallets || []).map((pallet) => pallet.palletNo).sort() check(`${record.label}来源托盘完整绑定海外真实资源`, null, @@ -459,6 +500,7 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals async function receiveOverseas(group) { const record = group[0] const label = record.label + const sourceContainerBookingId = String(record.loading.cabinet.id) // The PDA account helper keeps the marker tail; retain the run identity there. context.marker = `${label}-RECEIVE-${marker}` const base = '/api/pda/v1/last-leg/receiving-containers' @@ -476,19 +518,32 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals let candidate for (let attempt = 0; attempt < 60; attempt++) { const response = await context.request('GET', `${base}/candidates`, undefined, picker.token, {}) - candidate = response?.body?.data?.find?.((item) => item.containerNo === record.loading.containerNo) + candidate = response?.body?.data?.find?.((item) => item.containerNo === record.loading.containerNo + && String(item.sourceContainerBookingId) === sourceContainerBookingId) if (candidate?.operable && candidate?.allPlansReady) break if (attempt < 59) await new Promise((resolve) => setTimeout(resolve, 1000)) } check(`${label}本柜船节点和入库计划已跨区收敛`, null, candidate?.operable === true && candidate?.allPlansReady === true) - await call('准备拆柜会话', 'POST', `${base}/${record.loading.containerNo}/prepare`, + await call('准备拆柜会话', 'POST', `${base}/${record.loading.containerNo}/prepare?sourceContainerBookingId=${encodeURIComponent(sourceContainerBookingId)}`, { idempotencyKey: `${context.marker}-prepare` }) const photos = await uploadPhotos(context, picker.token) check(`${label}三类卸柜照片齐全`, null, photos?.length === 3) const signed = await call('照片绑定并签收', 'POST', `${base}/current/sign`, { photos, idempotencyKey: `${context.marker}-sign` }) check(`${label}签收成功`, null, signed.status === 'SIGNED') + const adminCall = async (name, method, path, body, headers) => { + const response = await context.request(method, path, body, context.token, headers) + const data = context.apiSuccess(MODULE, `${label}${name}`, response, '', OPTIONS) + check(`${label}${name}成功`, response, response?.body?.code === '200' && data != null) + return data + } + if (mixedContainer && group.length === 1) { + const receive = record.label === 'PRIVATE-COMPLETE' ? receiveOverseasComplete : receiveOverseasShortage + record.overseas.receiving = await receive({ record, call, adminCall, check, marker: context.marker, + request: (method, path, body) => context.request(method, path, body, picker.token, {}) }) + return + } if (mixedContainer) { const completed = await receiveMixedContainer({ group, call, check, marker: context.marker, request: (method, path, body) => context.request(method, path, body, picker.token, {}) }) @@ -502,6 +557,18 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals && row.locationId && item.overseas.plan.pallets.some(p => String(p.warehousePalletId) === String(row.palletId))) && stock.items.reduce((sum, row) => sum + Number(row.availablePackages), 0) === item.expectedCartonMarks.length) item.overseas.receiving = { completed, stock, receivingVerified: true } + if (item.order.fulfillmentType === 'STANDARD' && item.order.destinationType === 'OVERSEAS') { + const plan = await adminCall('回查普通海外仓现场逐托实收', 'GET', `/api/platform/v1/last-leg/inbound-plans/${encodeURIComponent(item.overseas.plan.planNo)}?customerId=${encodeURIComponent(item.order.tenantId)}`) + check('普通海外仓计划二加二可按三加一实收', null, + JSON.stringify(plan.allocations.map(row => Number(row.plannedPackageCount)).sort()) === '[2,2]' + && JSON.stringify(plan.pallets.map(row => Number(row.actualPackageCount)).sort()) === '[1,3]') + for (const pallet of plan.pallets) { + const label = await context.requestBinary('GET', `/api/platform/v1/overseas-warehouse-resources/${item.overseas.warehouseId}/pallets/${pallet.warehousePalletId}/label`) + check('普通海外仓预留托盘标签返回有效PDF', label, + label.http === 200 && label.contentType.toLowerCase().includes('application/pdf') + && label.bytes.length > 1000 && new TextDecoder().decode(label.bytes.slice(0, 5)) === '%PDF-') + } + } } return } @@ -666,6 +733,16 @@ async function runWorkflow(context, stopBeforeOverseasPda, mixedContainer = fals await progressShipping(group) await convertOverseas(group) await receiveOverseas(group) + const shortage = await prepare('PRIVATE-SHORTAGE', 1, {}, 'PRIVATE') + await allocate([shortage]) + await progressShipping([shortage]) + await convertOverseas([shortage]) + await receiveOverseas([shortage]) + const complete = await prepare('PRIVATE-COMPLETE', 1, {}, 'PRIVATE') + await allocate([complete]) + await progressShipping([complete]) + await convertOverseas([complete]) + await receiveOverseas([complete]) context.createdResources.dropshipPalletMatrix.stage = 'MIXED_CONTAINER_INBOUND_COMPLETED' return } diff --git a/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs b/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs index 32dc2069d..3f783c98c 100644 --- a/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs @@ -236,7 +236,8 @@ function measurementReplayContext(approved = false, mutate = () => {}, submitCod } assert.match(requestPath, /\/measurements\/approve\?/) return { http: 200, body: { code: '200', data: { - initialPricingSucceeded: true, initialFee: structuredClone(state.fee), + initialPricingSucceeded: state.fee != null, feeStatus: state.fee ? 'CALCULATED' : 'UNPRICED', + initialFee: structuredClone(state.fee), } }, requestId: 'approve-repeat', traceId: 'trace-approve' } } let data @@ -245,7 +246,10 @@ function measurementReplayContext(approved = false, mutate = () => {}, submitCod else if (requestPath.includes('/measurement-review-history?')) data = { records: state.reviews, total: state.reviews.length, page: 1, size: 200, } - else if (requestPath.includes('/fees/latest?')) data = state.fee + else if (requestPath.includes('/fees/latest?')) { + if (state.fee == null) return { http: 200, body: { code: 'A0404' } } + data = state.fee + } else if (requestPath.includes('/fee-details?')) { assert.equal(new URL(requestPath, 'https://example.invalid').searchParams.get('includeHistory'), 'true') data = state.details @@ -308,6 +312,28 @@ test('approval replay uses the original key and old expectedVersion and compares assert.equal(JSON.stringify(evidence).includes(fixture.context.token), false) }) +test('approval replay preserves an explicit unpriced result without creating fee facts', async () => { + const fixture = measurementReplayContext(true) + fixture.state.fee = null + fixture.state.details = [] + const evidence = await verifyMeasurementApprovalReplay(fixture.context, fixture.input, + { measurementId: fixture.input.measurementId, expectedVersion: 3, reason: null }, 'unpriced-review-key') + assert.equal(evidence.unchanged, true) + assert.equal(evidence.feeId, '') + assert.equal(evidence.feeVersion, null) + assert.deepEqual(evidence.feeDetailIds, []) +}) + +test('approval replay rejects newly created fee facts after an unpriced approval', async () => { + const fixture = measurementReplayContext(true, state => { + state.fee = { feeId: '9007199254740993191', feeVersion: 1 } + }) + fixture.state.fee = null + fixture.state.details = [] + await assert.rejects(verifyMeasurementApprovalReplay(fixture.context, fixture.input, + { measurementId: fixture.input.measurementId, expectedVersion: 3, reason: null }, 'unpriced-review-key'), /零新增/) +}) + for (const [name, mutate] of [ ['different fee ID', state => { state.fee.feeId = '9007199254740993191' }], ['extra fee version', state => { state.fee.feeVersion += 1 }], @@ -320,7 +346,7 @@ for (const [name, mutate] of [ const fixture = measurementReplayContext(true, mutate) await assert.rejects(verifyMeasurementApprovalReplay(fixture.context, fixture.input, { measurementId: fixture.input.measurementId, expectedVersion: 3, reason: null }, 'original-review-key'), - /零新增|唯一审核及已生成初算费用事实有效/) + /零新增|唯一审核及初算状态事实有效/) }) } @@ -329,7 +355,7 @@ test('review history uses the real reviewResult field and rejects a fabricated r fixture.state.reviews[0] = { id: '9007199254740993109', measurementId: fixture.input.measurementId, result: 'APPROVED' } await assert.rejects(verifyMeasurementApprovalReplay(fixture.context, fixture.input, { measurementId: fixture.input.measurementId, expectedVersion: 3, reason: null }, 'original-review-key'), - /唯一审核及已生成初算费用事实有效/) + /唯一审核及初算状态事实有效/) assert.equal(fixture.calls.some(call => call.method === 'POST'), false) assert.match(source, /item\.reviewResult === 'APPROVED'/) }) diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index 42e5860bd..6e0396c04 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -203,7 +203,10 @@ export async function verifyMeasurementApprovalReplay(context, input, payload, i } const feeResponse = await context.request('GET', `/api/platform/v1/inbounds/${input.inboundId}/fees/latest?${query(input.tenantId)}`) - const fee = context.apiSuccess(MODULE, `${name}原初算费用ID及版本`, feeResponse, '', OPTIONS) + const unpriced = feeResponse?.http === 200 && feeResponse.body?.code === 'A0404' + const fee = unpriced ? null : context.apiSuccess(MODULE, `${name}原初算费用ID及版本`, feeResponse, '', OPTIONS) + if (unpriced) context.assert(MODULE, `${name}初算费用快照待生成`, feeResponse, true, + '重放结果须明确保留UNPRICED状态', OPTIONS) const detailsResponse = await context.request('GET', `/api/platform/v1/inbounds/${input.inboundId}/fee-details?accountId=${encodeURIComponent(input.tenantId)}&includeHistory=true`) const feeDetails = context.apiSuccess(MODULE, `${name}全部版本费用明细`, detailsResponse, '', OPTIONS) @@ -213,14 +216,15 @@ export async function verifyMeasurementApprovalReplay(context, input, payload, i && snapshot.facts.measurement.measureReviewStatus === 'APPROVED' && reviews.length === total && new Set(reviews.map(item => asId(item.id))).size === total && approvedReviewCount === 1 - && Boolean(asId(fee?.feeId)) && Number.isInteger(Number(fee?.feeVersion)) && Number(fee.feeVersion) >= 1 && Array.isArray(feeDetails) && new Set(feeDetails.map(item => asId(item.id))).size === feeDetails.length - context.assert(MODULE, `${name}唯一审核及已生成初算费用事实有效`, detailsResponse, valid, + && (unpriced ? feeDetails.length === 0 + : Boolean(asId(fee?.feeId)) && Number.isInteger(Number(fee?.feeVersion)) && Number(fee.feeVersion) >= 1) + context.assert(MODULE, `${name}唯一审核及初算状态事实有效`, detailsResponse, valid, `reviews=${reviews.length}, feeId=${asId(fee?.feeId)}, feeVersion=${fee?.feeVersion ?? 'missing'}` + `, feeDetails=${feeDetails?.length ?? 'missing'}, approvedReviewCount=${approvedReviewCount}` + `, measurementStatus=${snapshot.facts.measurement.measurementStatus}` + `, measureReviewStatus=${snapshot.facts.measurement.measureReviewStatus}`, OPTIONS) - if (!valid) throw new Error('测量审核重放缺少唯一审核或已生成初算费用事实') + if (!valid) throw new Error('测量审核重放缺少唯一审核或有效初算状态事实') return { response: snapshot.response, facts: { ...snapshot.facts, reviews, fee, feeDetails } } } const before = await read('审核同键重放前') @@ -231,14 +235,17 @@ export async function verifyMeasurementApprovalReplay(context, input, payload, i const after = await read('审核同键重放后') const beforeDigest = measurementReplayDigest(before.facts) const afterDigest = measurementReplayDigest(after.facts) - const unchanged = beforeDigest === afterDigest && replay?.initialPricingSucceeded === true - && asId(replay?.initialFee?.feeId) === asId(before.facts.fee.feeId) - && Number(replay?.initialFee?.feeVersion) === Number(before.facts.fee.feeVersion) + const pricingUnchanged = before.facts.fee + ? replay?.initialPricingSucceeded === true + && asId(replay?.initialFee?.feeId) === asId(before.facts.fee.feeId) + && Number(replay?.initialFee?.feeVersion) === Number(before.facts.fee.feeVersion) + : replay?.initialPricingSucceeded === false && replay?.feeStatus === 'UNPRICED' && replay?.initialFee == null + const unchanged = beforeDigest === afterDigest && pricingUnchanged context.assert(MODULE, '测量审核重放保持批次费用ID版本全部明细审核及入库历史零新增', after.response, - unchanged, `feeId=${asId(after.facts.fee.feeId)}, version=${after.facts.fee.feeVersion}` + unchanged, `feeId=${asId(after.facts.fee?.feeId)}, version=${after.facts.fee?.feeVersion ?? 'UNPRICED'}` + `, reviews=${after.facts.reviews.length}, details=${after.facts.feeDetails.length}`, OPTIONS) return { measurementId: input.measurementId, unchanged, beforeDigest, afterDigest, - feeId: asId(after.facts.fee.feeId), feeVersion: after.facts.fee.feeVersion, + feeId: asId(after.facts.fee?.feeId), feeVersion: after.facts.fee?.feeVersion ?? null, feeDetailIds: after.facts.feeDetails.map(item => asId(item.id)), reviewIds: after.facts.reviews.map(item => asId(item.id)), inboundHistoryCount: after.facts.inbound.statusHistory.length } diff --git a/scripts/admin-test-env-modules/mixed-container-receiving-contract.test.mjs b/scripts/admin-test-env-modules/mixed-container-receiving-contract.test.mjs index 189c78773..be17d56ef 100644 --- a/scripts/admin-test-env-modules/mixed-container-receiving-contract.test.mjs +++ b/scripts/admin-test-env-modules/mixed-container-receiving-contract.test.mjs @@ -2,13 +2,13 @@ import test from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' -import { mixedReceivingSchedule, receiveMixedContainer } from './mixed-container-receiving.mjs' +import { mixedReceivingSchedule, receiveMixedContainer, receiveOverseasComplete, receiveOverseasShortage } from './mixed-container-receiving.mjs' function fixtures() { const record = (type, fulfillmentType = 'STANDARD') => ({ label: type, order: { destinationType: type, fulfillmentType }, source: { inboundId: type, inboundCode: type }, expectedCartonMarks: [1, 2, 3, 4].map(i => `${type}#4-${i}`), - overseas: { plan: { pallets: [{ palletNo: `PAL-${type}` }] } } }) + overseas: { plan: { pallets: [{ palletNo: `PAL-${type}` }, ...(type === 'OVERSEAS' ? [{ palletNo: 'PAL-OVERSEAS-2' }] : [])] } } }) const d = record('DROPSHIP', 'ONE_CLICK_DROPSHIP') d.task = { pallets: [{ palletNo: 'DP-1', cartons: [{ inboundId: 'DROPSHIP', boxCode: 'DROPSHIP#4-1' }] }, { palletNo: 'DP-2', cartons: [2, 3, 4].map(i => ({ inboundId: 'DROPSHIP', boxCode: `DROPSHIP#4-${i}` })) }] } @@ -18,6 +18,7 @@ function fixtures() { test('mixed receiving is registered with prerequisites, not transfer or downstream delivery', () => { assert.deepEqual(normalizeModuleOrder(['mixed-container-receiving']), ['merchant-qualification-fixture', 'mixed-container-receiving']) const source = readFileSync(new URL('dropship-pallet-matrix.mjs', import.meta.url), 'utf8') + assert.match(source, /await\s+adminCall\('回查普通海外仓现场逐托实收'/) const start = source.indexOf("const dropship = await prepare('MIXED-DROPSHIP'") const branch = source.slice(start, source.indexOf('if (stopBeforeOverseasPda)', start)) for (const entry of ["'MIXED-FBA', 1, {}, 'FBA'", "'MIXED-OVERSEAS', 1, {}, 'OVERSEAS'", @@ -27,12 +28,19 @@ test('mixed receiving is registered with prerequisites, not transfer or downstre assert.match(source, /混柜完成后按入仓码查询库存/) assert.match(source, /String\(row.customerId\) === String\(item.order.tenantId\)/) assert.match(source, /String\(p.warehousePalletId\) === String\(row.palletId\)/) + assert.match(source, /String\(item.sourceContainerBookingId\) === sourceContainerBookingId/) + assert.match(source, /\/prepare\?sourceContainerBookingId=\$\{encodeURIComponent\(sourceContainerBookingId\)\}/) + assert.match(source, /普通海外仓四箱预留两托/) + assert.match(source, /普通海外仓计划二加二可按三加一实收/) + assert.match(source, /普通海外仓预留托盘标签返回有效PDF/) }) test('schedule interleaves three modes and uses FBA cumulative count rather than delta', () => { const steps = mixedReceivingSchedule(fixtures()) assert.deepEqual(steps.map(s => s.path), ['bulk', 'pallet', 'carton', 'carton', 'carton', 'carton', 'bulk', 'pallet']) assert.deepEqual(steps.filter(s => s.path === 'bulk').map(s => [s.count, s.body.packageCount]), [[1, 1], [3, 4]]) + assert.deepEqual(steps.filter(s => s.path === 'carton').map(s => s.body.palletCode), + ['PAL-OVERSEAS', 'PAL-OVERSEAS', 'PAL-OVERSEAS', 'PAL-OVERSEAS-2']) assert.equal(steps.reduce((sum, s) => sum + s.count, 0), 12) assert.throws(() => mixedReceivingSchedule(fixtures().slice(0, 2))) }) @@ -96,3 +104,83 @@ test('mixed receiving rejects a new-key rescan that accepts another box', async test('mixed receiving rejects same-key different-payload success', async () => { await assert.rejects(receiveMixedContainer(driver({ changedPayloadAccepted: true }))) }) + +function shortageDriver({ stockPackages = 3, wrongPalletAccepted = false } = {}) { + const record = { order: { tenantId: 'CUSTOMER' }, source: { inboundCode: 'IN-OVERSEAS' }, + expectedCartonMarks: [1, 2, 3, 4].map(index => `IN-OVERSEAS#4-${index}`), + overseas: { plan: { planNo: 'IP-SHORTAGE', pallets: [{ palletNo: 'PAL-1', warehousePalletId: '900' }] } } } + const receipts = new Map() + const confirmation = new Map() + const state = { received: 0, status: 'SIGNED', planStatus: 'PENDING_RECONCILIATION', confirmations: 0 } + const snapshot = () => ({ status: state.status, receivedPackages: state.received, pendingPackages: 4 - state.received }) + return { record, marker: 'SHORTAGE-TEST', state, check: (name, _response, passed) => assert.ok(passed, name), + request: async (_method, path) => ({ body: { code: path.endsWith('/pallets/resolve') && wrongPalletAccepted ? '200' : 'A0409' } }), + call: async (_name, method, path, body) => { + if (method === 'GET') return snapshot() + if (path.endsWith('/cartons/resolve')) return { inboundCode: record.source.inboundCode, destinationType: 'OVERSEAS' } + if (path.endsWith('/pallets/resolve')) return { compatible: true, palletCode: 'PAL-1' } + if (path.endsWith('/inventory/resolve')) return { queryCode: record.source.inboundCode, items: [{ + inboundCode: record.source.inboundCode, customerId: 'CUSTOMER', palletId: '900', locationId: '800', availablePackages: stockPackages }] } + if (receipts.has(body.idempotencyKey)) return structuredClone(receipts.get(body.idempotencyKey)) + if (path.endsWith('/receipts/carton')) { + state.received++ + const result = { acceptedPackages: 1 }; receipts.set(body.idempotencyKey, result); return result + } + assert.ok(path.endsWith('/complete')) + assert.equal(body.allowDifference, true) + state.status = 'COMPLETED_WITH_DIFFERENCE' + const result = snapshot(); receipts.set(body.idempotencyKey, result); return result + }, + adminCall: async (_name, method, path, body, headers) => { + if (method === 'GET') return { status: state.planStatus, version: 4, expectedPackageCount: 4, pickedPackageCount: state.received } + assert.ok(path.endsWith('/confirm-shortage')) + assert.equal(body.expectedPackageCount, 4); assert.equal(body.receivedPackageCount, 3) + const key = headers['Idempotency-Key'] + if (confirmation.has(key)) return confirmation.get(key) + state.planStatus = 'PARTIAL_INBOUND'; state.confirmations++ + const result = { status: state.planStatus }; confirmation.set(key, result); return result + } } +} + +test('ordinary overseas shortage preserves four expected cartons and stocks three exactly once', async () => { + const input = shortageDriver() + const result = await receiveOverseasShortage(input) + assert.equal(result.shortageVerified, true) + assert.equal(result.plan.expectedPackageCount, 4) + assert.equal(result.plan.pickedPackageCount, 3) + assert.equal(input.state.confirmations, 1) +}) +test('ordinary overseas shortage fails if an unreserved pallet passes validation', async () => { + await assert.rejects(receiveOverseasShortage(shortageDriver({ wrongPalletAccepted: true }))) +}) +test('ordinary overseas shortage fails if stock includes an unreceived carton', async () => { + await assert.rejects(receiveOverseasShortage(shortageDriver({ stockPackages: 4 }))) +}) + +function completeDriver(stockPackages = 4) { + const input = shortageDriver({ stockPackages }) + const call = input.call + input.call = async (name, method, path, body) => { + if (path.endsWith('/complete')) { + assert.equal(body.allowDifference, false) + assert.equal(input.state.received, 4) + return { status: 'COMPLETED', receivedPackages: 4, pendingPackages: 0 } + } + return call(name, method, path, body) + } + input.adminCall = async (_name, method) => { + assert.equal(method, 'GET') + return { status: 'COMPLETED', expectedPackageCount: 4, pickedPackageCount: input.state.received } + } + return input +} + +test('ordinary overseas full receipt uses normal completion and stocks all four cartons', async () => { + const result = await receiveOverseasComplete(completeDriver()) + assert.equal(result.normalCompletionVerified, true) + assert.equal(result.stock.items[0].availablePackages, 4) +}) + +test('ordinary overseas full receipt rejects stock inconsistent with actual received cartons', async () => { + await assert.rejects(receiveOverseasComplete(completeDriver(3))) +}) diff --git a/scripts/admin-test-env-modules/mixed-container-receiving.mjs b/scripts/admin-test-env-modules/mixed-container-receiving.mjs index f204aba7e..44a1e503e 100644 --- a/scripts/admin-test-env-modules/mixed-container-receiving.mjs +++ b/scripts/admin-test-env-modules/mixed-container-receiving.mjs @@ -13,14 +13,16 @@ export function mixedReceivingSchedule(group) { || dropship[0].task?.pallets?.length !== 2) throw new Error('混柜必须包含普通FBA、普通海外仓及一件代发双托三票') const [f, o, d] = [fba[0], overseas[0], dropship[0]] const fbaPallet = f.overseas.plan.pallets?.[0]?.palletNo - const overseasPallet = o.overseas.plan.pallets?.[0]?.palletNo - if (!fbaPallet || !overseasPallet || f.expectedCartonMarks.length < 2) throw new Error('混柜普通订单缺少真实托盘或分批箱数') + const overseasPallets = o.overseas.plan.pallets || [] + if (!fbaPallet || overseasPallets.length !== 2 || overseasPallets.some(pallet => !pallet.palletNo) + || o.expectedCartonMarks.length !== 4 || f.expectedCartonMarks.length < 2) throw new Error('混柜普通订单缺少真实托盘或分批箱数') const palletStep = pallet => ({ record: d, path: 'pallet', count: pallet.cartons.filter(c => String(c.inboundId) === String(d.source.inboundId)).length, body: { sourcePalletNo: pallet.palletNo } }) return [ { record: f, path: 'bulk', count: 1, body: { inboundCode: f.source.inboundCode, palletCode: fbaPallet, packageCount: 1 } }, palletStep(d.task.pallets[0]), - ...o.expectedCartonMarks.map(cartonMark => ({ record: o, path: 'carton', count: 1, body: { cartonMark, palletCode: overseasPallet } })), + ...o.expectedCartonMarks.map((cartonMark, index) => ({ record: o, path: 'carton', count: 1, + body: { cartonMark, palletCode: overseasPallets[index === 3 ? 1 : 0].palletNo } })), { record: f, path: 'bulk', count: f.expectedCartonMarks.length - 1, body: { inboundCode: f.source.inboundCode, palletCode: fbaPallet, packageCount: f.expectedCartonMarks.length } }, palletStep(d.task.pallets[1]), @@ -79,3 +81,97 @@ export async function receiveMixedContainer({ group, call, check, request, marke check('混柜完成重放不重复入库', null, JSON.stringify(replay) === JSON.stringify(completed)) return completed } + +export async function receiveOverseasComplete({ record, call, adminCall, check, marker }) { + const base = '/api/pda/v1/last-leg/receiving-containers/current' + const inboundCode = record.source.inboundCode + const plan = record.overseas.plan + const pallet = plan.pallets[0] + const expected = record.expectedCartonMarks.length + check('普通海外仓全收场景具备四个真实箱码和预留托盘', null, expected === 4 && Boolean(pallet?.palletNo)) + for (const [index, cartonMark] of record.expectedCartonMarks.entries()) { + const carton = await call('全收场景解析本柜箱码', 'POST', `${base}/cartons/resolve`, { cartonMark }) + check('全收箱码属于当前普通海外仓票', null, carton.inboundCode === inboundCode && carton.destinationType === 'OVERSEAS') + const resolved = await call('全收场景校验预留托盘', 'POST', `${base}/pallets/resolve`, { inboundCode, palletCode: pallet.palletNo }) + check('全收预留托盘校验通过', null, resolved.compatible === true && resolved.palletCode === pallet.palletNo) + const receipt = await call('全收场景逐箱实收', 'POST', `${base}/receipts/carton`, + { cartonMark, palletCode: pallet.palletNo, idempotencyKey: `${marker}-complete-carton-${index}` }) + check('全收本次只接收当前一箱', null, Number(receipt.acceptedPackages) === 1) + } + const body = { allowDifference: false, idempotencyKey: `${marker}-ordinary-complete` } + const completed = await call('四箱全收后正常完成', 'POST', `${base}/complete`, body) + check('四箱全收正常完成且无差异', null, completed.status === 'COMPLETED' + && Number(completed.receivedPackages) === expected && Number(completed.pendingPackages) === 0) + const replay = await call('正常完成原键重放', 'POST', `${base}/complete`, body) + check('正常完成重放不重复入库', null, JSON.stringify(replay) === JSON.stringify(completed)) + const finalPlan = await adminCall('回查全收最终计划', 'GET', + `/api/platform/v1/last-leg/inbound-plans/${encodeURIComponent(plan.planNo)}?customerId=${encodeURIComponent(record.order.tenantId)}`) + check('全收计划完成且应收实收均为四箱', null, finalPlan.status === 'COMPLETED' + && Number(finalPlan.expectedPackageCount) === expected && Number(finalPlan.pickedPackageCount) === expected) + const stock = await call('回查全收可用库存', 'POST', '/api/pda/v1/last-leg/inventory/resolve', { code: inboundCode }) + check('全收库存只有本票实际收到的四箱', null, stock.queryCode === inboundCode && stock.items?.length > 0 + && stock.items.every(row => row.inboundCode === inboundCode && String(row.customerId) === String(record.order.tenantId) + && row.locationId && plan.pallets.some(item => String(item.warehousePalletId) === String(row.palletId))) + && stock.items.reduce((sum, row) => sum + Number(row.availablePackages), 0) === expected) + return { completed, plan: finalPlan, stock, receivingVerified: true, normalCompletionVerified: true } +} + +/** 逐箱短少只按本轮真实已收箱入库,验证后台确认、重放和库存归属。 */ +export async function receiveOverseasShortage({ record, call, adminCall, check, request, marker }) { + const base = '/api/pda/v1/last-leg/receiving-containers/current' + const inboundCode = record.source.inboundCode + const plan = record.overseas.plan + const pallet = plan.pallets[0] + const expected = record.expectedCartonMarks.length + const actual = expected - 1 + check('普通海外仓短少场景具备四个真实箱码和预留托盘', null, expected === 4 && Boolean(pallet?.palletNo)) + const before = await call('错误托盘校验前回查', 'GET', base) + const wrong = await request('POST', `${base}/pallets/resolve`, { inboundCode, palletCode: `${marker}-UNRESERVED` }) + check('普通海外仓非本票预留托盘在校验时拒绝', wrong, wrong.body?.code === 'A0409') + const unchanged = await call('错误托盘校验后回查', 'GET', base) + check('错误托盘校验后实收零漂移', null, JSON.stringify(before) === JSON.stringify(unchanged)) + for (const [index, cartonMark] of record.expectedCartonMarks.slice(0, actual).entries()) { + const carton = await call('短少场景解析本柜箱码', 'POST', `${base}/cartons/resolve`, { cartonMark }) + check('箱码属于当前普通海外仓票', null, carton.inboundCode === inboundCode && carton.destinationType === 'OVERSEAS') + const resolved = await call('短少场景校验预留托盘', 'POST', `${base}/pallets/resolve`, { inboundCode, palletCode: pallet.palletNo }) + check('预留托盘校验通过', null, resolved.compatible === true && resolved.palletCode === pallet.palletNo) + const body = { cartonMark, palletCode: pallet.palletNo, idempotencyKey: `${marker}-shortage-carton-${index}` } + const receipt = await call('短少场景逐箱实收', 'POST', `${base}/receipts/carton`, body) + check('本次只接收当前一箱', null, Number(receipt.acceptedPackages) === 1) + const replay = await call('逐箱实收同键重放', 'POST', `${base}/receipts/carton`, body) + check('逐箱重放不重复计数', null, JSON.stringify(receipt) === JSON.stringify(replay)) + const duplicate = await request('POST', `${base}/receipts/carton`, { ...body, idempotencyKey: `${body.idempotencyKey}-duplicate` }) + check('同箱新键重扫拒绝', duplicate, duplicate.body?.code === 'A0409') + } + const premature = await request('POST', `${base}/complete`, { allowDifference: false, idempotencyKey: `${marker}-shortage-normal-complete` }) + check('短少场景拒绝正常完成', premature, premature.body?.code === 'A0409') + const completeBody = { allowDifference: true, idempotencyKey: `${marker}-shortage-onsite-complete` } + const completed = await call('带短少结束现场收货', 'POST', `${base}/complete`, completeBody) + check('现场结束保留短少一箱', null, completed.status === 'COMPLETED_WITH_DIFFERENCE' + && Number(completed.receivedPackages) === actual && Number(completed.pendingPackages) === 1) + const completeReplay = await call('带短少结束同键重放', 'POST', `${base}/complete`, completeBody) + check('现场结束重放稳定', null, JSON.stringify(completeReplay) === JSON.stringify(completed)) + const planPath = `/api/platform/v1/last-leg/inbound-plans/${encodeURIComponent(plan.planNo)}` + const customerId = String(record.order.tenantId) + const pending = await adminCall('读取短少待核平计划', 'GET', `${planPath}?customerId=${encodeURIComponent(customerId)}`) + check('短少计划等待后台确认', null, pending.status === 'PENDING_RECONCILIATION' + && Number(pending.expectedPackageCount) === expected && Number(pending.pickedPackageCount) === actual + && Number.isInteger(pending.version)) + const confirmBody = { customerId, version: pending.version, expectedPackageCount: expected, + receivedPackageCount: actual, reason: `${marker}逐箱核对短少一箱` } + const headers = { 'Idempotency-Key': `${marker}-shortage-confirm` } + const confirmed = await adminCall('后台确认普通海外仓短少入库', 'POST', `${planPath}/confirm-shortage`, confirmBody, headers) + check('普通海外仓短少确认成功', null, confirmed.status === 'PARTIAL_INBOUND') + const confirmReplay = await adminCall('后台短少确认同键重放', 'POST', `${planPath}/confirm-shortage`, confirmBody, headers) + check('后台短少确认重放不重复入库', null, JSON.stringify(confirmReplay) === JSON.stringify(confirmed)) + const finalPlan = await adminCall('回查短少入库最终计划', 'GET', `${planPath}?customerId=${encodeURIComponent(customerId)}`) + check('短少入库保留原应收及实际箱数', null, finalPlan.status === 'PARTIAL_INBOUND' + && Number(finalPlan.expectedPackageCount) === expected && Number(finalPlan.pickedPackageCount) === actual) + const stock = await call('回查短少入库可用库存', 'POST', '/api/pda/v1/last-leg/inventory/resolve', { code: inboundCode }) + check('短少库存只有本票真实已收三箱', null, stock.queryCode === inboundCode && Array.isArray(stock.items) + && stock.items.length > 0 && stock.items.every(row => row.inboundCode === inboundCode + && String(row.customerId) === customerId && row.locationId + && plan.pallets.some(item => String(item.warehousePalletId) === String(row.palletId))) + && stock.items.reduce((sum, row) => sum + Number(row.availablePackages), 0) === actual) + return { completed, plan: finalPlan, stock, receivingVerified: true, shortageVerified: true } +}