From 2b54e709f820fae760530fe74ba151768d2a420c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 23 Oct 2026 05:21:30 +0800 Subject: [PATCH] test(acceptance): verify native outbound ownership and exact relabel carton quantities --- scripts/admin-test-env-modules/README.md | 2 + ...phase4-overseas-dispatch-contract.test.mjs | 19 ++++- .../phase4-overseas-dispatch.mjs | 10 +-- ...se5-overseas-fulfillment-contract.test.mjs | 72 +++++++++++++++++-- .../phase5-overseas-fulfillment.mjs | 57 +++++++++++++-- .../phase5-root-fulfillment-contract.test.mjs | 3 +- .../phase5-root-fulfillment.mjs | 5 +- 7 files changed, 148 insertions(+), 20 deletions(-) diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index af0ac8201..f9e97a856 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -10,6 +10,8 @@ 货物废弃的库存列表按客户、仓、目的仓和 SKU 聚合,不带入仓码;脚本按该 SKU 的代表余额 ID 回查原始详情,并要求客户、仓、入仓码及全部数量桶与聚合值一致,不能给聚合行补造来源,也不能让混合来源汇总冒充单一余额。散板余位、询价和实收的 Long 版本接受服务端规范十进制编码,仍拒绝空值、负值、前导零和不安全整数。 +派车出库的客户归属按正式关联派送明细核对,验证客户、原出库号、原派车号、全部派送及箱数守恒,不读取 OutboundRow 未声明的 customerId。PDA 换标候选只限定原申请和真实目标托盘,不继承原接柜计划筛选;普通换标拣货按原箱号、当前位置及已核验逐 SKU 件数逐箱提交,不把箱数当件数。已发车原单的签收阶段可通过内部 continueDepartedFulfillment 续验,须保留原批次和当前发车证明;已有签收或未知结果必须核对原命令,不能重建。 + 散板来货沿当前 PDA 接口创建空托盘草稿,生成并绑定业务托盘后,读取原预报箱号与 SKU,通过逐箱清点保存显式 TEST 尺寸、重量和件数,再打托及运营实收确认。两托三箱的受控输入逐箱登记,不把托盘草稿接口当作测量接口,也不从整托总数猜测箱规;这些合成材料不代表真机测量或照片效果验收。 货物废弃在同一申请上传照片后,先重复提交同一照片 ID,要求 409/A0409、诊断标识齐全,且申请与库存快照不变,才继续正常 PDA 确认。实际废弃总账必须具有有效流水 ID,匹配原客户、仓库、托盘、SKU 和入仓码,扣减前后数量差为一箱;独立箱级流水仍不能用公共库存总账替代。对应离线反例位于 `cargo-discard-fulfillment-contract.test.mjs`,不额外创建客户、申请或照片。 diff --git a/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs index 16b1dcb34..c72be7276 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs @@ -5,7 +5,7 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve, basename } from 'node:path' import { DISPATCH_BASE as BASE, dispatchEvidenceContext, dispatchIo } from './grouped-dispatch-acceptance.mjs' -import { run, createClientRelabelDispatch, selectDispatchRouteFixture, dispatchRouteMatches, dispatchBusinessSnapshot, verifyDispatchRouteLifecycle } from './phase4-overseas-dispatch.mjs' +import { run, createClientRelabelDispatch, createPdaRelabelDispatch, selectDispatchRouteFixture, dispatchRouteMatches, dispatchBusinessSnapshot, verifyDispatchRouteLifecycle } from './phase4-overseas-dispatch.mjs' const clone = value => structuredClone(value) const rows = () => [1, 2, 3].map(n => ({ selection: { sourceType: 'INBOUND_PLAN', planPalletId: `2${n}`, returnReceiptId: null, relabelNo: null, warehousePalletId: `3${n}`, sourceVersion: `${n}`.repeat(64) }, @@ -119,6 +119,23 @@ test('client original relabel gets its own exact dispatch and refuses repeat exe assert.equal(h.calls.filter(c => c.method === 'POST').length, writes) }) +test('PDA relabel candidates do not inherit original receiving plan filters and missing sources fail before writes', async () => { + for (const missing of [false, true]) { + const h = server(); addClientRelabel(h) + const fixture = h.context.createdResources.phase4OverseasClientServices.relabelFulfillment + h.context.createdResources.phase4OverseasRelabel = { ...fixture, overseasWarehouseId: fixture.warehouseId } + if (missing) h.candidates.pop() + const proof = {}, io = dispatchIo(h.context, 'test', proof, 'PDA-RELABEL'); io.planIds = ['old-plan'] + const original = io.command.bind(io) + io.command = async (method, path, ...args) => { + if (path.includes('/candidates?')) assert.equal(new URL(path, 'https://offline.invalid').searchParams.has('planIds'), false) + return original(method, path, ...args) + } + if (missing) { await assert.rejects(createPdaRelabelDispatch(h.context, io, proof)); assert.equal(h.calls.some(c => c.method === 'POST'), false) } + else { await createPdaRelabelDispatch(h.context, io, proof); assert.equal(h.context.createdResources.phase4OverseasRelabel.dispatchNo, 'DS-1') } + } +}) + for (const fault of ['customer', 'pallet', 'missing']) test(`client relabel excludes ${fault} candidates before writes`, async () => { const h = server(); addClientRelabel(h) if (fault === 'customer') h.candidates.at(-1).customerId = '99' diff --git a/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs b/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs index 6cb465719..c67e90b57 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs @@ -137,13 +137,15 @@ async function prepareDraft(context, io, candidates) { 'not-implemented', { blocking: false }) return draft } -async function createRelabel(context, io, proof) { +export async function createPdaRelabelDispatch(context, io, proof) { const fixture = context.createdResources?.phase4OverseasRelabel if (fixture?.marker !== context.marker || fixture.status !== 'READY_FOR_OUTBOUND') return - const page = await candidatePage(io, fixture.overseasWarehouseId, { orderNo: fixture.relabelNo, sourceStage: 'RELABEL' }) + const page = await candidatePage(io, fixture.overseasWarehouseId, { orderNo: fixture.relabelNo, sourceStage: 'RELABEL', planIds: [] }) const candidates = page.filter(c => c.selection?.sourceType === 'RELABEL' && c.selection.relabelNo === fixture.relabelNo && id(c.customerId) === id(fixture.customerId) && id(c.warehouseId) === id(fixture.overseasWarehouseId) && fixture.targetPalletIds?.map(id).includes(id(c.selection.warehousePalletId))) - if (!candidates.length || candidates.length !== fixture.targetPalletIds.length) { proof.relabel = { status: 'NOT_RUN', reason: '没有本轮完整换标申请来源' }; return } + io.check('PDA原换标目标托盘候选完整且唯一', null, candidates.length > 0 && candidates.length === fixture.targetPalletIds.length + && new Set(candidates.map(c => id(c.selection.warehousePalletId))).size === candidates.length) + if (fixture.dispatchNo || proof.relabel) throw Error('PDA原换标排车已开始,保留原请求,不自动重建') const body = createBody(context.marker, 'RELABEL', candidates), created = await io.command('POST', BASE, body, '真实换标整单Create') await replay(io, BASE, body, created, '换标Create原键恢复') const current = await readDispatch(io, created.dispatchNo) @@ -225,7 +227,7 @@ export async function run(context) { finalStatus: current.data.status, stage: current.data.stage, data: current.data, items: current.items, route: current.route, sourceCandidates: selected, status: 'VERIFIED', loadingEvidenceStatus: 'NOT_RUN_MISSING_ACTUAL_MEASUREMENTS' }) io.check('交接Detail真实来源键和目的地守恒', null, same(proof.sourceKeys, sorted(selected.map(c => c.sourceKey))) && proof.finalStatus === 'ACTIVE' && proof.stage === 'BEFORE_LOADING') - await createRelabel(context, io, proof) + await createPdaRelabelDispatch(context, io, proof) await createClientRelabelDispatch(context, io, proof) await context.checkpoint() } catch (error) { diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs index 73cf998ad..260cf0e9b 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs @@ -1,7 +1,7 @@ import test from 'node:test' import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, dispatchInventorySources, inventorySourcesPresent, dispatchStockConservation, verifyAutomaticDeparture, verifyPartialOrderCompletion, verifyFulfillmentRootStatus, createDeliveryReturn, completeReturnedException, returnedExceptionFactsMatch, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer, - dispatchDestinationConservation, pickAll, readAutomaticDispatchDelivery, fulfillmentEvidenceContext, verifyRelabelDispatchOutbound, verifyClientRelabelSettlement } from './phase5-overseas-fulfillment.mjs' + dispatchDestinationConservation, dispatchOutboundMatches, continueDepartedFulfillment, pickAll, readAutomaticDispatchDelivery, fulfillmentEvidenceContext, verifyRelabelDispatchOutbound, verifyClientRelabelSettlement } from './phase5-overseas-fulfillment.mjs' import assert from 'node:assert/strict' import { readFile, mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -11,6 +11,33 @@ import { parseArguments } from '../admin-test-env-acceptance.mjs' const source = await readFile(fileURLToPath(new URL('./phase5-overseas-fulfillment.mjs', import.meta.url)), 'utf8') +test('原出库客户归属沿正式关联派送明细核对并拒绝串客漏项数量漂移', () => { + const reference = { outboundNo: 'CK-1', customerId: '101', packageCount: '7' } + const deliveries = [{ deliveryNo: 'DL-1', outboundNo: 'CK-1', customerId: '101', packageCount: '3' }, + { deliveryNo: 'DL-2', outboundNo: 'CK-1', customerId: '101', packageCount: '4' }] + const detail = { outbound: { outboundNo: 'CK-1', dispatchNo: 'PC-1', status: 'OUTBOUNDED', packageCount: 7 }, + deliveries: deliveries.map(row => ({ ...row, dispatchNo: 'PC-1', packageCount: Number(row.packageCount) })) } + assert.equal(dispatchOutboundMatches(detail, reference, 'PC-1', deliveries), true) + for (const change of [value => { value.deliveries[0].customerId = '102' }, value => { value.deliveries.pop() }, + value => { value.deliveries[0].outboundNo = 'CK-2' }, value => { value.deliveries[0].dispatchNo = 'PC-2' }, + value => { value.deliveries[1].deliveryNo = 'DL-1' }, value => { value.deliveries[1].packageCount = 3 }, + value => { value.outbound.status = 'PICKED' }, value => { value.outbound.packageCount = 8 }]) { + const value = structuredClone(detail); change(value) + assert.equal(dispatchOutboundMatches(value, reference, 'PC-1', deliveries), false) + } +}) + +test('原发车接续拒绝外批次或已开始签收的证明且不发送请求', async () => { + let calls = 0 + const args = { customerId: '101', warehouseId: '42', dispatchNo: 'PC-1', tripProof: { dispatchNo: 'PC-1', warehouseId: '42' }, + deliveries: [{}], outbounds: [{}], merchantToken: 'test-only' } + for (const proof of [{ marker: 'other', dispatchNo: 'PC-1' }, { marker: 'batch', dispatchNo: 'PC-1', receiptFiles: [{}] }]) { + await assert.rejects(continueDepartedFulfillment({ marker: 'batch', createdResources: { phase5OverseasFulfillment: proof }, + request: async () => { calls++ } }, args), /原发车证明/) + } + assert.equal(calls, 0) +}) + for (const fault of ['', 'foreign customer', 'old marks', 'sign failure', 'resource held', 'settlement failed']) { test(`client original relabel delivery includes receipt and resource release: ${fault || 'normal'}`, async () => { const relabel = { marker: 'batch', relabelNo: 'RL-CLIENT', dispatchNo: 'DP-CLIENT', warehouseId: '42', customerId: '101', @@ -22,13 +49,14 @@ for (const fault of ['', 'foreign customer', 'old marks', 'sign failure', 'resou async request(method, path) { calls.push('outbound') assert.equal(method, 'GET'); assert.ok(path.endsWith('/outbound-orders/OUT-1')) - return { http: 200, body: { code: '200', data: { outbound: { outboundNo: 'OUT-1', dispatchNo: 'DP-CLIENT', customerId: '101', - status: 'OUTBOUNDED', packageCount: 1 }, pallets: [{ cartonMarks: fault === 'old marks' ? 'OLD' : 'NEW' }] } } } + return { http: 200, body: { code: '200', data: { outbound: { outboundNo: 'OUT-1', dispatchNo: 'DP-CLIENT', + status: 'OUTBOUNDED', packageCount: 1 }, deliveries: [{ deliveryNo: 'DE-1', outboundNo: 'OUT-1', dispatchNo: 'DP-CLIENT', customerId: '101', packageCount: 1 }], + pallets: [{ cartonMarks: fault === 'old marks' ? 'OLD' : 'NEW' }] } } } } } const helpers = { async prepare(_c, _m, dispatch, warehouse) { calls.push('prepare'); assert.equal(dispatch, relabel.dispatchNo); assert.equal(warehouse, '42'); return { tripNo: 'TR-1' } }, async pick() { calls.push('pick'); return true }, async load() { calls.push('load') }, - async read() { calls.push('read'); return { outbounds: [{ customerId: '101', outboundNo: 'OUT-1' }], + async read() { calls.push('read'); return { outbounds: [{ customerId: '101', outboundNo: 'OUT-1', packageCount: 1 }], deliveries: [{ customerId: fault === 'foreign customer' ? '102' : '101', outboundNo: 'OUT-1', deliveryNo: 'DE-1', packageCount: 1 }] } }, async sign() { calls.push('sign'); return { order: { status: fault === 'sign failure' ? 'DELIVERING' : 'DELIVERED', deliveryNo: 'DE-1', customerId: '101', outboundNo: 'OUT-1' } } }, @@ -228,7 +256,7 @@ test('relabel marks are checked on the authoritative outbound DTO without weaken assert.equal(relabelOutboundMarksMatch({ ...detail, pallets: [{ cartonMarks }] }, relabel), false) } assert.equal(relabelOutboundMarksMatch({ ...detail, outbound: { packageCount: 3 } }, relabel), false) - assert.match(source, /detail\.data\?\.outbound\?\.dispatchNo === relabel\.dispatchNo/) + assert.match(source, /dispatchOutboundMatches\(detail.data, transferred.outbounds\[0\], relabel.dispatchNo, transferred.deliveries\)/) assert.match(source, /relabel\?\.marker !== context\.marker/) }) @@ -868,6 +896,40 @@ test('native PDA scans use exact current versions including zero, immutable repl assert.deepEqual(context.assertions.filter(row => !row.passed), []) }) +test('normal relabel scans each original carton using explicit ten-unit SKU verification and actual shared position', async () => { + const values = ['0', '9007199254740993', '9007199254740994'] + let currentVersion = values[0], picked = 0, firstBody, firstResult + const context = contextFor((method, path, body) => { + if (path.includes('/relabel-applications/')) return ok({ application: { relabelNo: 'TB-1', overseasWarehouseId: '42' }, + items: [1, 2].map(n => ({ cartonMark: 'BOX-' + n, sourceInventoryPositionId: '9007199254740996', notifiedPackageCount: 1, + operationType: 'CARTON_AND_SKU', skuItems: [{ skuCode: 'OLD', newSkuCode: 'NEW', quantity: 10 }] })) }) + if (method === 'GET') return ok({ dispatchNo: 'DS-1', status: 'ACTIVE', stage: 'LOADING', recordVersion: currentVersion, + pickingReconciled: picked === 2, counts: { packageCount: 2, pickedPackageCount: picked }, + items: [{ dispatchItemId: '9007199254740995', customerId: '100', sourceBusinessType: 'RELABEL_ORDER', sourceBusinessNo: 'TB-1', scheduledPackages: 2, pickedPackages: picked, + positions: [{ positionId: '9007199254740996', balanceId: '9007199254740997', availablePackages: 2 - picked }] }] }) + assert.match(path, /items\/9007199254740995\/pick$/) + assert.equal(Object.hasOwn(body, 'expectedVersion'), false) + assert.equal(typeof body.positionId, 'string') + if (body.idempotencyKey.endsWith('-STALE')) { + assert.equal(body.recordVersion, '0'); return { http: 200, body: { code: 'A0409' } } + } + if (firstBody?.idempotencyKey === body.idempotencyKey) { + assert.deepEqual(body, firstBody); return ok({ ...firstResult, replayed: true }) + } + assert.equal(body.recordVersion, currentVersion); assert.equal(body.packages, 1); assert.equal(body.productQuantity, 10); assert.equal(body.cartonMark, 'BOX-' + (picked + 1)) + picked += 1; currentVersion = values[picked] + firstBody = structuredClone(body) + firstResult = { dispatchNo: 'DS-1', status: 'ACTIVE', stage: 'LOADING', recordVersion: currentVersion, + result: { dispatchItemId: '9007199254740995', pickedPackages: picked }, counts: { pickedPackageCount: picked } } + return ok(firstResult) + }) + context.createdResources.phase4OverseasRelabel = { marker: context.marker, relabelNo: 'TB-1', customerId: '100', overseasWarehouseId: '42' } + assert.equal(await pickAll(context, 'PDA-IN-MEMORY', 'DS-1'), true) + assert.equal(picked, 2) + assert.equal(context.calls.some(call => /loading-complete|\/complete|\/depart/.test(call.path)), false) + assert.deepEqual(context.assertions.filter(row => !row.passed), []) +}) + function automaticDispatch(recordVersion = '0') { return { dispatchNo: 'DS-1', recordVersion, status: 'ACTIVE', stage: 'DELIVERING', pickingReconciled: true, counts: { packageCount: 2, pickedPackageCount: 2, shippedPackageCount: 2 }, diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs index a629cee74..39a669a89 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs @@ -907,8 +907,7 @@ export async function verifyRelabelDispatchOutbound(context, relabel, token, war `${LASTLEG_BASE}/outbound-orders/${encodeURIComponent(outboundNo)}`) require('排车出库快照精确保留全部新箱唛且无原箱唛或重复箱', success(detail.response) && detail.data?.outbound?.outboundNo === outboundNo - && detail.data?.outbound?.dispatchNo === relabel.dispatchNo - && id(detail.data?.outbound?.customerId) === customerId && detail.data?.outbound?.status === 'OUTBOUNDED' + && dispatchOutboundMatches(detail.data, transferred.outbounds[0], relabel.dispatchNo, transferred.deliveries) && relabelOutboundMarksMatch(detail.data, relabel)) Object.assign(relabel, { outboundNo, deliveryNos: transferred.deliveries.map(row => row.deliveryNo).sort() }) for (const [index, delivery] of transferred.deliveries.entries()) { @@ -933,7 +932,7 @@ export async function verifyRelabelDispatchOutbound(context, relabel, token, war } // 主车结案后串行复用同仓PDA及车辆池,客户原申请与旧换标样本分别核验。 -async function finishRelabelDeliveries(context, mainTrip, warehouseId) { +export async function finishRelabelDeliveries(context, mainTrip, warehouseId) { if (mainTrip.closure?.status !== 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') throw Error('主车尚未释放,不能启动换标履约') const access = await resolvePickerAccess(context) const picker = access ? await createPicker(context, 95, warehouseId, access) : null @@ -1017,7 +1016,27 @@ export async function pickAll(context, token, dispatchNo) { detail = await pda(context, token, 'GET', '扫码前刷新公共版本及真实来源', `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) let item = detail.data?.items?.find(row => id(row.dispatchItemId) === id(original.dispatchItemId)) if (!success(detail.response) || !item) return false - const sources = item.returnCartons?.length ? item.returnCartons.map(carton => ({ carton })) : (item.positions || []).map(position => ({ position })) + if (Number(item.scheduledPackages) === Number(item.pickedPackages)) continue + let sources = item.returnCartons?.length ? item.returnCartons.map(carton => ({ carton })) : (item.positions || []).map(position => ({ position })) + if (item.sourceBusinessType === 'RELABEL_ORDER') { + const originalRelabel = [context.createdResources?.phase4OverseasRelabel, context.createdResources?.phase4OverseasClientServices?.relabelFulfillment] + .find(row => row?.marker === context.marker && row.relabelNo === item.sourceBusinessNo && id(row.customerId) === id(item.customerId)) + if (!originalRelabel) throw Error('换标拣货缺少本轮原申请身份,不能猜测SKU件数') + const relabel = await platform(context, 'GET', '扫码前读取原换标逐箱SKU与实际位置', `${LASTLEG_BASE}/relabel-applications/${encodeURIComponent(item.sourceBusinessNo)}`) + const value = relabel.data + if (!success(relabel.response) || value?.application?.relabelNo !== item.sourceBusinessNo + || id(value.application.overseasWarehouseId) !== id(originalRelabel.warehouseId ?? originalRelabel.overseasWarehouseId) + || !Array.isArray(value.items)) throw Error('换标逐箱来源或仓库身份不一致') + sources = value.items.filter(row => (item.positions || []).some(position => id(position.positionId) === id(row.sourceInventoryPositionId) + && Number(position.availablePackages) > 0)).map(row => { + if (!row.cartonMark || row.notifiedPackageCount !== 1 || !['CARTON_AND_SKU', 'SKU_LABEL'].includes(row.operationType) + || !row.skuItems?.length || row.skuItems.some(sku => !sku.skuCode || !sku.newSkuCode || !Number.isSafeInteger(Number(sku.quantity)) || Number(sku.quantity) <= 0)) + throw Error('换标原箱缺少明确逐SKU核验件数,不按箱数推算') + return { position: item.positions.find(position => id(position.positionId) === id(row.sourceInventoryPositionId)), + carton: { cartonMark: row.cartonMark, productQuantity: row.skuItems.reduce((sum, sku) => sum + Number(sku.quantity), 0) } } + }) + if (new Set(sources.map(row => row.carton.cartonMark)).size !== sources.length) throw Error('换标逐箱来源重复') + } for (const [sourceIndex, source] of sources.entries()) { const remaining = Number(item.scheduledPackages) - Number(item.pickedPackages) if (remaining <= 0) break @@ -1123,6 +1142,21 @@ export async function createDeliveryReturn(context, { customerId, warehouseId, d return { returned, returnBody } } +/** 出库详情的客户归属来自关联派送明细,OutboundRow本身不声明customerId。 */ +export function dispatchOutboundMatches(detail, reference, dispatchNo, deliveries) { + const outbound = detail?.outbound + const expected = Array.isArray(deliveries) ? deliveries.filter(row => row.outboundNo === reference?.outboundNo) : [] + const actual = detail?.deliveries + return Boolean(reference?.customerId && reference.outboundNo && dispatchNo && expected.length + && outbound?.outboundNo === reference.outboundNo && outbound.dispatchNo === dispatchNo && outbound.status === 'OUTBOUNDED' + && Number(outbound.packageCount) === Number(reference.packageCount) + && Array.isArray(actual) && actual.length === expected.length && new Set(actual.map(row => row.deliveryNo)).size === actual.length + && expected.every(row => id(row.customerId) === id(reference.customerId) && actual.some(value => value.deliveryNo === row.deliveryNo + && value.outboundNo === reference.outboundNo && value.dispatchNo === dispatchNo && id(value.customerId) === id(reference.customerId) + && Number.isSafeInteger(Number(value.packageCount)) && Number(value.packageCount) > 0 && Number(value.packageCount) === Number(row.packageCount))) + && actual.reduce((sum, row) => sum + Number(row.packageCount), 0) === Number(outbound.packageCount)) +} + export async function run(context) { context.createdResources.phase5OverseasFulfillment = { marker: context.marker, dispatchNo: null, outboundNo: null, deliveryNo: null, deliveryNos: [], @@ -1231,6 +1265,18 @@ export async function run(context) { try { await disablePicker(context, picker, 5) } finally { if (picker) picker.token = null; await context.checkpoint() } } + return continueDepartedFulfillment(context, { customerId, warehouseId, dispatchNo, tripProof, deliveries, outbounds, merchantToken }) +} + +/** 已发车原单的签收、回访及结案阶段;不重新准备订单、扫码或发车。 */ +export async function continueDepartedFulfillment(context, { customerId, warehouseId, dispatchNo, tripProof, deliveries, outbounds, merchantToken }) { + const proof = context.createdResources?.phase5OverseasFulfillment + if (!merchantToken || proof?.marker !== context.marker || proof.dispatchNo !== dispatchNo || tripProof?.dispatchNo !== dispatchNo + || id(tripProof.warehouseId) !== warehouseId || !Array.isArray(deliveries) || !deliveries.length || !Array.isArray(outbounds) || !outbounds.length + || proof.finalStatus || proof.receiptFiles?.length || tripProof.closure) throw Error('签收阶段必须使用本轮尚未签收的原发车证明') + const departed = await readAutomaticDispatchDelivery(context, dispatchNo) + if (!departed || JSON.stringify(departed.deliveries) !== JSON.stringify(deliveries) || JSON.stringify(departed.outbounds) !== JSON.stringify(outbounds)) + throw Error('原发车单据与当前客户来源或数量不一致,停止签收') const primaryDelivery = deliveries.find(row => id(row.customerId) === customerId) if (!primaryDelivery) { blocker(context, '客户签收资料前置', '本轮主客户不在实际派送结果中'); return } const deliveryNo = primaryDelivery.deliveryNo, outboundNo = primaryDelivery.outboundNo @@ -1238,8 +1284,7 @@ export async function run(context) { Object.assign(context.createdResources.phase5OverseasFulfillment, { outboundNo, deliveryNo, deliveryNos, outbounds, deliveries }) for (const outboundRef of outbounds) { const outbound = await platform(context, 'GET', '逐客户回查发车后真实出库状态', `${LASTLEG_BASE}/outbound-orders/${encodeURIComponent(outboundRef.outboundNo)}`) - const passed = outbound.data?.outbound?.outboundNo === outboundRef.outboundNo - && id(outbound.data?.outbound?.customerId) === id(outboundRef.customerId) && outbound.data?.outbound?.status === 'OUTBOUNDED' + const passed = dispatchOutboundMatches(outbound.data, outboundRef, dispatchNo, deliveries) context.assert(MODULE, '每客户出库单独立OUTBOUNDED且归属不漂移', outbound.response, passed, trace(outbound.response), OPTIONS) if (!passed) return } diff --git a/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs index 444fea329..5ae72584e 100644 --- a/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs @@ -625,7 +625,8 @@ function nativeFragmentFixture() { data = { order: { id: '9007199254741001', customerId: '90', outboundNo: 'OB-F40', deliveryNo: 'DL-F40', version: '9007199254741002', status: departed ? 'DELIVERING' : 'WAIT_DELIVERY' }, skus: [{ inboundCode: 'IN-F40', packageCount: 1 }] } } else if (method === 'GET' && path.endsWith('/outbound-orders/OB-F40')) { - data = { outbound: { customerId: '90', outboundNo: 'OB-F40', status: 'OUTBOUNDED' }, pallets: [{ inboundCode: 'IN-F40' }] } + data = { outbound: { outboundNo: 'OB-F40', dispatchNo: 'DS-F40', status: 'OUTBOUNDED', packageCount: 1 }, + deliveries: [{ ...delivery, dispatchNo: 'DS-F40' }], pallets: [{ inboundCode: 'IN-F40' }] } } else if (method === 'GET' && path.endsWith('/dispatches/DS-F40')) { assert.equal(signed, true); events.push('normal-completed') data = { dispatchNo: 'DS-F40', status: 'ACTIVE', stage: 'DELIVERED', recordVersion: '9007199254741004' } diff --git a/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs index 78fa2a883..b0bc19d01 100644 --- a/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs @@ -4,7 +4,7 @@ import { verifyPalletLabels } from './phase3-loading-finalize.mjs' import { stableLoadedCartonMarks } from './phase3-loading.mjs' import { awaitProducerConsumption, convertReceivingContainers, runReceiving, resolvePickerAccess, createPicker, disablePicker } from './phase4-overseas-pda.mjs' -import { pickAll, verifyAutomaticDeparture, saveReceiptAndComplete, readAutomaticDispatchDelivery, fulfillmentEvidenceContext } from './phase5-overseas-fulfillment.mjs' +import { pickAll, verifyAutomaticDeparture, saveReceiptAndComplete, readAutomaticDispatchDelivery, dispatchOutboundMatches, fulfillmentEvidenceContext } from './phase5-overseas-fulfillment.mjs' import { isDeepStrictEqual } from 'node:util' import { assertTestDeliveryFaultProof } from '../test-delivery-fault-proof.mjs' import { prepareTerminalDrayage } from './phase5-terminal-drayage.mjs' @@ -568,8 +568,7 @@ export async function sendFragment(context, producer, picker, merchantToken, ind check(context, 'F40 自动发车真实出库一箱', Number(ready.data.counts?.shippedPackageCount) === 1, `dispatch=${dispatchNo}`) dispatchProof.departure = ready.data; dispatchProof.loadingReplay = tripProof.loadingCommand?.replay; dispatchProof.stage = 'DELIVERING' const outbound = await api(context, 'GET', '回查实际来源出库完成', `/api/platform/v1/last-leg/outbound-orders/${outboundNo}`) - check(context,'F40 实际出库单 OUTBOUNDED 且来源入仓码匹配', id(outbound.outbound?.customerId) === customerId - && outbound.outbound?.outboundNo === outboundNo && outbound.outbound?.status === 'OUTBOUNDED' + check(context,'F40 实际出库单 OUTBOUNDED 且来源入仓码匹配', dispatchOutboundMatches(outbound, outbounds[0], dispatchNo, deliveries) && outbound.pallets?.length > 0 && outbound.pallets.every(row => row.inboundCode === producer.inboundCode), `outbound=${outboundNo}`) const beforeDelivery = await api(context, 'GET', '回查实际发车推进本片派送及其版本', deliveryPath) check(context, 'F40 实际发车已推进派送DELIVERING且身份未漂移', beforeDelivery.order?.status === 'DELIVERING'