This commit is contained in:
2026-09-20 19:33:51 +08:00
parent 6211230564
commit 80778e017e
3 changed files with 98 additions and 3 deletions
+1 -1
View File
@@ -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`,不能用此前未冻结库存的独立出库结果替代。
退货专项复跑命令(在后端根目录执行,每次使用新的报告名;不要与其他环境混用):
@@ -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)}`
@@ -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 }] }