Files
baiyue-firstleg/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs
T

530 lines
35 KiB
JavaScript

import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overseas-pda.mjs'
import { runReturnBoundaries, verifyNoMutation, palletReceiptBody } from './return-boundary-scenarios.mjs'
const MODULE = 'phase4-overseas-return-receipt'
const SCENARIO = 'phase4-overseas-return-receipt-and-billing'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
const RETURN_ORDERS = '/api/merchant/v1/last-leg/return-orders'
const PDA_RECEIPTS = '/api/pda/v1/last-leg/return-receipts'
const RETURN_INVENTORY = '/api/merchant/v1/last-leg/return-inventory'
function id(value) { return value == null ? '' : String(value) }
function nonBlank(value) { return typeof value === 'string' && value.trim() !== '' }
function code(response) { return String(response?.body?.code ?? '') }
function success(response) { return response?.http === 200 && code(response) === '200' }
function conflict(response) {
return (response?.http === 409 || (response?.http === 200 && ['409', 'A0409'].includes(code(response))))
&& nonBlank(response?.requestId) && nonBlank(response?.traceId)
}
function notFound(response) {
return (response?.http === 404 || (response?.http === 200 && ['404', 'A0404'].includes(code(response))))
&& nonBlank(response?.requestId) && nonBlank(response?.traceId)
}
export function validationFailure(response) {
return ((response?.http === 400 || response?.http === 422)
|| (response?.http === 200 && ['400', '422', 'A0400', 'A0422'].includes(code(response))))
&& nonBlank(response?.requestId) && nonBlank(response?.traceId)
}
export function inventorySnapshot(cartons) {
return JSON.stringify(records(cartons).map(row => ({
cartonMark: row.cartonMark, status: row.status, quantity: Number(row.totalQuantity),
sourceBatchNo: row.sourceBatchNo, sourceReturnNo: row.sourceReturnNo,
})).sort((a, b) => a.cartonMark.localeCompare(b.cartonMark)))
}
function records(value) { return Array.isArray(value?.records) ? value.records : Array.isArray(value) ? value : [] }
function trace(response) {
return `HTTP=${response?.http ?? 'missing'}, code=${code(response) || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`
}
function query(path, values) {
const params = new URLSearchParams()
for (const [name, value] of Object.entries(values || {})) {
if (value !== undefined && value !== null && String(value) !== '') params.set(name, String(value))
}
return params.size ? `${path}?${params}` : path
}
function key(context, action) {
return `${context.marker}-RETURN-${action}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 255)
}
function markerSuffix(context) {
return String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase()
}
function add(context, name, response, passed, evidence = '') {
context.addStep(MODULE, name, response, passed, `${evidence}${evidence ? '; ' : ''}${trace(response)}`, OPTIONS)
}
async function overseas(context, token, method, name, path, body = undefined) {
let response = await context.request(method, path, body, token, {})
for (let attempt = 1; method === 'GET' && attempt < 3 && response?.http === 0; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 250 * attempt))
response = await context.request(method, path, body, token, {})
}
add(context, name, response, success(response))
return { response, data: success(response) ? response.body?.data : null }
}
async function resolveWarehouse(context) {
const existing = id(context.createdResources?.phase4OverseasPda?.overseasWarehouseId)
const response = await context.request('GET',
'/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE')
const rows = records(response?.body?.data)
const fixedIds = new Set(['193000000000200003', '193000000000200004'])
const warehouse = rows.find((item) => id(item?.id) === existing)
|| rows.find((item) => fixedIds.has(id(item?.id)))
|| rows[0]
add(context, '读取可执行退货签收的海外仓', response,
success(response) && nonBlank(id(warehouse?.id)), `warehouseId=${id(warehouse?.id) || 'missing'}`)
return success(response) && warehouse ? { id: id(warehouse.id), name: warehouse.warehouseName || warehouse.name } : null
}
async function loginMerchant(context) {
const fixture = context.context?.merchantQualificationFixture
if (fixture?.marker !== context.marker || !fixture?.username || !fixture?.password) {
add(context, '取得本轮客户账号', null, false, `marker=${fixture?.marker || 'missing'}`)
return null
}
const response = await context.request('POST', '/api/auth/login', {
loginId: fixture.username, password: fixture.password,
}, '')
const auth = success(response) ? response.body?.data : null
add(context, '客户登录并取得海外访问令牌', response, nonBlank(auth?.accessToken) && nonBlank(auth?.refreshToken),
`tokenPresent=${nonBlank(auth?.accessToken)}, refreshPresent=${nonBlank(auth?.refreshToken)}`)
return nonBlank(auth?.accessToken) && nonBlank(auth?.refreshToken) ? auth : null
}
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.request('POST', path, { decision: 'APPROVE', remark: `${context.marker} 退货验收审核` }, undefined, { '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) {
const start = context.steps.length
const auth = await loginMerchant(context)
if (!auth) return
try {
return await runWithSession(context, auth.accessToken)
} catch (error) {
const source = context.createdResources.phase4OverseasReturnReceipt
if (source?.marker === context.marker) source.mainChainStatus = 'FAILED'
throw error
} finally {
const logout = await context.request('POST', '/api/auth/logout', { refreshToken: auth.refreshToken }, auth.accessToken)
add(context, '退出本轮退货客户会话', logout, success(logout))
const source = context.createdResources.phase4OverseasReturnReceipt
if (source?.marker === context.marker && context.steps.slice(start).some(step => !step.passed)) {
source.mainChainStatus = 'FAILED'
}
}
}
async function runWithSession(context, merchantToken) {
const start = context.steps.length
context.createdResources.phase4OverseasReturnReceipt = {
marker: context.marker, returnNo: null, receiptNos: [], receiptBatchNos: [], cartonMarks: [],
billingExpectation: 'One PENDING_PRICING storage billing batch per successful non-replayed receipt',
}
const warehouse = await resolveWarehouse(context)
const access = await resolvePickerAccess(context)
if (!merchantToken || !warehouse || !access) return
Object.assign(context.createdResources.phase4OverseasReturnReceipt, {
warehouseId: warehouse.id, customerId: id(context.context?.merchantQualificationFixture?.tenantId),
mainChainStatus: 'RUNNING',
})
const picker = await createPicker(context, 91, warehouse.id, access)
if (!picker?.token) {
add(context, '创建退货签收PDA操作员', null, false, '未取得绑定仓PDA令牌')
if (picker?.membershipId) await disablePicker(context, picker, 91)
return
}
const suffix = markerSuffix(context)
const skuA = `RET-A-${suffix}`.slice(0, 128)
const skuB = `RET-B-${suffix}`.slice(0, 128)
const createBody = {
overseasWarehouseId: warehouse.id,
warehouseContactName: '全量验收海外仓', warehouseContactPhone: '0000000000',
customerReferenceNo: `REF-${suffix}`, needRelabel: false, remark: context.marker,
idempotencyKey: key(context, 'CREATE'),
items: [
{ skuCode: skuA, productNameZh: '退货验收商品A', expectedQuantity: 3 },
{ skuCode: skuB, productNameZh: '退货验收商品B', expectedQuantity: 2 },
],
}
try {
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_REVIEW'
&& Number(created.data?.order?.expectedQuantity) === 5
&& Number(created.data?.order?.receivedQuantity) === 0
&& itemMap(created.data?.items).size === 2,
`returnNo=${returnNo || 'missing'}`, OPTIONS)
if (!success(created.response) || !returnNo || created.data?.order?.status !== 'PENDING_REVIEW') {
throw new Error('退货预报创建前置失败,停止后续签收')
}
context.createdResources.phase4OverseasReturnReceipt.returnNo = returnNo
async function snapshot() {
const reads = [
['订单列表', query(RETURN_ORDERS, { page: 1, size: 200 })],
['订单及签收批次', `${RETURN_ORDERS}/${encodeURIComponent(returnNo)}`],
['全部状态箱', query(`${RETURN_INVENTORY}/cartons`, { page: 1, size: 200, returnNo })],
['SKU汇总', query(`${RETURN_INVENTORY}/summary`, { page: 1, size: 200, overseasWarehouseId: warehouse.id })],
]
const state = {}
for (const [name, path] of reads) {
const result = await overseas(context, merchantToken, 'GET', `失败操作逐项回查${name}`, path)
if (!success(result.response)) throw new Error(`无法读取${name},不能证明失败未落账`)
state[name] = result.data
}
state.items = []
for (const row of records(state['全部状态箱'])) {
const result = await overseas(context, merchantToken, 'GET', '失败操作逐箱回查明细', `${RETURN_INVENTORY}/cartons/${encodeURIComponent(row.cartonMark)}`)
if (!success(result.response)) throw new Error('无法读取箱明细,不能证明失败未落账')
state.items.push(result.data)
}
return state
}
const reject = (name, expected, action) => verifyNoMutation({ snapshot, action, name, expected,
check: (label, response, passed) => add(context, label, response, passed) })
const createReplay = await overseas(context, merchantToken, 'POST', '退货预报同键同载荷重放', RETURN_ORDERS, createBody)
context.assert(MODULE, '预报幂等重放复用同一退货单', createReplay.response,
id(createReplay.data?.order?.returnNo) === returnNo, trace(createReplay.response), OPTIONS)
await reject('退货预报同键异载荷返回冲突', conflict, () => context.request('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 }]],
['负预报数量', [{ skuCode: skuA, expectedQuantity: -1 }]],
['小数预报数量', [{ skuCode: skuA, expectedQuantity: 1.5 }]],
['重复预报SKU', [createBody.items[0], createBody.items[0]]],
]) {
await reject(`拒绝${name}`, validationFailure, () => context.request('POST', RETURN_ORDERS, { ...createBody, items, idempotencyKey: key(context, `INVALID-CREATE-${encodeURIComponent(name)}`) }, merchantToken, {}))
}
const onlyOrder = await overseas(context, merchantToken, 'GET', '非法预报后回查本轮订单数量',
query(RETURN_ORDERS, { page: 1, size: 20, skuCode: skuA }))
context.assert(MODULE, '非法预报不新增订单且原单仍为待签收', onlyOrder.response,
Number(onlyOrder.data?.total) === 1 && records(onlyOrder.data)[0]?.returnNo === returnNo
&& records(onlyOrder.data)[0]?.status === 'PENDING_RECEIPT', trace(onlyOrder.response), OPTIONS)
await reject('不存在退货单返回可诊断未找到', notFound, () => context.request('POST', `${PDA_RECEIPTS}/start`, { returnNo: `MISSING-${suffix}` }, picker.token, {}))
const emptySku = await overseas(context, picker.token, 'POST', 'PDA查询不存在SKU返回正常空候选',
`${PDA_RECEIPTS}/sku-candidates`, { skuCode: `MISSING-${suffix}` })
context.assert(MODULE, '不存在SKU不是业务错误', emptySku.response,
success(emptySku.response) && Array.isArray(emptySku.data?.candidates) && emptySku.data.candidates.length === 0,
trace(emptySku.response), OPTIONS)
const started = await overseas(context, picker.token, 'POST', 'PDA按退货单号开始签收',
`${PDA_RECEIPTS}/start`, { returnNo })
context.assert(MODULE, '首次开始返回全部未收SKU', started.response,
started.data?.status === 'PENDING_RECEIPT' && itemMap(started.data?.expectedItems).size === 2,
trace(started.response), OPTIONS)
const skuCandidates = await overseas(context, picker.token, 'POST', 'PDA按SKU查询当前仓候选退货单',
`${PDA_RECEIPTS}/sku-candidates`, { skuCode: skuB })
context.assert(MODULE, 'SKU候选包含本轮退货单及准确数量', skuCandidates.response,
skuCandidates.data?.candidates?.some?.((candidate) => candidate.returnNo === returnNo
&& Number(candidate.skuExpectedQuantity) === 2 && Number(candidate.skuReceivedQuantity) === 0),
trace(skuCandidates.response), OPTIONS)
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 palletCodeA = palletA.data.palletCode
const firstBody = {
...palletReceiptBody(returnNo, palletCodeA, skuA, 3, key(context, 'RECEIPT-1'), 'RETURN_NO', 0),
trackingNo: `TRACK-${suffix}`,
}
const first = await overseas(context, picker.token, 'POST', 'PDA首次单SKU签收甲3件', `${PDA_RECEIPTS}/confirm`, firstBody)
if (!success(first.response)) throw new Error('首次签收失败,停止后续库存场景')
const cartonA = id(first.data?.cartonMark)
context.assert(MODULE, '托盘签收返回独立实物箱码', first.response, nonBlank(cartonA) && cartonA !== palletCodeA, trace(first.response), OPTIONS)
context.assert(MODULE, '首次签收形成唯一箱和仓储计费批次', first.response,
first.data?.orderStatus === 'PARTIALLY_RECEIVED' && first.data?.fullyMatched === false
&& Number(first.data?.receivedQuantity) === 3 && first.data?.palletCode === palletCodeA
&& 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)
const firstBatchNo = id(first.data?.receiptBatchNo)
const firstReplay = await overseas(context, picker.token, 'POST', '首次签收同键同载荷重放', `${PDA_RECEIPTS}/confirm`, firstBody)
context.assert(MODULE, '签收重放不重复创建签收单、箱或计费批次', firstReplay.response,
id(firstReplay.data?.receiptNo) === firstReceiptNo
&& id(firstReplay.data?.receiptBatchNo) === firstBatchNo
&& id(firstReplay.data?.palletCode) === palletCodeA
&& id(firstReplay.data?.cartonMark) === cartonA,
trace(firstReplay.response), OPTIONS)
await reject('签收同键异载荷返回冲突', conflict, () => context.request('POST', `${PDA_RECEIPTS}/confirm`, {
...firstBody, trackingNo: `TRACK-${suffix}-CHANGED`,
}, picker.token, {}))
await reject('签收成功后旧实收快照换新键仍拒绝且不重复入账',
response => conflict(response) && response.body?.message?.includes('退货单实收数量已变化'),
() => context.request('POST', `${PDA_RECEIPTS}/confirm`, {
...firstBody, idempotencyKey: key(context, 'STALE-RECEIPT-NEW-KEY'),
}, picker.token, {}))
await reject('超收被拒绝且不得生成费用批次', conflict, async () => {
const body = {
...palletReceiptBody(returnNo, palletCodeA, skuA, 2, key(context, 'OVER-RECEIPT'), 'SKU', 3),
}
let response = null
for (let attempt = 1; attempt <= 3; attempt += 1) {
response = await context.request('POST', `${PDA_RECEIPTS}/confirm`, body, picker.token, {})
if (response?.http !== 0 || attempt === 3) break
await new Promise((resolve) => setTimeout(resolve, 400 * attempt))
}
return response
})
for (const [name, changes, expected] of [
['零实收数量', { items: [{ skuCode: skuA, receivedQuantity: 0, boxCount: 1 }] }, validationFailure],
['负实收数量', { items: [{ skuCode: skuA, receivedQuantity: -1, boxCount: 1 }] }, validationFailure],
['零SKU箱数', { items: [{ skuCode: skuB, receivedQuantity: 1, boxCount: 0 }] }, validationFailure],
['空实收明细', { items: [] }, validationFailure],
['非法签收方式', { startedBy: 'INVALID' }, validationFailure],
['未预报SKU', { items: [{ skuCode: `UNKNOWN-${suffix}`, receivedQuantity: 1, boxCount: 1 }] }, conflict],
]) {
await reject(`拒绝${name}`, expected, () => context.request('POST', `${PDA_RECEIPTS}/confirm`, { ...firstBody, expectedReceivedQuantity: 3, ...changes, idempotencyKey: key(context, `INVALID-RECEIPT-${encodeURIComponent(name)}`) }, picker.token, {}))
}
await reject('部分签收退货单禁止合箱', conflict, () => context.request('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge', {
returnNos: [returnNo], items: [{ skuCode: skuA, quantity: 1 }], length: 30, width: 20, height: 10,
weight: 1, idempotencyKey: key(context, 'EARLY-MERGE'),
}, picker.token, {}))
const afterPartial = await overseas(context, merchantToken, 'GET', '客户回查部分签收状态',
`${RETURN_ORDERS}/${encodeURIComponent(returnNo)}`)
context.assert(MODULE, '超收失败后订单、签收和费用批次数量均未变化', afterPartial.response,
afterPartial.data?.order?.status === 'PARTIALLY_RECEIVED'
&& 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 = {
...palletReceiptBody(returnNo, palletB.data.palletCode, skuB, 2, key(context, 'RECEIPT-2'), 'SKU', 3),
}
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 && 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)
context.createdResources.phase4OverseasReturnReceipt.receiptNos.push(firstReceiptNo, secondReceiptNo)
context.createdResources.phase4OverseasReturnReceipt.receiptBatchNos.push(firstBatchNo, secondBatchNo)
context.createdResources.phase4OverseasReturnReceipt.cartonMarks.push(cartonA, cartonB)
await reject('已完成退货单不再进入待签收流程', notFound,
() => context.request('POST', `${PDA_RECEIPTS}/start`, { returnNo }, picker.token, {}))
const completedCandidates = await overseas(context, picker.token, 'POST', '完成后SKU候选自动清空',
`${PDA_RECEIPTS}/sku-candidates`, { skuCode: skuB })
context.assert(MODULE, '完成单不再出现在SKU候选', completedCandidates.response,
!completedCandidates.data?.candidates?.some?.((candidate) => candidate.returnNo === returnNo),
trace(completedCandidates.response), OPTIONS)
const detail = await overseas(context, merchantToken, 'GET', '客户回查完成退货单与两次签收',
`${RETURN_ORDERS}/${encodeURIComponent(returnNo)}`)
const detailItems = itemMap(detail.data?.items)
const detailReceipts = Array.isArray(detail.data?.receipts) ? detail.data.receipts : []
const detailBatches = new Set(detailReceipts.map((receipt) => id(receipt.receiptBatchNo)))
context.assert(MODULE, '订单5件核平且恰有两个唯一仓储计费批次', detail.response,
detail.data?.order?.status === 'COMPLETED' && Number(detail.data?.order?.receivedQuantity) === 5
&& Number(detailItems.get(skuA)?.receivedQuantity) === 3
&& Number(detailItems.get(skuB)?.receivedQuantity) === 2
&& detailReceipts.length === 2 && detailBatches.size === 2
&& detailBatches.has(firstBatchNo) && detailBatches.has(secondBatchNo),
`receipts=${detailReceipts.length}, billingBatches=${detailBatches.size}`, OPTIONS)
const merchantInventory = await context.request('GET', query(RETURN_INVENTORY, { page: 1, size: 20, overseasWarehouseId: warehouse.id, returnNo }), undefined, merchantToken, {})
add(context, '客户权限可读取自身退货库存', merchantInventory, success(merchantInventory))
const customerId = id(context.context?.merchantQualificationFixture?.tenantId)
const inventory = await overseas(context, context.token, 'GET', '平台按客户和退货单回查全部退货箱库存',
query('/api/platform/v1/last-leg/return-inventory/cartons', {
page: 1, size: 20, customerId, overseasWarehouseId: warehouse.id, returnNo,
}))
const inventoryRows = records(inventory.data)
const sourceBatches = new Set(inventoryRows.map((row) => id(row.sourceBatchNo)))
context.assert(MODULE, '两个实收箱逐一引用对应计费批次且数量守恒', inventory.response,
inventoryRows.length === 2
&& inventoryRows.reduce((sum, row) => sum + Number(row.totalQuantity || 0), 0) === 5
&& sourceBatches.size === 2 && sourceBatches.has(firstBatchNo) && sourceBatches.has(secondBatchNo),
`cartons=${inventoryRows.length}, quantity=${inventoryRows.reduce((sum, row) => sum + Number(row.totalQuantity || 0), 0)}`, OPTIONS)
const summary = await overseas(context, context.token, 'GET', '平台回查退货SKU汇总库存',
query('/api/platform/v1/last-leg/return-inventory', {
page: 1, size: 20, customerId, overseasWarehouseId: warehouse.id,
}))
const summaries = new Map(records(summary.data).filter((row) => [skuA, skuB].includes(row.skuCode))
.map((row) => [row.skuCode, row]))
context.assert(MODULE, '退货SKU汇总数量与两个实收箱一致', summary.response,
Number(summaries.get(skuA)?.totalQuantity) === 3
&& 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: 3 },
{ cartonMark: cartonB, batchNo: secondBatchNo, quantity: 2 }].entries()) {
const carton = await overseas(context, context.token, 'GET', `平台回查第${index + 1}个退货箱明细`,
`/api/platform/v1/last-leg/return-inventory/cartons/${encodeURIComponent(expected.cartonMark)}`)
context.assert(MODULE, `第${index + 1}个退货箱来源费用批次和数量正确`, carton.response,
carton.data?.sourceBatchNo === expected.batchNo
&& Number(carton.data?.totalQuantity) === expected.quantity
&& Array.isArray(carton.data?.items) && carton.data.items.length > 0,
`batch=${carton.data?.sourceBatchNo || 'missing'}, quantity=${carton.data?.totalQuantity ?? 'missing'}`, OPTIONS)
const label = await overseas(context, context.token, 'GET', `平台生成第${index + 1}个退货箱标签`,
`/api/platform/v1/last-leg/return-inventory/cartons/${encodeURIComponent(expected.cartonMark)}/label`)
context.assert(MODULE, `第${index + 1}个箱标签保留来源批次`, label.response,
label.data?.cartonMark === expected.cartonMark && label.data?.sourceBatchNo === expected.batchNo
&& Number(label.data?.totalQuantity) === expected.quantity && nonBlank(label.data?.printableHtml),
trace(label.response), OPTIONS)
}
const packing = await overseas(context, picker.token, 'GET', 'PDA查询完成退货单的可合箱库存',
query('/api/pda/v1/last-leg/return-inventory/packing/orders', { returnNo }))
const packingSkus = new Map((packing.data?.skus || []).map((row) => [row.skuCode, row]))
context.assert(MODULE, '实物箱库存不混入散SKU合箱候选', packing.response,
packing.data?.orders?.some?.((order) => order.returnNo === returnNo && order.status === 'COMPLETED')
&& !packingSkus.has(skuA) && !packingSkus.has(skuB),
trace(packing.response), OPTIONS)
const mergeBody = {
sourceCartonMarks: [cartonA, cartonB],
expectedSourceQuantities: [{ cartonMark: cartonA, skuCode: skuA, quantity: 3 }, { cartonMark: cartonB, skuCode: skuB, quantity: 2 }],
sourceDeductions: [{ cartonMark: cartonA, skuCode: skuA, quantity: 1 }, { cartonMark: cartonB, skuCode: skuB, quantity: 1 }],
length: 30, width: 20, height: 10, weight: 2, idempotencyKey: key(context, 'MERGE'),
}
const merged = await overseas(context, picker.token, 'POST', 'PDA将同客户退货库存合为新箱',
'/api/pda/v1/last-leg/return-inventory/cartons/merge-boxes', mergeBody)
if (success(merged.response)) {
context.assert(MODULE, '退货合箱生成含两SKU和箱规的可打印新箱', merged.response,
nonBlank(merged.data?.cartonMark) && Number(merged.data?.totalQuantity) === 2
&& nonBlank(merged.data?.label?.sourceBatchNo) && merged.data?.label?.sourceBatchNo !== 'MERGED'
&& merged.data?.label?.sourceReturnNo === returnNo
&& Number(merged.data?.label?.items?.length) === 2
&& nonBlank(merged.data?.label?.printableHtml),
`cartonMark=${merged.data?.cartonMark || 'missing'}`, OPTIONS)
const consumedBatches = []
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)
}
const actualBatches = String(merged.data?.label?.sourceBatchNo || '').split(',')
context.assert(MODULE, '合箱来源批次精确等于实际消耗来源且去重', merged.response,
consumedBatches.length > 0 && new Set(actualBatches).size === actualBatches.length
&& JSON.stringify([...consumedBatches].sort()) === JSON.stringify([...actualBatches].sort()),
`expected=${consumedBatches.join(',')}, actual=${actualBatches.join(',')}`, OPTIONS)
const cartonPage = await overseas(context, merchantToken, 'GET', '客户箱号管理按实物箱分页',
query(`${RETURN_INVENTORY}/cartons`, { page: 1, size: 20, returnNo }))
context.assert(MODULE, '箱号分页包含本轮合箱且来源退货单保持真实业务号', cartonPage.response,
records(cartonPage.data).some(row => row.cartonMark === merged.data.cartonMark
&& row.sourceReturnNo === returnNo && Number(row.totalQuantity) === 2), trace(cartonPage.response), OPTIONS)
const label = await overseas(context, merchantToken, 'GET', '客户读取本轮合箱条码标签',
`${RETURN_INVENTORY}/cartons/${encodeURIComponent(merged.data.cartonMark)}/label`)
context.assert(MODULE, '标签包含SVG条码和当前箱号', label.response,
label.data?.cartonMark === merged.data.cartonMark
&& /<svg\b/.test(label.data?.printableHtml || '')
&& label.data?.printableHtml?.includes(merged.data.cartonMark), trace(label.response), OPTIONS)
const mergeReplay = await overseas(context, picker.token, 'POST', '退货合箱同键同载荷重放',
'/api/pda/v1/last-leg/return-inventory/cartons/merge-boxes', mergeBody)
context.assert(MODULE, '合箱重放复用同一新箱且不重复扣减', mergeReplay.response,
success(mergeReplay.response) && nonBlank(merged.data?.cartonMark)
&& id(mergeReplay.data?.cartonMark) === id(merged.data?.cartonMark), trace(mergeReplay.response), OPTIONS)
await reject('合箱同键异载荷返回冲突', conflict, () => context.request('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge-boxes', { ...mergeBody, weight: 3 }, picker.token, {}))
await reject('部分合箱成功后旧库存快照换新键仍拒绝且不重复扣减',
response => conflict(response) && response.body?.message?.includes('来源箱库存已变化'),
() => context.request('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge-boxes', {
...mergeBody, idempotencyKey: key(context, 'STALE-MERGE-NEW-KEY'),
}, picker.token, {}))
const beforeInvalid = await overseas(context, merchantToken, 'GET', '冻结合箱异常检查前库存快照',
query(`${RETURN_INVENTORY}/cartons`, { page: 1, size: 200, returnNo }))
for (const [name, changes, expected] of [
['零箱长', { length: 0 }, validationFailure],
['负重量', { weight: -1 }, validationFailure],
['空来源箱', { sourceCartonMarks: [] }, validationFailure],
['零合箱数量', { sourceDeductions: [{ cartonMark: cartonA, skuCode: skuA, quantity: 0 }] }, validationFailure],
['超可用库存', { sourceDeductions: [{ cartonMark: cartonA, skuCode: skuA, quantity: 999 }] }, conflict],
['不存在来源箱', { sourceCartonMarks: [`MISSING-${suffix}`], expectedSourceQuantities: [{ cartonMark: `MISSING-${suffix}`, skuCode: skuA, quantity: 1 }] }, conflict],
]) {
await reject(`合箱拒绝${name}`, expected, () => context.request('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge-boxes', { ...mergeBody, ...changes, idempotencyKey: key(context, `INVALID-MERGE-${encodeURIComponent(name)}`) }, picker.token, {}))
}
await overseas(context, picker.token, 'POST', '异常检查后再重放原合箱请求',
'/api/pda/v1/last-leg/return-inventory/cartons/merge-boxes', mergeBody)
const afterInvalid = await overseas(context, merchantToken, 'GET', '回查异常及重放后的全部箱库存',
query(`${RETURN_INVENTORY}/cartons`, { page: 1, size: 200, returnNo }))
context.assert(MODULE, '失败与幂等重放不改变箱数数量状态及来源', afterInvalid.response,
success(beforeInvalid.response) && success(afterInvalid.response)
&& inventorySnapshot(beforeInvalid.data) === inventorySnapshot(afterInvalid.data)
&& Number(afterInvalid.data?.total) === 3
&& records(afterInvalid.data).reduce((sum, row) => sum + Number(row.totalQuantity), 0) === 5,
trace(afterInvalid.response), OPTIONS)
const pagedMarks = []
for (let page = 1; page <= 3; page += 1) {
const result = await overseas(context, merchantToken, 'GET', `实物箱逐页回查第${page}页`,
query(`${RETURN_INVENTORY}/cartons`, { page, size: 1, returnNo }))
context.assert(MODULE, `第${page}页保持总箱数且只有一个箱`, result.response,
Number(result.data?.total) === 3 && records(result.data).length === 1, trace(result.response), OPTIONS)
pagedMarks.push(...records(result.data).map(row => row.cartonMark))
}
context.assert(MODULE, '跨页箱号无重复无遗漏', afterInvalid.response,
new Set(pagedMarks).size === 3 && JSON.stringify([...pagedMarks].sort())
=== JSON.stringify(records(afterInvalid.data).map(row => row.cartonMark).sort()), trace(afterInvalid.response), OPTIONS)
const merchantSummary = await overseas(context, merchantToken, 'GET', '客户合箱后回查SKU汇总',
query(`${RETURN_INVENTORY}/summary`, { page: 1, size: 20, skuCode: `RET-`, overseasWarehouseId: warehouse.id }))
const quantities = itemMap(records(merchantSummary.data))
context.assert(MODULE, '合箱及异常后客户SKU仍为甲3乙2', merchantSummary.response,
Number(quantities.get(skuA)?.availableQuantity) === 3 && Number(quantities.get(skuB)?.availableQuantity) === 2,
trace(merchantSummary.response), OPTIONS)
}
if (context.options.returnMainChainOnly || context.options.fixedCustomerId) {
context.createdResources.phase4OverseasReturnReceipt.excludedScope = 'Additional customer, cross-warehouse and concurrent return boundary scenarios';
context.addCoverageGap(MODULE, 'return-isolation-boundaries', '跨客户、跨仓及并发退货扩展',
'主链复用固定客户;跨客户扩展仍需独立固定资源,不创建第二客户,也不计边界通过',
'prerequisite', { blocking: false })
} else {
await runReturnBoundaries(context, { merchantToken, warehouse, picker, access })
}
context.createdResources.phase4OverseasReturnReceipt.mainChainStatus = context.steps.slice(start).every(step => step.passed)
? 'READY_FOR_FOLLOWUPS' : 'FAILED'
} finally {
await disablePicker(context, picker, 91)
}
}