This commit is contained in:
2026-09-20 18:25:12 +08:00
parent d8e368bf55
commit 20a748cf7f
5 changed files with 145 additions and 32 deletions
+2
View File
@@ -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 验收;运行报告中的每个失败均保留阻断。
@@ -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, /已完成退货单不再进入待签收流程/)
})
@@ -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)
@@ -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])
@@ -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)
}
})