优化
This commit is contained in:
@@ -16,10 +16,14 @@
|
||||
|
||||
扩展离线检查:`node --test scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs scripts/script-documentation-contract.test.mjs`。这些校验验证脚本编排和断言,不代替真实 HTTP 验收;运行报告中的每个失败均保留阻断。
|
||||
|
||||
边界扩展创建的每张退货单也必须先审核,并由绑定该仓的 PDA 经正式接口生成本轮退货实收托盘、上传合成打托测试图片;客户身份通过本轮登录会话映射,禁止用主客户替代第二客户。签收请求传 `palletCode`,成功响应核对同一托盘而非已移除的 `cartonMark` 字段;流水分两批时分别使用两个正式托盘。混合明细场景先验证两种 SKU 同批拒绝且不落账,再分别签收为两个来源库存箱以继续合箱回滚验证。三轮双请求并发范围保持不变。
|
||||
|
||||
同一退货扩展还覆盖五组完整性检查:混合明细中前项合法、后项超量/不存在时签收与合箱整笔回滚;同一操作员同键并发合箱及后续重试只生成一个目标箱;不同客户及仓库使用同一 SKU 时的候选、列表和库存隔离;客户/平台/PDA/无登录身份不得进入未授权退货入口;两批签收流水逐行核对客户、仓库、SKU、签收业务号及增量,并在重放和超收失败后回查不变。流水由现有接口从签收明细重建,不代表独立持久审计账本,也不验证后续换标或出库。并发合箱固定三轮、每轮两个请求,其余造数和校验顺序执行。
|
||||
|
||||
`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`,还需另验该换标库存的后续出库;不能把此前未冻结库存独立出库视为换标后出库。
|
||||
|
||||
退货专项复跑命令(在后端根目录执行,每次使用新的报告名;不要与其他环境混用):
|
||||
|
||||
```powershell
|
||||
|
||||
@@ -102,22 +102,35 @@ export async function run(context) {
|
||||
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')
|
||||
.find(row => source.cartonMarks?.includes(row?.cartonMark) && row?.status === 'AVAILABLE')
|
||||
context.assert(MODULE, '退货换标候选来自本轮已签收可用箱', relabelCandidates.response,
|
||||
Boolean(relabelCandidate) && relabelCandidate.sourceReturnNo === returnNo,
|
||||
`cartonMark=${relabelCandidate?.cartonMark || 'missing'}`, OPTIONS)
|
||||
if (relabelCandidate) {
|
||||
let destination
|
||||
for (let page = 1; page <= 20; page += 1) {
|
||||
const destinations = await context.request('GET', `/api/platform/v1/warehouse-admin?page=${page}&size=200&warehouseType=FBA&status=ACTIVE`)
|
||||
if (!success(destinations)) throw new Error('读取FBA目的仓失败')
|
||||
const data = destinations.body.data
|
||||
destination = records(data).find(row => row.warehouseCode === 'SCK8' && row.status === 'ACTIVE')
|
||||
if (destination || page * 200 >= Number(data.total)) break
|
||||
if (!records(data).length) throw new Error('FBA目的仓分页不完整')
|
||||
}
|
||||
if (!destination?.id) throw new Error('完整分页未找到启用的SCK8目的仓,停止退货转换标')
|
||||
const sku = relabelCandidate.items?.[0]?.skuCode
|
||||
if (!sku) throw new Error('本轮候选缺少SKU')
|
||||
const relabelBody = {
|
||||
cartonMarks: [relabelCandidate.cartonMark], idempotencyKey: key(context, 'RELABEL'),
|
||||
contactName: '退货换标验收', contactPhone: '13800000008', deliveryMethod: 'TRUCK', remark: context.marker,
|
||||
items: [{ skuCode: sku, quantity: 1 }], idempotencyKey: key(context, 'RELABEL'),
|
||||
fileScope: 'CARTON_TOTAL', destinationFbaWarehouseId: id(destination.id),
|
||||
destinationFbaWarehouseCode: destination.warehouseCode, cartonRemark: 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,
|
||||
context.assert(MODULE, '退货换标单绑定原退货单与选中SKU', relabel.response,
|
||||
nonBlank(relabelNo) && relabel.data?.returnNo === returnNo
|
||||
&& relabel.data?.status === 'PENDING_PROCESSING'
|
||||
&& relabel.data?.cartonMarks?.includes?.(relabelCandidate.cartonMark),
|
||||
&& relabel.data?.status === 'PENDING_REVIEW'
|
||||
&& relabel.data?.skuCodes?.includes?.(sku),
|
||||
`relabelNo=${relabelNo || 'missing'}`, OPTIONS)
|
||||
if (relabelNo) {
|
||||
const form = new FormData()
|
||||
@@ -125,21 +138,21 @@ export async function run(context) {
|
||||
{ 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)
|
||||
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)
|
||||
&& relabelDetail.data?.application?.status === 'PENDING_REVIEW'
|
||||
&& nonBlank(relabelDetail.data?.application?.cartonTotalFileId), 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,
|
||||
customerId, warehouseId: warehouse.id,
|
||||
}))
|
||||
const outboundCandidate = (Array.isArray(outboundCandidates.data) ? outboundCandidates.data : [])
|
||||
.find(row => row?.returnNo === returnNo && nonBlank(row?.cartonMark)
|
||||
@@ -185,7 +198,72 @@ export async function run(context) {
|
||||
context.createdResources.phase4OverseasReturnReceipt.returnOutboundNo = outboundNo
|
||||
}
|
||||
}
|
||||
if (source.relabelNo) await completeReturnRelabel(context, merchantToken, picker.token, source.relabelNo, returnNo)
|
||||
} finally {
|
||||
await disablePicker(context, picker, 92)
|
||||
}
|
||||
}
|
||||
|
||||
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)}`
|
||||
const work = `/api/pda/v1/last-leg/relabel-tasks/${encodeURIComponent(relabelNo)}`
|
||||
const pallets = '/api/pda/v1/last-leg/pallet-operations'
|
||||
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, passed) => {
|
||||
context.assert(MODULE, name, null, Boolean(passed), `relabelNo=${relabelNo}`, OPTIONS)
|
||||
if (!passed) throw new Error(name)
|
||||
}
|
||||
const reviewed = await command(context.overseasToken, 'PUT', '审核本轮退货换标申请', `${platform}/status`,
|
||||
{ status: 'PENDING_PROCESSING', remark: context.marker })
|
||||
require('换标审核后等待PDA作业', reviewed.application?.relabelNo === relabelNo && reviewed.application.status === 'PENDING_PROCESSING')
|
||||
let task = await command(pdaToken, 'POST', 'PDA认领本轮退货换标', `${work}/claim`, { idempotencyKey: key(context, 'RETURN-CLAIM') })
|
||||
require('本轮任务全部为退货SKU换标', task.relabelNo === relabelNo && task.items?.length > 0
|
||||
&& task.items.every(item => item.operationType === 'RETURN_BULK_SKU_LABEL'))
|
||||
for (const item of task.items) {
|
||||
task = await command(pdaToken, 'POST', 'PDA完成本轮退货SKU数量', `${work}/return-skus/complete`, {
|
||||
itemId: item.itemId, quantity: Number(item.expectedPackages), expectedVersion: Number(task.version),
|
||||
idempotencyKey: key(context, `RETURN-SKU-${item.itemId}`),
|
||||
})
|
||||
}
|
||||
const sources = await command(pdaToken, 'POST', '读取本轮换标实际来源托盘', `${pallets}/relabel-sources`, { relabelNo })
|
||||
require('退货换标具有明确来源托盘', Array.isArray(sources) && sources.length > 0)
|
||||
for (const [index, source] of sources.entries()) {
|
||||
const target = await command(pdaToken, 'POST', '生成本轮带目的仓换标托盘', `${pallets}/relabel-pallet-label`,
|
||||
{ relabelNo, idempotencyKey: key(context, `RETURN-PALLET-${index}`) })
|
||||
const from = await command(pdaToken, 'POST', '回查来源托盘版本', `${pallets}/resolve`, { code: source.palletCode })
|
||||
const to = await command(pdaToken, 'POST', '回查目标托盘版本', `${pallets}/resolve`, { code: target.palletCode })
|
||||
require('组托仅使用本轮任务箱唛', source.cartonMarks?.length > 0
|
||||
&& source.cartonMarks.every(mark => task.items.some(item => item.cartonMark === mark)))
|
||||
const body = { relabelNo, source: { palletCode: from.palletCode, expectedVersion: Number(from.palletVersion) },
|
||||
target: { palletCode: to.palletCode, expectedVersion: Number(to.palletVersion) },
|
||||
cartonMarks: source.cartonMarks, idempotencyKey: key(context, `RETURN-BIND-${index}`), reason: context.marker }
|
||||
const bound = await command(pdaToken, 'POST', '退货换标按实际数量绑定目标托盘', `${pallets}/relabel-disassemble`, body)
|
||||
const replay = await command(pdaToken, 'POST', '退货换标组托原键重放', `${pallets}/relabel-disassemble`, body)
|
||||
require('换标组托重放不重复移动库存', bound.status === 'COMPLETED' && replay.replayed === true && id(bound.operationId) === id(replay.operationId))
|
||||
}
|
||||
task = await command(pdaToken, 'GET', '组托后回查换标任务版本', work)
|
||||
const form = new FormData()
|
||||
form.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }), 'synthetic-return-relabel.png')
|
||||
const photo = await context.requestOverseasMultipart('POST', `${work}/photos/files`, form, pdaToken)
|
||||
add(context, '上传合成换标完工照片', photo, success(photo))
|
||||
if (!success(photo) || !photo.body.data?.fileId) throw new Error('换标完工照片失败')
|
||||
task = await command(pdaToken, 'POST', 'PDA提交退货换标完工', `${work}/complete`, {
|
||||
photoFileIds: [id(photo.body.data.fileId)], expectedVersion: Number(task.version),
|
||||
idempotencyKey: key(context, 'RETURN-COMPLETE'), remark: context.marker,
|
||||
})
|
||||
require('换标完工等待客户确认', task.applicationStatus === 'CUSTOMER_CONFIRM_PENDING')
|
||||
const body = { idempotencyKey: key(context, 'RETURN-CONFIRM'), remark: context.marker }
|
||||
const confirmed = await command(merchantToken, 'POST', '客户确认本轮退货换标完成', `${merchant}/confirm-completion`, body)
|
||||
const replay = await command(merchantToken, 'POST', '客户确认换标原键重放', `${merchant}/confirm-completion`, body)
|
||||
require('退货SKU换标客户确认完成且不重复建单', confirmed.application?.status === 'COMPLETED'
|
||||
&& replay.application?.relabelNo === relabelNo && replay.application?.status === 'COMPLETED')
|
||||
const order = await command(merchantToken, 'GET', '回查原退货单换标状态', `${RETURN_ORDERS}/${encodeURIComponent(returnNo)}`)
|
||||
require('原退货单保持签收完成且关联同一已完成换标', order.order?.status === 'COMPLETED'
|
||||
&& order.order?.relabelNo === relabelNo && order.order?.relabelStatus === 'COMPLETED')
|
||||
context.createdResources.phase4OverseasReturnReceipt.relabelCompleted = true
|
||||
}
|
||||
|
||||
@@ -3,10 +3,45 @@ 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'
|
||||
|
||||
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('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 }] }
|
||||
const context = { marker: 'fixture', overseasToken: 'admin', createdResources: { phase4OverseasReturnReceipt: {} },
|
||||
addStep() {}, assert(_m, name, _r, passed) { assert.ok(passed, name) },
|
||||
async requestOverseas(method, path, body, _headers, token) {
|
||||
calls.push({ method, path, body, token })
|
||||
let data = task
|
||||
if (path.endsWith('/status')) data = { application: { relabelNo: 'RL-1', status: 'PENDING_PROCESSING' } }
|
||||
if (path.endsWith('/relabel-sources')) data = [{ palletCode: 'OLD', cartonMarks: ['BOX'] }]
|
||||
if (path.endsWith('/relabel-pallet-label')) data = { palletCode: 'NEW' }
|
||||
if (path.endsWith('/resolve')) data = { palletCode: body.code, palletVersion: 1 }
|
||||
if (path.endsWith('/relabel-disassemble')) data = { status: 'COMPLETED', operationId: '9', replayed: true }
|
||||
if (path.endsWith('/RL-1/complete')) data = { applicationStatus: 'CUSTOMER_CONFIRM_PENDING' }
|
||||
if (path.endsWith('/confirm-completion')) data = { application: { relabelNo: 'RL-1', status: 'COMPLETED' } }
|
||||
if (path.endsWith('/return-orders/RT-1')) data = { order: { status: 'COMPLETED', relabelNo: 'RL-1', relabelStatus: 'COMPLETED' } }
|
||||
return { http: 200, body: { code: '200', data } }
|
||||
},
|
||||
async requestOverseasMultipart(method, path, form, token) {
|
||||
assert.equal(token, 'pda'); assert.ok(form instanceof FormData)
|
||||
calls.push({ method, path })
|
||||
return { http: 200, body: { code: '200', data: { fileId: '7' } } }
|
||||
} }
|
||||
await completeReturnRelabel(context, 'merchant', 'pda', 'RL-1', 'RT-1')
|
||||
assert.equal(context.createdResources.phase4OverseasReturnReceipt.relabelCompleted, true)
|
||||
const index = suffix => calls.findIndex(c => c.path.endsWith(suffix))
|
||||
assert.ok(index('/status') < index('/claim'))
|
||||
assert.ok(index('/return-skus/complete') < index('/relabel-disassemble'))
|
||||
assert.ok(index('/relabel-disassemble') < index('/photos/files'))
|
||||
assert.ok(index('/photos/files') < index('/RL-1/complete'))
|
||||
assert.ok(index('/RL-1/complete') < index('/confirm-completion'))
|
||||
assert.equal(calls.find(c => c.path.endsWith('/confirm-completion')).token, 'merchant')
|
||||
})
|
||||
|
||||
test('full acceptance registers return receipt as a configured overseas module with an isolated merchant fixture', async () => {
|
||||
const source = await readFile(acceptanceUrl, 'utf8')
|
||||
assert.match(source, /CONFIGURED_MODULES[^\n]+phase4-overseas-return-receipt/)
|
||||
@@ -62,6 +97,18 @@ test('receipt scope stops before relabel and outbound; followups are explicit an
|
||||
assert.ok(!normalizeModuleOrder(['phase4-overseas-return-receipt']).includes('phase4-overseas-return-followups'))
|
||||
})
|
||||
|
||||
test('return followups use SKU quantities, explicit FBA destination and current file and candidate contracts', async () => {
|
||||
const source = await readFile(new URL('./phase4-overseas-return-followups.mjs', import.meta.url), 'utf8')
|
||||
assert.match(source, /items: \[\{ skuCode: sku, quantity: 1 \}\]/)
|
||||
assert.match(source, /destinationFbaWarehouseId: id\(destination.id\)/)
|
||||
assert.match(source, /destinationFbaWarehouseCode: destination.warehouseCode/)
|
||||
assert.match(source, /status === 'PENDING_REVIEW'/)
|
||||
assert.match(source, /skuCodes\?\.includes\?\.\(sku\)/)
|
||||
assert.match(source, /cartonTotalFileId/)
|
||||
assert.match(source, /customerId, warehouseId: warehouse.id/)
|
||||
assert.doesNotMatch(source, /form, \{\}, merchantToken/)
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
@@ -131,3 +131,15 @@ test('高价值扩展覆盖混合回滚、同键合箱、同码隔离、权限
|
||||
assert.match(source, /snapshot: withLedger[\s\S]*签收重放不重复产生流水/)
|
||||
assert.match(source, /sameKeyResponses/)
|
||||
})
|
||||
|
||||
test('边界fixture遵守审核、单SKU托盘和完工照片新契约', async () => {
|
||||
const source = await readFile(new URL('return-boundary-scenarios.mjs', import.meta.url), 'utf8')
|
||||
assert.match(source, /customerByToken\.set\(tokenB, String\(fixtureB\.tenantId\)\)/)
|
||||
assert.match(source, /'X-Customer-Id': customerId/)
|
||||
assert.match(source, /status !== 'PENDING_REVIEW'/)
|
||||
assert.match(source, /palletCode: order\.palletCode/)
|
||||
assert.match(source, /requestOverseasMultipart\('POST',[^\n]+form, operator\.token\)/)
|
||||
assert.match(source, /两个合法SKU同批签收仍整笔拒绝/)
|
||||
assert.match(source, /palletCode: nextPallet\.palletCode/)
|
||||
assert.doesNotMatch(source, /\['receiptNo', 'receiptBatchNo', 'cartonMark'\]/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user