From 20a748cf7f10287d540ba89860b3494057ee7e9f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 20 Sep 2026 18:25:12 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/admin-test-env-modules/README.md | 2 + ...-overseas-return-receipt-contract.test.mjs | 9 ++- .../phase4-overseas-return-receipt.mjs | 79 ++++++++++++++----- .../return-boundary-scenarios.mjs | 46 ++++++++--- .../return-review-contract.test.mjs | 41 ++++++++++ 5 files changed, 145 insertions(+), 32 deletions(-) create mode 100644 scripts/admin-test-env-modules/return-review-contract.test.mjs diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index afd844fc1..34babb109 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -10,6 +10,8 @@ 退货专项只运行 `--modules phase4-overseas-return-receipt`,自动准备本轮客户;覆盖非法/重复预报、退货单号和 SKU 查询、非法/未知 SKU 实收、超收及失败不落账、分批签收完成、部分签收禁止合箱、合箱非法箱规/数量/来源、幂等重放前后库存快照一致、逐箱分页无重复遗漏、客户 SKU 数量守恒及标签。通过显式测试环境文件运行总入口,JSON 路径由 `--output` 指定;会创建隔离测试数据,finally 停用临时 PDA,不删除业务记录。失败保留报告,先核对记录再决定是否新批次重跑。 +退货预报创建后为 `PENDING_REVIEW`。签收前必须通过客户详情确认归属,再经平台 `/return-orders/{returnNo}/review` 携带 `X-Customer-Id` 审核,客户回查 `PENDING_RECEIPT` 且实收为零;审核失败立即停止签收。`return-review-contract.test.mjs` 覆盖归属拒绝、审核权限失败和回查未收敛。主签收场景按单 SKU 分甲3件、乙2件两批,使用正式 PDA 退货托盘生成接口返回的 `palletCode`,不复用国内基础托盘;每批对应一个计费批次。最后一批先验证缺照片拒绝及库存不变,再上传明确为合成测试图片的 PNG 并完成签收;不把图片称为真实现场照片。边界扩展与换标后续仍需按新协议独立验证,主签收通过不能证明全部退货链通过。 + `return-boundary-scenarios.mjs` 是退货专项自动调用的内部扩展,不单独注册入口:本轮新建第二隔离客户,复用两个有效海外仓,覆盖跨客户单据/箱/标签/列表隔离、跨仓 PDA 隔离、两人争抢最后一件的签收及合箱、同键并发签收与成功后重试、多单同 SKU 全量合箱和实际来源追溯、耗尽箱清零、多单 SKU 候选和输入边界。异常写入逐次前后回查订单、签收批次、箱及 SKU;同键重试只模拟响应丢失后的客户端行为,不代表网络故障注入。每组并发严格为两个请求,不启动批量 worker;缺少第二仓或操作员属于失败,不能跳过后宣称通过。finally 停用新增 PDA,保留测试客户和业务记录;不触发部署、SQL、换标、出库或二次合箱。真实运行须明确授权目标环境及此小规模并发边界。`merchant-qualification-fixture.mjs` 的内部参数 `publishEnvironment: false` 只将第二客户凭据留在独立内存上下文,不覆盖主客户环境凭据,默认主 fixture 行为不变。 扩展离线检查:`node --test scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs scripts/script-documentation-contract.test.mjs`。这些校验验证脚本编排和断言,不代替真实 HTTP 验收;运行报告中的每个失败均保留阻断。 diff --git a/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs index 013e3b62b..11ca11af5 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs @@ -19,8 +19,13 @@ test('return receipt acceptance covers both lookup paths, partial/final receipt assert.match(source, /PDA_RECEIPTS = '\/api\/pda\/v1\/last-leg\/return-receipts'/) assert.match(source, /\$\{PDA_RECEIPTS\}\/start/) assert.match(source, /\$\{PDA_RECEIPTS\}\/sku-candidates/) - assert.match(source, /PDA首次分批签收2件/) - assert.match(source, /PDA按SKU完成剩余3件签收/) + assert.match(source, /PDA首次单SKU签收甲3件/) + assert.match(source, /PDA按单SKU完成乙2件签收/) + assert.match(source, /palletCode: cartonA/) + assert.match(source, /最后一批缺少打托照片拒绝且库存不变/) + assert.match(source, /photos\/files/) + assert.match(source, /requestOverseasMultipart\('POST',[^\n]+photoForm, picker\.token\)/) + assert.doesNotMatch(source, /photoForm, \{\}, picker\.token/) assert.match(source, /不存在SKU不是业务错误/) assert.match(source, /已完成退货单不再进入待签收流程/) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs index 649cec453..5ff371f88 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs @@ -92,6 +92,32 @@ function itemMap(items) { return new Map((Array.isArray(items) ? items : []).map((item) => [item.skuCode, item])) } +export async function approveReturnFixture(context, merchantToken, created) { + const expectedCustomer = id(context.context?.merchantQualificationFixture?.tenantId) + const order = created?.order + if (!expectedCustomer || !nonBlank(id(order?.returnNo)) + || order.status !== 'PENDING_REVIEW') throw new Error('退货审核前置的客户、单号或状态不符') + // Summary deliberately omits customerId. Verify ownership via the merchant-scoped detail API. + const owned = await overseas(context, merchantToken, 'GET', '客户核对本轮待审核退货单', + `${RETURN_ORDERS}/${encodeURIComponent(order.returnNo)}`) + if (!success(owned.response) || owned.data?.order?.returnNo !== order.returnNo + || owned.data?.order?.status !== 'PENDING_REVIEW') throw new Error('客户不可读取当前待审核退货单') + const path = `/api/platform/v1/last-leg/return-orders/${encodeURIComponent(order.returnNo)}/review` + const response = await context.requestOverseas('POST', path, + { decision: 'APPROVE', remark: `${context.marker} 退货验收审核` }, { 'X-Customer-Id': expectedCustomer }) + add(context, '平台审核本轮退货预报', response, success(response)) + if (!success(response)) throw new Error(`退货审核失败:${trace(response)}`) + const detail = await overseas(context, merchantToken, 'GET', '客户回查退货审核状态', + `${RETURN_ORDERS}/${encodeURIComponent(order.returnNo)}`) + const verified = success(detail.response) && detail.data?.order?.returnNo === order.returnNo + && detail.data?.order?.status === 'PENDING_RECEIPT' + && Number(detail.data?.order?.receivedQuantity) === 0 + context.assert(MODULE, '审核后同一客户同一退货单进入待签收且尚未入库', detail.response, verified, + trace(detail.response), OPTIONS) + if (!verified) throw new Error('退货审核回查未收敛,停止签收链路') + return detail.data +} + export async function run(context) { context.createdResources.phase4OverseasReturnReceipt = { marker: context.marker, returnNo: null, receiptNos: [], receiptBatchNos: [], cartonMarks: [], @@ -126,12 +152,14 @@ export async function run(context) { const created = await overseas(context, merchantToken, 'POST', '客户创建双SKU退货预报', RETURN_ORDERS, createBody) const returnNo = id(created.data?.order?.returnNo) context.assert(MODULE, '退货预报初始数量和状态正确', created.response, - nonBlank(returnNo) && created.data?.order?.status === 'PENDING_RECEIPT' + nonBlank(returnNo) && created.data?.order?.status === 'PENDING_REVIEW' && Number(created.data?.order?.expectedQuantity) === 5 && Number(created.data?.order?.receivedQuantity) === 0 && itemMap(created.data?.items).size === 2, `returnNo=${returnNo || 'missing'}`, OPTIONS) - if (!returnNo) return + if (!success(created.response) || !returnNo || created.data?.order?.status !== 'PENDING_REVIEW') { + throw new Error('退货预报创建前置失败,停止后续签收') + } context.createdResources.phase4OverseasReturnReceipt.returnNo = returnNo async function snapshot() { @@ -164,6 +192,8 @@ export async function run(context) { await reject('退货预报同键异载荷返回冲突', conflict, () => context.requestOverseas('POST', RETURN_ORDERS, { ...createBody, remark: `${context.marker}-CHANGED` }, {}, merchantToken)) + await approveReturnFixture(context, merchantToken, created.data) + for (const [name, items] of [ ['空SKU明细', []], ['零预报数量', [{ skuCode: skuA, expectedQuantity: 0 }]], @@ -201,15 +231,20 @@ export async function run(context) { && Number(candidate.skuExpectedQuantity) === 2 && Number(candidate.skuReceivedQuantity) === 0), trace(skuCandidates.response), OPTIONS) - const cartonA = `RC-${suffix}-A`.slice(0, 128) + const palletA = await overseas(context, picker.token, 'POST', 'PDA生成本轮SKU甲签收托盘', `${PDA_RECEIPTS}/pallets`, { returnNo, skuCode: skuA }) + const palletB = await overseas(context, picker.token, 'POST', 'PDA生成本轮SKU乙签收托盘', `${PDA_RECEIPTS}/pallets`, { returnNo, skuCode: skuB }) + if (!success(palletA.response) || !nonBlank(palletA.data?.palletCode) + || !success(palletB.response) || !nonBlank(palletB.data?.palletCode)) throw new Error('签收托盘准备失败') + const cartonA = palletA.data.palletCode const firstBody = { - returnNo, trackingNo: `TRACK-${suffix}`, startedBy: 'RETURN_NO', cartonMark: cartonA, - items: [{ skuCode: skuA, receivedQuantity: 2 }], idempotencyKey: key(context, 'RECEIPT-1'), + returnNo, trackingNo: `TRACK-${suffix}`, startedBy: 'RETURN_NO', palletCode: cartonA, + items: [{ skuCode: skuA, receivedQuantity: 3 }], idempotencyKey: key(context, 'RECEIPT-1'), } - const first = await overseas(context, picker.token, 'POST', 'PDA首次分批签收2件', `${PDA_RECEIPTS}/confirm`, firstBody) + const first = await overseas(context, picker.token, 'POST', 'PDA首次单SKU签收甲3件', `${PDA_RECEIPTS}/confirm`, firstBody) + if (!success(first.response)) throw new Error('首次签收失败,停止后续库存场景') context.assert(MODULE, '首次签收形成唯一箱和仓储计费批次', first.response, first.data?.orderStatus === 'PARTIALLY_RECEIVED' && first.data?.fullyMatched === false - && Number(first.data?.receivedQuantity) === 2 && first.data?.cartonMark === cartonA + && Number(first.data?.receivedQuantity) === 3 && first.data?.palletCode === cartonA && nonBlank(first.data?.receiptNo) && nonBlank(first.data?.receiptBatchNo), `receiptNo=${id(first.data?.receiptNo) || 'missing'}, billingBatch=${id(first.data?.receiptBatchNo) || 'missing'}`, OPTIONS) const firstReceiptNo = id(first.data?.receiptNo) @@ -219,14 +254,14 @@ export async function run(context) { context.assert(MODULE, '签收重放不重复创建签收单、箱或计费批次', firstReplay.response, id(firstReplay.data?.receiptNo) === firstReceiptNo && id(firstReplay.data?.receiptBatchNo) === firstBatchNo - && id(firstReplay.data?.cartonMark) === cartonA, + && id(firstReplay.data?.palletCode) === cartonA, trace(firstReplay.response), OPTIONS) await reject('签收同键异载荷返回冲突', conflict, () => context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, { ...firstBody, trackingNo: `TRACK-${suffix}-CHANGED`, }, {}, picker.token)) await reject('超收被拒绝且不得生成费用批次', conflict, () => context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, { - returnNo, startedBy: 'SKU', items: [{ skuCode: skuA, receivedQuantity: 2 }], + returnNo, startedBy: 'SKU', palletCode: cartonA, items: [{ skuCode: skuA, receivedQuantity: 2 }], idempotencyKey: key(context, 'OVER-RECEIPT'), }, {}, picker.token)) @@ -249,26 +284,34 @@ export async function run(context) { `${RETURN_ORDERS}/${encodeURIComponent(returnNo)}`) context.assert(MODULE, '超收失败后订单、签收和费用批次数量均未变化', afterPartial.response, afterPartial.data?.order?.status === 'PARTIALLY_RECEIVED' - && Number(afterPartial.data?.order?.receivedQuantity) === 2 + && Number(afterPartial.data?.order?.receivedQuantity) === 3 && afterPartial.data?.receipts?.length === 1 && id(afterPartial.data.receipts[0]?.receiptBatchNo) === firstBatchNo, `receipts=${afterPartial.data?.receipts?.length ?? 'missing'}`, OPTIONS) const secondBody = { - returnNo, startedBy: 'SKU', - items: [{ skuCode: skuA, receivedQuantity: 1 }, { skuCode: skuB, receivedQuantity: 2 }], + returnNo, startedBy: 'SKU', palletCode: palletB.data.palletCode, + items: [{ skuCode: skuB, receivedQuantity: 2 }], idempotencyKey: key(context, 'RECEIPT-2'), } - const second = await overseas(context, picker.token, 'POST', 'PDA按SKU完成剩余3件签收', `${PDA_RECEIPTS}/confirm`, secondBody) + await reject('最后一批缺少打托照片拒绝且库存不变', conflict, () => context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, secondBody, {}, picker.token)) + const photoForm = new FormData() + // Synthetic HTTP fixture, not a photograph of real warehouse activity. + photoForm.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }), 'return-receipt-test.png') + const photo = await context.requestOverseasMultipart('POST', `${PDA_RECEIPTS}/${encodeURIComponent(returnNo)}/photos/files`, photoForm, picker.token) + add(context, '上传本轮合成打托测试照片', photo, success(photo)) + if (!success(photo) || !photo.body?.data?.fileId) throw new Error('打托照片上传失败') + const second = await overseas(context, picker.token, 'POST', 'PDA按单SKU完成乙2件签收', `${PDA_RECEIPTS}/confirm`, secondBody) + if (!success(second.response)) throw new Error('最终签收失败,停止合箱和换标后续') context.assert(MODULE, '最终签收自动核平并生成第二个费用批次', second.response, second.data?.orderStatus === 'COMPLETED' && second.data?.fullyMatched === true - && Number(second.data?.receivedQuantity) === 5 && nonBlank(second.data?.cartonMark) + && Number(second.data?.receivedQuantity) === 5 && second.data?.palletCode === palletB.data.palletCode && nonBlank(second.data?.receiptNo) && nonBlank(second.data?.receiptBatchNo) && id(second.data?.receiptBatchNo) !== firstBatchNo, `billingBatch=${id(second.data?.receiptBatchNo) || 'missing'}`, OPTIONS) const secondReceiptNo = id(second.data?.receiptNo) const secondBatchNo = id(second.data?.receiptBatchNo) - const cartonB = id(second.data?.cartonMark) + const cartonB = id(second.data?.palletCode) context.createdResources.phase4OverseasReturnReceipt.receiptNos.push(firstReceiptNo, secondReceiptNo) context.createdResources.phase4OverseasReturnReceipt.receiptBatchNos.push(firstBatchNo, secondBatchNo) context.createdResources.phase4OverseasReturnReceipt.cartonMarks.push(cartonA, cartonB) @@ -323,8 +366,8 @@ export async function run(context) { && Number(summaries.get(skuB)?.totalQuantity) === 2, `skuA=${summaries.get(skuA)?.totalQuantity ?? 'missing'}, skuB=${summaries.get(skuB)?.totalQuantity ?? 'missing'}`, OPTIONS) - for (const [index, expected] of [{ cartonMark: cartonA, batchNo: firstBatchNo, quantity: 2 }, - { cartonMark: cartonB, batchNo: secondBatchNo, quantity: 3 }].entries()) { + for (const [index, expected] of [{ cartonMark: cartonA, batchNo: firstBatchNo, quantity: 3 }, + { cartonMark: cartonB, batchNo: secondBatchNo, quantity: 2 }].entries()) { const carton = await overseas(context, context.overseasToken, 'GET', `平台回查第${index + 1}个退货箱明细`, `/api/platform/v1/last-leg/return-inventory/cartons/${encodeURIComponent(expected.cartonMark)}`) context.assert(MODULE, `第${index + 1}个退货箱来源费用批次和数量正确`, carton.response, @@ -364,7 +407,7 @@ export async function run(context) { && nonBlank(merged.data?.label?.printableHtml), `cartonMark=${merged.data?.cartonMark || 'missing'}`, OPTIONS) const consumedBatches = [] - for (const [mark, batch, before] of [[cartonA, firstBatchNo, 2], [cartonB, secondBatchNo, 3]]) { + for (const [mark, batch, before] of [[cartonA, firstBatchNo, 3], [cartonB, secondBatchNo, 2]]) { const source = await overseas(context, merchantToken, 'GET', '合箱后回查来源箱实际扣减', `${RETURN_INVENTORY}/cartons/${encodeURIComponent(mark)}`) if (success(source.response) && Number(source.data?.totalQuantity) < before) consumedBatches.push(batch) diff --git a/scripts/admin-test-env-modules/return-boundary-scenarios.mjs b/scripts/admin-test-env-modules/return-boundary-scenarios.mjs index 8b935b53f..2a09c6e46 100644 --- a/scripts/admin-test-env-modules/return-boundary-scenarios.mjs +++ b/scripts/admin-test-env-modules/return-boundary-scenarios.mjs @@ -53,6 +53,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p const key = name => `${prefix}-${name}-${++sequence}` const extraPickers = [] let foreignPicker + const customerByToken = new Map([[merchantToken, String(context.context.merchantQualificationFixture.tenantId)]]) let failures = 0 const resources = context.createdResources.returnBoundaryScenarios = { returnNos: [], cartonMarks: [], cases: [], concurrencyRounds: 3 } function check(name, response, passed, evidence = '') { @@ -77,19 +78,34 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p resources.returnNos.push(data.order.returnNo) check('新退货单及退货主单采用独立完整编号', null, /^RT-[0-9A-Z]{25}$/.test(data.order.returnNo) && /^RETU-[0-9A-Z]{25}$/.test(data.order.lastlegOrderNo)) - return { returnNo: data.order.returnNo, sku, skus: items.map(item => item.skuCode), quantity, token, warehouseId } + const customerId = customerByToken.get(token) + if (!customerId || data.order.status !== 'PENDING_REVIEW') throw new Error('边界退货客户或审核初态不符') + const reviewed = await context.requestOverseas('POST', `/api/platform/v1/last-leg/return-orders/${data.order.returnNo}/review`, + { decision: 'APPROVE', remark: context.marker }, { 'X-Customer-Id': customerId }) + check('边界退货单审核通过进入待签收', reviewed, ok(reviewed) && reviewed.body.data?.order?.status === 'PENDING_RECEIPT') + if (!ok(reviewed) || reviewed.body.data?.order?.status !== 'PENDING_RECEIPT') throw new Error('边界退货审核失败') + const operator = String(warehouseId) === String(warehouse.id) ? picker : foreignPicker + if (!operator?.token) throw new Error('缺少本仓签收操作员') + const pallet = await good(operator.token, 'POST', '准备边界退货实收托盘', `${RECEIPTS}/pallets`, { returnNo: data.order.returnNo, skuCode: sku }) + if (!pallet.palletCode) throw new Error('退货托盘码缺失') + const form = new FormData() + form.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }), 'synthetic-return-boundary.png') + const photo = await context.requestOverseasMultipart('POST', `${RECEIPTS}/${data.order.returnNo}/photos/files`, form, operator.token) + check('边界签收准备合成打托照片', photo, ok(photo) && Boolean(photo.body.data?.fileId)) + if (!ok(photo) || !photo.body.data?.fileId) throw new Error('边界照片上传失败') + return { returnNo: data.order.returnNo, sku, skus: items.map(item => item.skuCode), quantity, token, warehouseId, palletCode: pallet.palletCode } } function receiptBody(order, quantity = order.quantity, idempotencyKey = key('RECEIPT')) { - return { returnNo: order.returnNo, startedBy: 'RETURN_NO', items: [{ skuCode: order.sku, receivedQuantity: quantity }], idempotencyKey } + return { returnNo: order.returnNo, startedBy: 'RETURN_NO', palletCode: order.palletCode, items: [{ skuCode: order.sku, receivedQuantity: quantity }], idempotencyKey } } async function receive(order, operator = picker) { const data = await good(operator.token, 'POST', '准备边界场景实收库存', `${RECEIPTS}/confirm`, receiptBody(order)) - check('签收单批次及自动箱唛采用独立完整编号', null, + check('签收单批次采用独立完整编号且绑定本轮托盘', null, /^RR-[0-9A-Z]{25}$/.test(data.receiptNo) && /^RB-[0-9A-Z]{25}$/.test(data.receiptBatchNo) - && /^RC-[0-9A-Z]{25}$/.test(data.cartonMark)) - order.cartonMark = data.cartonMark + && data.palletCode === order.palletCode) + order.cartonMark = data.palletCode order.batch = data.receiptBatchNo - resources.cartonMarks.push(data.cartonMark) + resources.cartonMarks.push(data.palletCode) return data } function mergeBody(orders, sku, quantity, idempotencyKey = key('MERGE')) { @@ -140,6 +156,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p check('第二隔离客户登录取得海外令牌', loginB, ok(loginB) && Boolean(loginB.body?.data?.overseasAccessToken)) if (!ok(loginB) || !loginB.body?.data?.overseasAccessToken) return const tokenB = loginB.body.data.overseasAccessToken + customerByToken.set(tokenB, String(fixtureB.tenantId)) const warehouseResponse = await context.request('GET', '/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE') const otherWarehouse = rows(warehouseResponse?.body?.data).find(row => String(row.id) !== String(warehouse.id)) check('跨仓场景具备第二个真实有效海外仓', warehouseResponse, ok(warehouseResponse) && Boolean(otherWarehouse)) @@ -205,7 +222,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p const retry = await request(picker.token, 'POST', `${RECEIPTS}/confirm`, body) const accepted = sameKeyResponses.find(ok)?.body.data check('模拟成功响应丢失后原键重试复用原签收', retry, ok(retry) && accepted - && ['receiptNo', 'receiptBatchNo', 'cartonMark'].every(field => retry.body.data[field] === accepted[field])) + && ['receiptNo', 'receiptBatchNo', 'palletCode'].every(field => retry.body.data[field] === accepted[field])) check('重试后签收和库存不重复', retry, JSON.stringify(stableState(beforeRetry)) === JSON.stringify(stableState(await snapshot([replayOrder])))) } }) @@ -270,14 +287,18 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p for (const [name, secondSku, quantity] of [['后项超收', skuB, 3], ['后项未预报', `${prefix}-ATOMIC-UNKNOWN`, 1]]) { await unchanged(`混合签收${name}必须整笔失败`, [order], () => request(picker.token, 'POST', `${RECEIPTS}/confirm`, makeReceipt(secondSku, quantity)), [409]) } - await good(picker.token, 'POST', '混合明细正常签收准备合箱', `${RECEIPTS}/confirm`, { - ...receiptBody(order), items: [skuA, skuB].map(skuCode => ({ skuCode, receivedQuantity: 2 })) }) + await unchanged('两个合法SKU同批签收仍整笔拒绝', [order], () => request(picker.token, 'POST', `${RECEIPTS}/confirm`, { + ...receiptBody(order), items: [skuA, skuB].map(skuCode => ({ skuCode, receivedQuantity: 2 })) }), [409]) + await good(picker.token, 'POST', '甲SKU独立签收准备合箱', `${RECEIPTS}/confirm`, receiptBody(order)) + const skuBPallet = await good(picker.token, 'POST', '乙SKU独立托盘', `${RECEIPTS}/pallets`, { returnNo: order.returnNo, skuCode: skuB }) + await good(picker.token, 'POST', '乙SKU独立签收准备合箱', `${RECEIPTS}/confirm`, { + ...receiptBody(order), palletCode: skuBPallet.palletCode, items: [{ skuCode: skuB, receivedQuantity: 2 }] }) for (const [name, secondSku, quantity] of [['后项超库存', skuB, 3], ['后项不存在', `${prefix}-ATOMIC-UNKNOWN`, 1]]) { await unchanged(`混合合箱${name}必须整笔回滚`, [order], () => request(picker.token, 'POST', MERGE, { ...mergeBody([order], skuA, 1), items: [{ skuCode: skuA, quantity: 1 }, { skuCode: secondSku, quantity }] }), [409]) } const [state] = await snapshot([order]) - check('混合失败后两SKU均保持两件且仅一个来源箱', null, Number(state.cartons.total) === 1 + check('混合失败后两SKU均保持两件且各自一个来源箱', null, Number(state.cartons.total) === 2 && [state.summary, ...state.additionalSummaries].every(page => rows(page).length === 1 && Number(rows(page)[0].availableQuantity) === 2)) }) @@ -368,7 +389,8 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p check, name: '签收重放不重复产生流水', expected: result => ok(result) && result.body.data?.receiptNo === signed.receiptNo }) await verifyNoMutation({ snapshot: withLedger, action: () => request(picker.token, 'POST', `${RECEIPTS}/confirm`, receiptBody(order, 2)), check, name: '超收失败不留下流水', expected: result => rejected(result, [409]) }) - const final = await good(picker.token, 'POST', '流水场景第二批实收一件', `${RECEIPTS}/confirm`, receiptBody(order, 1)) + const nextPallet = await good(picker.token, 'POST', '流水场景第二批托盘', `${RECEIPTS}/pallets`, { returnNo: order.returnNo, skuCode: order.sku }) + const final = await good(picker.token, 'POST', '流水场景第二批实收一件', `${RECEIPTS}/confirm`, { ...receiptBody(order, 1), palletCode: nextPallet.palletCode }) const data = await ledger() check('两批签收流水业务引用唯一且分别两件一件', null, exactReceiptLedger(data, [entry, { ...entry, referenceNo: final.receiptNo, totalDelta: 1, availableDelta: 1 }]) && signed.receiptNo !== final.receiptNo) @@ -392,7 +414,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p && remaining.candidates[0].returnNo === b.returnNo && Number(remaining.candidates[0].skuExpectedQuantity) === 2 && Number(remaining.candidates[0].skuReceivedQuantity) === 0) for (const [name, patch] of [ - ['已存在箱号', { cartonMark: a.cartonMark }], + ['已存在箱号', { palletCode: a.cartonMark }], ['缺少幂等键', { idempotencyKey: '' }], ['超长快递单号', { trackingNo: 'X'.repeat(129) }], ]) await unchanged(`拒绝${name}`, [a, b], () => request(picker.token, 'POST', `${RECEIPTS}/confirm`, { ...receiptBody(b, 1), ...patch }), name === '已存在箱号' ? [409] : [400, 422]) diff --git a/scripts/admin-test-env-modules/return-review-contract.test.mjs b/scripts/admin-test-env-modules/return-review-contract.test.mjs new file mode 100644 index 000000000..8076f8576 --- /dev/null +++ b/scripts/admin-test-env-modules/return-review-contract.test.mjs @@ -0,0 +1,41 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { approveReturnFixture } from './phase4-overseas-return-receipt.mjs' + +function fixture({ failedReview = false, wrongOwner = false, stale = false } = {}) { + let status = 'PENDING_REVIEW' + const calls = [] + const context = { marker: 'FIXTURE', context: { merchantQualificationFixture: { tenantId: '123' } }, + addStep() {}, assert() {}, + async requestOverseas(method, path, body, headers, token) { + calls.push({ method, path, body, headers, token }) + if (method === 'POST') { + assert.equal(headers['X-Customer-Id'], '123') + assert.equal(body.decision, 'APPROVE') + if (!stale) status = 'PENDING_RECEIPT' + } else assert.equal(token, 'merchant-session') + return { http: 200, requestId: 'request', traceId: 'trace', body: { + code: wrongOwner ? 'A0404' : failedReview && method === 'POST' ? 'A0403' : '200', + data: { order: { returnNo: 'RT-FIXTURE', status, receivedQuantity: '0' } }, + } } + } } + return { context, calls, created: { order: { returnNo: 'RT-FIXTURE', status } } } +} +test('review requires merchant ownership then explicit platform customer then merchant readback', async () => { + const f = fixture() + const result = await approveReturnFixture(f.context, 'merchant-session', f.created) + assert.equal(result.order.status, 'PENDING_RECEIPT') + assert.deepEqual(f.calls.map(c => c.method), ['GET', 'POST', 'GET']) +}) +test('cannot approve inaccessible customer order', async () => { + const f = fixture({ wrongOwner: true }) + await assert.rejects(approveReturnFixture(f.context, 'merchant-session', f.created)) + assert.equal(f.calls.some(c => c.method === 'POST'), false) +}) +test('permission failure and stale review readback stop the receipt chain', async () => { + for (const config of [{ failedReview: true }, { stale: true }]) { + const f = fixture(config) + await assert.rejects(approveReturnFixture(f.context, 'merchant-session', f.created)) + assert.equal(f.calls.filter(c => c.method === 'POST').length, 1) + } +})