This commit is contained in:
2026-09-15 16:10:55 +08:00
parent 506525524b
commit c207a4d3b7
20 changed files with 693 additions and 93 deletions
+13
View File
@@ -4,6 +4,19 @@
总入口是 `../admin-test-env-acceptance.mjs`。每个业务模块使用独立的同名 `.mjs` 文件,避免多个对话同时修改总入口。
退货专项只运行 `--modules phase4-overseas-return-receipt`,自动准备本轮客户;覆盖非法/重复预报、退货单号和 SKU 查询、非法/未知 SKU 实收、超收及失败不落账、分批签收完成、部分签收禁止合箱、合箱非法箱规/数量/来源、幂等重放前后库存快照一致、逐箱分页无重复遗漏、客户 SKU 数量守恒及标签。通过显式测试环境文件运行总入口,JSON 路径由 `--output` 指定;会创建隔离测试数据,finally 停用临时 PDA,不删除业务记录。失败保留报告,先核对记录再决定是否新批次重跑。
`phase4-overseas-return-followups.mjs` 是独立可选后续模块,只在显式 `--modules phase4-overseas-return-followups` 时依赖并运行退货前置,再关联换标单及执行退货出库;不计入退货专项通过结论。仅复用同次运行、同 marker 的退货资源,凭据只在内存中,finally 停用其独立临时 PDA。合同检查复用 `phase4-overseas-return-receipt-contract.test.mjs`;HTTP 必须有目标环境写入授权。
退货专项复跑命令(在后端根目录执行,每次使用新的报告名;不要与其他环境混用):
```powershell
node --test scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs scripts/script-documentation-contract.test.mjs
node --env-file="$env:USERPROFILE/.codex/chongchong-global-test-acceptance.env" scripts/admin-test-env-acceptance.mjs --modules phase4-overseas-return-receipt --output reports/return-<本轮批次>.json
```
PC/PDA 页面交互验收与 HTTP 独立取证,不用接口提交替代点击:PC 固定 1920×1080、缩放100%,验证空预报拦截后新建独立双SKU单(甲3乙2,不执行换标);PDA 固定390×848,验证不存在单号空态、按单号签收甲2、按SKU候选确认2/3与整单2/5、全部填入剩余甲1乙2并确认5/5;再合箱甲2乙1及30×20×10cm/1.2kg。PC 查询三箱3+1+1、两批真实来源、新箱明细、SKU汇总甲3乙2,并检查无结果查询后恢复正常列表。记录本轮业务单号、实际视口、截图与控制台信息;H5点击不代表真机扫码,标签HTTP不代表PDF分页或实体打印。人工重点确认布局、长字段换行、滚动、反馈和操作便利性。
需要为人工联调批量准备同一客户、同一国内仓和同一渠道的 `WAIT_SPLIT` 数据时,使用[《批量待分柜数据生成指南》](批量待分柜数据生成指南.md)。该入口通过正式下单、入库、称重和测量接口生成数据,支持 FBA、海外仓、私人地址及一件代发等稳定随机组合;不得直接写库或用于生产环境。
固定“IT测试”客户与“义乌仓”、其余线路随机并尽力生成最多 8,000 票时,独立编排入口是 `../it-test-wait-split-bulk.mjs`。它仍复用本目录的 `orders.mjs` 与 `inbound-measurement.mjs`,但逐票保存日志且单票失败后继续。测量前置只随机复用既有 `TP-001`、`YW-TP-002`,不得调用托盘新增接口;库位由正式仓储接口分配,普通位不足时使用公共位 `A0`,不得调用库位新增接口。
@@ -0,0 +1,191 @@
import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overseas-pda.mjs'
const MODULE = 'phase4-overseas-return-followups'
const SCENARIO = 'phase4-overseas-return-followups'
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)
}
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) {
const response = await context.requestOverseas(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 token = success(response) ? response.body?.data?.overseasAccessToken : null
add(context, '客户登录并取得海外访问令牌', response, nonBlank(token), `tokenPresent=${nonBlank(token)}`)
return nonBlank(token) ? token : null
}
function itemMap(items) {
return new Map((Array.isArray(items) ? items : []).map((item) => [item.skuCode, item]))
}
export async function run(context) {
const source = context.createdResources.phase4OverseasReturnReceipt
if (source?.marker !== context.marker || !source?.returnNo) {
add(context, '后续关联要求本轮退货签收合箱前置', null, false, '缺少本轮退货前置')
return
}
const merchantToken = await loginMerchant(context)
const warehouse = await resolveWarehouse(context)
const access = await resolvePickerAccess(context)
if (!merchantToken || !warehouse || !access) return
const picker = await createPicker(context, 92, warehouse.id, access)
if (!picker?.token) {
add(context, '创建后续关联PDA操作员', null, false, '未取得绑定仓PDA令牌')
if (picker?.membershipId) await disablePicker(context, picker, 92)
return
}
const returnNo = source.returnNo
const customerId = id(context.context?.merchantQualificationFixture?.tenantId)
const suffix = markerSuffix(context)
try {
const relabelCandidates = await overseas(context, merchantToken, 'GET', '客户查询本轮退货换标候选',
`${RETURN_ORDERS}/${encodeURIComponent(returnNo)}/relabel-candidates`)
const relabelCandidate = (Array.isArray(relabelCandidates.data) ? relabelCandidates.data : [])
.find(row => nonBlank(row?.cartonMark) && row?.status === 'AVAILABLE')
context.assert(MODULE, '退货换标候选来自本轮已签收可用箱', relabelCandidates.response,
Boolean(relabelCandidate) && relabelCandidate.sourceReturnNo === returnNo,
`cartonMark=${relabelCandidate?.cartonMark || 'missing'}`, OPTIONS)
if (relabelCandidate) {
const relabelBody = {
cartonMarks: [relabelCandidate.cartonMark], idempotencyKey: key(context, 'RELABEL'),
contactName: '退货换标验收', contactPhone: '13800000008', deliveryMethod: 'TRUCK', remark: context.marker,
}
const relabel = await overseas(context, merchantToken, 'POST', '客户从退货箱创建换标单',
`${RETURN_ORDERS}/${encodeURIComponent(returnNo)}/relabel`, relabelBody)
const relabelNo = id(relabel.data?.relabelNo)
context.assert(MODULE, '退货换标单绑定原退货单与选中箱', relabel.response,
nonBlank(relabelNo) && relabel.data?.returnNo === returnNo
&& relabel.data?.status === 'PENDING_PROCESSING'
&& relabel.data?.cartonMarks?.includes?.(relabelCandidate.cartonMark),
`relabelNo=${relabelNo || 'missing'}`, OPTIONS)
if (relabelNo) {
const form = new FormData()
form.append('file', new Blob([new TextEncoder().encode(`%PDF-1.4\n${context.marker}\n%%EOF`)],
{ type: 'application/pdf' }), `${suffix}-return-relabel.pdf`)
const uploaded = await context.requestOverseasMultipart('POST',
`/api/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/files?scope=CARTON_TOTAL`,
form, {}, merchantToken)
add(context, '客户编辑退货换标总资料', uploaded, success(uploaded))
const relabelDetail = await overseas(context, merchantToken, 'GET', '客户回查退货换标资料修改',
`/api/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}`)
context.assert(MODULE, '退货换标详情持久化总资料且业务状态未漂移', relabelDetail.response,
relabelDetail.data?.application?.relabelNo === relabelNo
&& relabelDetail.data?.application?.status === 'PENDING_PROCESSING'
&& nonBlank(relabelDetail.data?.application?.totalFileId), trace(relabelDetail.response), OPTIONS)
context.createdResources.phase4OverseasReturnReceipt.relabelNo = relabelNo
}
}
const outboundCandidates = await overseas(context, context.overseasToken, 'GET', '平台查询本轮退货出库候选',
query('/api/platform/v1/last-leg/outbound-orders/return/candidates', {
customerId, overseasWarehouseId: warehouse.id,
}))
const outboundCandidate = (Array.isArray(outboundCandidates.data) ? outboundCandidates.data : [])
.find(row => row?.returnNo === returnNo && nonBlank(row?.cartonMark)
&& row.cartonMark !== relabelCandidate?.cartonMark && row.status === 'AVAILABLE')
context.assert(MODULE, '退货出库候选排除已冻结换标箱', outboundCandidates.response,
Boolean(outboundCandidate), `cartonMark=${outboundCandidate?.cartonMark || 'missing'}`, OPTIONS)
if (outboundCandidate) {
const outboundBody = {
recipient: `${context.marker} RETURN OUTBOUND`, overseasWarehouseId: warehouse.id,
returnNo, cartonMarks: [outboundCandidate.cartonMark], remark: context.marker,
idempotencyKey: key(context, 'OUTBOUND'),
}
const outbound = await overseas(context, context.overseasToken, 'POST', '平台创建独立退货出库单',
query('/api/platform/v1/last-leg/outbound-orders/return', { customerId }), outboundBody)
const taskNo = id(outbound.data?.taskNo)
const outboundNo = id(outbound.data?.outboundNo)
context.assert(MODULE, 'RETURN_OUTBOUND创建PDA待拣任务', outbound.response,
nonBlank(taskNo) && nonBlank(outboundNo) && outbound.data?.status === 'PENDING',
`outboundNo=${outboundNo || 'missing'}, taskNo=${taskNo || 'missing'}`, OPTIONS)
if (taskNo) {
const task = await overseas(context, picker.token, 'GET', 'PDA读取退货出库任务明细',
`/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(taskNo)}`)
const taskItem = task.data?.items?.find?.(row => String(row?.cartonMarks || '').includes(outboundCandidate.cartonMark))
|| task.data?.items?.[0]
const pickBody = { outboundPalletItemId: taskItem?.outboundPalletItemId,
inventoryPositionId: null, packages: 1, cartonMarks: [outboundCandidate.cartonMark],
idempotencyKey: key(context, 'OUTBOUND-PICK') }
const picked = await overseas(context, picker.token, 'POST', 'PDA一次扫描退货箱完成拣货',
`/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(taskNo)}/pick`, pickBody)
context.assert(MODULE, '退货出库拣货推进为PICKED且任务进行中', picked.response,
picked.data?.outboundNo === outboundNo && picked.data?.status === 'IN_PROGRESS', trace(picked.response), OPTIONS)
const completed = await overseas(context, picker.token, 'POST', 'PDA完成退货出库',
`/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(taskNo)}/complete`,
{ idempotencyKey: key(context, 'OUTBOUND-COMPLETE') })
const outboundDetail = await overseas(context, context.overseasToken, 'GET', '平台回查退货出库最终状态',
query(`/api/platform/v1/last-leg/outbound-orders/${encodeURIComponent(outboundNo)}`, { customerId }))
context.assert(MODULE, 'RETURN_OUTBOUND真实完成且箱唛只出库一次', outboundDetail.response,
completed.data?.status === 'COMPLETED'
&& outboundDetail.data?.outbound?.outboundType === 'RETURN_OUTBOUND'
&& outboundDetail.data?.outbound?.status === 'OUTBOUNDED'
&& outboundDetail.data?.pallets?.some?.(row => row.cartonMarks === outboundCandidate.cartonMark
&& Number(row.pickedPackageCount) === 1), trace(outboundDetail.response), OPTIONS)
context.createdResources.phase4OverseasReturnReceipt.returnOutboundNo = outboundNo
}
}
} finally {
await disablePicker(context, picker, 92)
}
}
@@ -1,6 +1,8 @@
import test from 'node:test'
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'
const moduleUrl = new URL('./phase4-overseas-return-receipt.mjs', import.meta.url)
const acceptanceUrl = new URL('../admin-test-env-acceptance.mjs', import.meta.url)
@@ -46,3 +48,38 @@ test('HTTP acceptance uses injected clients and never bypasses APIs with databas
const source = await readFile(moduleUrl, 'utf8')
assert.doesNotMatch(source, /process\.env|https?:\/\/|\bfetch\s*\(|\bmysql\b|\bSELECT\s|\bINSERT\s|\bUPDATE\s/i)
})
test('receipt scope stops before relabel and outbound; followups are explicit and dependency ordered', async () => {
const source = await readFile(moduleUrl, 'utf8')
assert.doesNotMatch(source, /relabel-candidates|relabel-applications|outbound-orders|outbound-tasks/)
const ordered = normalizeModuleOrder(['phase4-overseas-return-followups'])
assert.ok(ordered.indexOf('phase4-overseas-return-receipt') < ordered.indexOf('phase4-overseas-return-followups'))
assert.ok(!normalizeModuleOrder(['phase4-overseas-return-receipt']).includes('phase4-overseas-return-followups'))
})
test('validation predicate rejects outages, permission failures, success and missing diagnostics', () => {
const response = { http: 200, body: { code: 'A0422' }, requestId: 'request', traceId: 'trace' }
assert.equal(validationFailure(response), true)
for (const code of ['200', 'A0403', 'A0409', 'S0500', 'S0503']) {
assert.equal(validationFailure({ ...response, body: { code } }), false)
}
assert.equal(validationFailure({ ...response, traceId: '' }), false)
})
test('inventory snapshot ignores ordering but detects quantity, status and source drift', () => {
const a = { cartonMark: 'A', totalQuantity: 2, status: 'AVAILABLE', sourceBatchNo: 'B1', sourceReturnNo: 'R1' }
const b = { ...a, cartonMark: 'B', totalQuantity: 3 }
const baseline = inventorySnapshot([a, b])
assert.equal(inventorySnapshot({ records: [b, a] }), baseline)
for (const change of [{ totalQuantity: 1 }, { status: 'MERGED' }, { sourceBatchNo: 'B2' }, { sourceReturnNo: 'R2' }]) {
assert.notEqual(inventorySnapshot([{ ...a, ...change }, b]), baseline)
}
})
test('extended HTTP coverage includes negative cases and persisted no-mutation verification', async () => {
const source = await readFile(moduleUrl, 'utf8')
for (const label of ['重复预报SKU', '负实收数量', '未预报SKU', '部分签收退货单禁止合箱',
'超可用库存', '失败与幂等重放不改变箱数数量状态及来源', '跨页箱号无重复无遗漏', '合箱及异常后客户SKU仍为甲3乙2']) {
assert.ok(source.includes(label), label)
}
})
@@ -19,6 +19,18 @@ 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'}`
@@ -128,6 +140,26 @@ export async function run(context) {
{ ...createBody, remark: `${context.marker}-CHANGED` }, {}, merchantToken)
add(context, '退货预报同键异载荷返回冲突', createConflict, conflict(createConflict))
for (const [name, items] of [
['空SKU明细', []],
['零预报数量', [{ skuCode: skuA, expectedQuantity: 0 }]],
['负预报数量', [{ skuCode: skuA, expectedQuantity: -1 }]],
['重复预报SKU', [createBody.items[0], createBody.items[0]]],
]) {
const response = await context.requestOverseas('POST', RETURN_ORDERS,
{ ...createBody, items, idempotencyKey: key(context, `INVALID-CREATE-${name}`) }, {}, merchantToken)
add(context, `拒绝${name}`, response, validationFailure(response))
}
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)
const missingOrder = await context.requestOverseas('POST', `${PDA_RECEIPTS}/start`,
{ returnNo: `MISSING-${suffix}` }, {}, picker.token)
add(context, '不存在退货单返回可诊断未找到', missingOrder, notFound(missingOrder))
const emptySku = await overseas(context, picker.token, 'POST', 'PDA查询不存在SKU返回正常空候选',
`${PDA_RECEIPTS}/sku-candidates`, { skuCode: `MISSING-${suffix}` })
context.assert(MODULE, '不存在SKU不是业务错误', emptySku.response,
@@ -177,6 +209,23 @@ export async function run(context) {
}, {}, picker.token)
add(context, '超收被拒绝且不得生成费用批次', overReceipt, conflict(overReceipt))
for (const [name, changes, expected] of [
['零实收数量', { items: [{ skuCode: skuA, receivedQuantity: 0 }] }, validationFailure],
['负实收数量', { items: [{ skuCode: skuA, receivedQuantity: -1 }] }, validationFailure],
['空实收明细', { items: [] }, validationFailure],
['非法签收方式', { startedBy: 'INVALID' }, validationFailure],
['未预报SKU', { items: [{ skuCode: `UNKNOWN-${suffix}`, receivedQuantity: 1 }] }, conflict],
]) {
const response = await context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`,
{ ...firstBody, ...changes, idempotencyKey: key(context, `INVALID-RECEIPT-${name}`) }, {}, picker.token)
add(context, `拒绝${name}`, response, expected(response))
}
const earlyMerge = await context.requestOverseas('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)
add(context, '部分签收退货单禁止合箱', earlyMerge, conflict(earlyMerge))
const afterPartial = await overseas(context, merchantToken, 'GET', '客户回查部分签收状态',
`${RETURN_ORDERS}/${encodeURIComponent(returnNo)}`)
context.assert(MODULE, '超收失败后订单、签收和费用批次数量均未变化', afterPartial.response,
@@ -326,94 +375,50 @@ export async function run(context) {
'/api/pda/v1/last-leg/return-inventory/cartons/merge',
{ ...mergeBody, weight: 3 }, {}, picker.token)
add(context, '合箱同键异载荷返回冲突', mergeConflict, conflict(mergeConflict))
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],
['空合箱明细', { items: [] }, validationFailure],
['零合箱数量', { items: [{ skuCode: skuA, quantity: 0 }] }, validationFailure],
['超可用库存', { items: [{ skuCode: skuA, quantity: 999 }] }, conflict],
['不存在来源单', { returnNos: [`MISSING-${suffix}`] }, notFound],
]) {
const response = await context.requestOverseas('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge',
{ ...mergeBody, ...changes, idempotencyKey: key(context, `INVALID-MERGE-${name}`) }, {}, picker.token)
add(context, `合箱拒绝${name}`, response, expected(response))
}
await overseas(context, picker.token, 'POST', '异常检查后再重放原合箱请求',
'/api/pda/v1/last-leg/return-inventory/cartons/merge', 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)
}
const relabelCandidates = await overseas(context, merchantToken, 'GET', '客户查询本轮退货换标候选',
`${RETURN_ORDERS}/${encodeURIComponent(returnNo)}/relabel-candidates`)
const relabelCandidate = (Array.isArray(relabelCandidates.data) ? relabelCandidates.data : [])
.find(row => nonBlank(row?.cartonMark) && row?.status === 'AVAILABLE')
context.assert(MODULE, '退货换标候选来自本轮已签收可用箱', relabelCandidates.response,
Boolean(relabelCandidate) && relabelCandidate.sourceReturnNo === returnNo,
`cartonMark=${relabelCandidate?.cartonMark || 'missing'}`, OPTIONS)
if (relabelCandidate) {
const relabelBody = {
cartonMarks: [relabelCandidate.cartonMark], idempotencyKey: key(context, 'RELABEL'),
contactName: '退货换标验收', contactPhone: '13800000008', deliveryMethod: 'TRUCK', remark: context.marker,
}
const relabel = await overseas(context, merchantToken, 'POST', '客户从退货箱创建换标单',
`${RETURN_ORDERS}/${encodeURIComponent(returnNo)}/relabel`, relabelBody)
const relabelNo = id(relabel.data?.relabelNo)
context.assert(MODULE, '退货换标单绑定原退货单与选中箱', relabel.response,
nonBlank(relabelNo) && relabel.data?.returnNo === returnNo
&& relabel.data?.status === 'PENDING_PROCESSING'
&& relabel.data?.cartonMarks?.includes?.(relabelCandidate.cartonMark),
`relabelNo=${relabelNo || 'missing'}`, OPTIONS)
if (relabelNo) {
const form = new FormData()
form.append('file', new Blob([new TextEncoder().encode(`%PDF-1.4\n${context.marker}\n%%EOF`)],
{ type: 'application/pdf' }), `${suffix}-return-relabel.pdf`)
const uploaded = await context.requestOverseasMultipart('POST',
`/api/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/files?scope=CARTON_TOTAL`,
form, {}, merchantToken)
add(context, '客户编辑退货换标总资料', uploaded, success(uploaded))
const relabelDetail = await overseas(context, merchantToken, 'GET', '客户回查退货换标资料修改',
`/api/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}`)
context.assert(MODULE, '退货换标详情持久化总资料且业务状态未漂移', relabelDetail.response,
relabelDetail.data?.application?.relabelNo === relabelNo
&& relabelDetail.data?.application?.status === 'PENDING_PROCESSING'
&& nonBlank(relabelDetail.data?.application?.totalFileId), trace(relabelDetail.response), OPTIONS)
context.createdResources.phase4OverseasReturnReceipt.relabelNo = relabelNo
}
}
const outboundCandidates = await overseas(context, context.overseasToken, 'GET', '平台查询本轮退货出库候选',
query('/api/platform/v1/last-leg/outbound-orders/return/candidates', {
customerId, overseasWarehouseId: warehouse.id,
}))
const outboundCandidate = (Array.isArray(outboundCandidates.data) ? outboundCandidates.data : [])
.find(row => row?.returnNo === returnNo && nonBlank(row?.cartonMark)
&& row.cartonMark !== relabelCandidate?.cartonMark && row.status === 'AVAILABLE')
context.assert(MODULE, '退货出库候选排除已冻结换标箱', outboundCandidates.response,
Boolean(outboundCandidate), `cartonMark=${outboundCandidate?.cartonMark || 'missing'}`, OPTIONS)
if (outboundCandidate) {
const outboundBody = {
recipient: `${context.marker} RETURN OUTBOUND`, overseasWarehouseId: warehouse.id,
returnNo, cartonMarks: [outboundCandidate.cartonMark], remark: context.marker,
idempotencyKey: key(context, 'OUTBOUND'),
}
const outbound = await overseas(context, context.overseasToken, 'POST', '平台创建独立退货出库单',
query('/api/platform/v1/last-leg/outbound-orders/return', { customerId }), outboundBody)
const taskNo = id(outbound.data?.taskNo)
const outboundNo = id(outbound.data?.outboundNo)
context.assert(MODULE, 'RETURN_OUTBOUND创建PDA待拣任务', outbound.response,
nonBlank(taskNo) && nonBlank(outboundNo) && outbound.data?.status === 'PENDING',
`outboundNo=${outboundNo || 'missing'}, taskNo=${taskNo || 'missing'}`, OPTIONS)
if (taskNo) {
const task = await overseas(context, picker.token, 'GET', 'PDA读取退货出库任务明细',
`/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(taskNo)}`)
const taskItem = task.data?.items?.find?.(row => String(row?.cartonMarks || '').includes(outboundCandidate.cartonMark))
|| task.data?.items?.[0]
const pickBody = { outboundPalletItemId: taskItem?.outboundPalletItemId,
inventoryPositionId: null, packages: 1, cartonMarks: [outboundCandidate.cartonMark],
idempotencyKey: key(context, 'OUTBOUND-PICK') }
const picked = await overseas(context, picker.token, 'POST', 'PDA一次扫描退货箱完成拣货',
`/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(taskNo)}/pick`, pickBody)
context.assert(MODULE, '退货出库拣货推进为PICKED且任务进行中', picked.response,
picked.data?.outboundNo === outboundNo && picked.data?.status === 'IN_PROGRESS', trace(picked.response), OPTIONS)
const completed = await overseas(context, picker.token, 'POST', 'PDA完成退货出库',
`/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(taskNo)}/complete`,
{ idempotencyKey: key(context, 'OUTBOUND-COMPLETE') })
const outboundDetail = await overseas(context, context.overseasToken, 'GET', '平台回查退货出库最终状态',
query(`/api/platform/v1/last-leg/outbound-orders/${encodeURIComponent(outboundNo)}`, { customerId }))
context.assert(MODULE, 'RETURN_OUTBOUND真实完成且箱唛只出库一次', outboundDetail.response,
completed.data?.status === 'COMPLETED'
&& outboundDetail.data?.outbound?.outboundType === 'RETURN_OUTBOUND'
&& outboundDetail.data?.outbound?.status === 'OUTBOUNDED'
&& outboundDetail.data?.pallets?.some?.(row => row.cartonMarks === outboundCandidate.cartonMark
&& Number(row.pickedPackageCount) === 1), trace(outboundDetail.response), OPTIONS)
context.createdResources.phase4OverseasReturnReceipt.returnOutboundNo = outboundNo
}
}
} finally {
await disablePicker(context, picker, 91)
}