优化
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user