diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 9d32effcc..1a281b68f 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -22,7 +22,7 @@ `phase4-overseas-return-followups.mjs` 是独立可选后续模块,只在显式 `--modules phase4-overseas-return-followups` 时依赖并运行退货前置,再关联换标单及执行退货出库;不计入退货专项通过结论。仅复用同次运行、同 marker 的退货资源,凭据只在内存中,finally 停用其独立临时 PDA。合同检查复用 `phase4-overseas-return-receipt-contract.test.mjs`;HTTP 必须有目标环境写入授权。 -退货转换标请求按当前契约传 `items[{skuCode, quantity}]`、`fileScope` 和真实 FBA 目的仓 ID/编码,返回 `PENDING_REVIEW` 与 `skuCodes`。分页查找启用的 SCK8,缺失则阻断;总箱标文件回查 `cartonTotalFileId`。独立退货出库候选使用查询参数 `warehouseId`。同模块的 `completeReturnRelabel` 在独立出库之后继续审核本轮换标申请、PDA 认领与数量完成、来源托盘解析、正式生成目标托盘、组托及原键重放、上传合成完工图片、提交完工、客户确认及原键重放,最终回查原退货单仍为签收完成且换标关联一致。它不执行其他客户库存操作;目标托盘是本轮换标业务资源,不是国内基础托盘。退货 SKU 换标客户确认后为 `COMPLETED`,还需另验该换标库存的后续出库;不能把此前未冻结库存独立出库视为换标后出库。 +退货转换标请求按当前契约传 `items[{skuCode, quantity}]`、`fileScope` 和真实 FBA 目的仓 ID/编码,返回 `PENDING_REVIEW` 与 `skuCodes`。分页查找启用的 SCK8,缺失则阻断;总箱标文件回查 `cartonTotalFileId`。独立退货出库候选使用查询参数 `warehouseId`。同模块的 `completeReturnRelabel` 在独立出库之后继续审核本轮换标申请、PDA 认领与数量完成、来源托盘解析、正式生成目标托盘、组托及原键重放、上传合成完工图片、提交完工、客户确认及原键重放,最终回查原退货单仍为签收完成且换标关联一致。它不执行其他客户库存操作;目标托盘是本轮换标业务资源,不是国内基础托盘。客户确认后 `outboundRelabelledReturn` 逐箱核对本轮换标号、客户、仓库、来源单与数量,只为已完成换标的一件库存创建出库,检查创建原键重放、PDA 拣货/完成与平台、客户库存 `OUTBOUNDED` 终态;另存 `relabelOutboundNo`,不能用此前未冻结库存的独立出库结果替代。 退货专项复跑命令(在后端根目录执行,每次使用新的报告名;不要与其他环境混用): diff --git a/scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs index 2d95f89e4..36eecc1db 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs @@ -198,12 +198,71 @@ export async function run(context) { context.createdResources.phase4OverseasReturnReceipt.returnOutboundNo = outboundNo } } - if (source.relabelNo) await completeReturnRelabel(context, merchantToken, picker.token, source.relabelNo, returnNo) + if (source.relabelNo) { + await completeReturnRelabel(context, merchantToken, picker.token, source.relabelNo, returnNo) + await outboundRelabelledReturn(context, merchantToken, picker.token, source.relabelNo, returnNo, warehouse.id) + } } finally { await disablePicker(context, picker, 92) } } +export async function outboundRelabelledReturn(context, merchantToken, pdaToken, relabelNo, returnNo, warehouseId) { + const customerId = id(context.context.merchantQualificationFixture.tenantId) + const root = '/api/platform/v1/last-leg/outbound-orders' + const inventory = '/api/merchant/v1/last-leg/return-inventory/cartons' + const command = async (token, method, name, path, body) => { + const result = await overseas(context, token, method, name, path, body) + if (!success(result.response)) throw new Error(`${name}: ${trace(result.response)}`) + return result.data + } + const require = (name, value) => { + context.assert(MODULE, name, null, Boolean(value), `relabelNo=${relabelNo}`, OPTIONS) + if (!value) throw new Error(name) + } + const candidates = await command(context.overseasToken, 'GET', '查询换标完成后的退货出库候选', + query(`${root}/return/candidates`, { customerId, warehouseId })) + const selected = [] + for (const row of (Array.isArray(candidates) ? candidates : []).filter(row => row.returnNo === returnNo)) { + const detail = await command(merchantToken, 'GET', '客户核对出库候选的真实换标归属', `${inventory}/${encodeURIComponent(row.cartonMark)}`) + if (detail.relabelNo !== relabelNo) continue + require('已换标候选归属数量与完成状态正确', detail.customerId === customerId + && detail.overseasWarehouseId === warehouseId && detail.sourceReturnNo === returnNo + && detail.status === 'AVAILABLE' && detail.relabelStatus === 'COMPLETED' + && Number(detail.totalQuantity) === 1 && detail.items?.length === 1) + selected.push(detail) + } + require('只出库本轮实际换标的一件库存', selected.length === 1) + const mark = selected[0].cartonMark + const body = { recipient: `${context.marker} RELABELLED RETURN`, overseasWarehouseId: warehouseId, + returnNo, cartonMarks: [mark], remark: context.marker, idempotencyKey: key(context, 'RELABELLED-OUTBOUND') } + const task = await command(context.overseasToken, 'POST', '创建本轮已换标库存出库', query(`${root}/return`, { customerId }), body) + const replay = await command(context.overseasToken, 'POST', '已换标库存出库创建原键重放', query(`${root}/return`, { customerId }), body) + require('出库重放保持同一任务和业务单', nonBlank(task.taskNo) && nonBlank(task.outboundNo) + && replay.taskNo === task.taskNo && replay.outboundNo === task.outboundNo) + const path = `/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(task.taskNo)}` + const detail = await command(pdaToken, 'GET', 'PDA核对已换标库存出库任务', path) + const frozen = await command(context.overseasToken, 'GET', '平台核对本轮冻结出库箱号', + query(`${root}/${encodeURIComponent(task.outboundNo)}`, { customerId })) + const items = detail.items || [] + require('出库任务只包含本轮已换标箱', detail.task?.outboundNo === task.outboundNo + && items.length === 1 && Number(items[0].packageCount) === 1 + && frozen.outbound?.outboundNo === task.outboundNo && frozen.pallets?.length === 1 + && frozen.pallets[0].cartonMarks === mark + && id(frozen.pallets[0].id) === id(items[0].outboundPalletItemId)) + await command(pdaToken, 'POST', 'PDA拣取已换标库存', `${path}/pick`, { + outboundPalletItemId: items[0].outboundPalletItemId, inventoryPositionId: null, packages: 1, + cartonMarks: [mark], idempotencyKey: key(context, 'RELABELLED-PICK'), + }) + const completed = await command(pdaToken, 'POST', 'PDA完成已换标库存出库', `${path}/complete`, { idempotencyKey: key(context, 'RELABELLED-COMPLETE') }) + const outbound = await command(context.overseasToken, 'GET', '平台回查已换标出库终态', query(`${root}/${encodeURIComponent(task.outboundNo)}`, { customerId })) + const after = await command(merchantToken, 'GET', '客户回查已换标库存出库终态', `${inventory}/${encodeURIComponent(mark)}`) + require('已换标库存出库完成且来源和换标关联不漂移', completed.status === 'COMPLETED' + && outbound.outbound?.status === 'OUTBOUNDED' && after.status === 'OUTBOUNDED' + && after.relabelNo === relabelNo && after.sourceReturnNo === returnNo && Number(after.totalQuantity) === 1) + context.createdResources.phase4OverseasReturnReceipt.relabelOutboundNo = task.outboundNo +} + export async function completeReturnRelabel(context, merchantToken, pdaToken, relabelNo, returnNo) { const platform = `/api/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}` const merchant = `/api/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}` 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 d32d4054b..e6a42f051 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 @@ -3,11 +3,47 @@ import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import { validationFailure, inventorySnapshot } from './phase4-overseas-return-receipt.mjs' import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' -import { completeReturnRelabel } from './phase4-overseas-return-followups.mjs' +import { completeReturnRelabel, outboundRelabelledReturn } from './phase4-overseas-return-followups.mjs' const moduleUrl = new URL('./phase4-overseas-return-receipt.mjs', import.meta.url) const acceptanceUrl = new URL('../admin-test-env-acceptance.mjs', import.meta.url) +test('relabelled outbound refuses another relabel application before creating an outbound', async () => { + const calls = [] + const context = { marker: 'fixture', overseasToken: 'admin', context: { merchantQualificationFixture: { tenantId: '123' } }, + addStep() {}, assert() {}, + async requestOverseas(method, path) { + calls.push({ method, path }) + return { http: 200, body: { code: '200', data: path.includes('/candidates') + ? [{ returnNo: 'RT-1', cartonMark: 'BOX' }] : { relabelNo: 'RL-OTHER' } } } + } } + await assert.rejects(outboundRelabelledReturn(context, 'merchant', 'pda', 'RL-1', 'RT-1', '42'), /只出库本轮/) + assert.ok(calls.every(call => call.method === 'GET')) +}) + +test('return outbound binds platform frozen carton to PDA item without requiring ordinary inventory positions', async () => { + const context = { marker: 'fixture', overseasToken: 'admin', context: { merchantQualificationFixture: { tenantId: '123' } }, + createdResources: { phase4OverseasReturnReceipt: {} }, addStep() {}, + assert(_m, name, _r, passed) { assert.ok(passed, name) }, + async requestOverseas(method, path, body) { + let data + if (path.includes('/candidates')) data = [{ returnNo: 'RT-1', cartonMark: 'BOX' }] + else if (path.includes('/return-inventory/cartons/')) data = { cartonMark: 'BOX', customerId: '123', + overseasWarehouseId: '42', sourceReturnNo: 'RT-1', relabelNo: 'RL-1', relabelStatus: 'COMPLETED', + status: this.done ? 'OUTBOUNDED' : 'AVAILABLE', totalQuantity: 1, items: [{ skuCode: 'SKU', quantity: 1 }] } + else if (path.includes('/outbound-orders/return?')) data = { taskNo: 'TASK', outboundNo: 'OUT' } + else if (path.endsWith('/complete')) { this.done = true; data = { status: 'COMPLETED' } } + else if (path.endsWith('/pick')) { + assert.deepEqual(body.cartonMarks, ['BOX']); assert.equal(body.outboundPalletItemId, '9') + data = { status: 'IN_PROGRESS' } + } else if (path.includes('/outbound-orders/OUT?')) data = { outbound: { outboundNo: 'OUT', status: this.done ? 'OUTBOUNDED' : 'WAIT_PICK' }, pallets: [{ id: '9', cartonMarks: 'BOX' }] } + else data = { task: { outboundNo: 'OUT' }, items: [{ outboundPalletItemId: '9', packageCount: 1, positions: [] }] } + return { http: 200, body: { code: '200', data } } + } } + await outboundRelabelledReturn(context, 'merchant', 'pda', 'RL-1', 'RT-1', '42') + assert.equal(context.createdResources.phase4OverseasReturnReceipt.relabelOutboundNo, 'OUT') +}) + test('return relabel lifecycle requires review, quantities, binding, photo and customer confirmation', async () => { const calls = [] const task = { relabelNo: 'RL-1', version: 1, items: [{ itemId: '1', cartonMark: 'BOX', operationType: 'RETURN_BULK_SKU_LABEL', expectedPackages: 1 }] }