This commit is contained in:
2026-08-26 17:50:56 +08:00
parent aad8dd6344
commit 64047df5b3
10 changed files with 193 additions and 20 deletions
+4
View File
@@ -4,6 +4,8 @@
需要为人工联调批量准备同一客户、同一国内仓和同一渠道的 `WAIT_SPLIT` 数据时,使用[《批量待分柜数据生成指南》](批量待分柜数据生成指南.md)。该入口通过正式下单、入库、称重和测量接口生成数据,支持 FBA、海外仓、私人地址及一件代发等稳定随机组合;不得直接写库或用于生产环境。
订单造数中的 `GET /api/merchant/v1/partners/candidates?type=SHIPPER` 属于 Warehouse 正式客户候选能力。失败时订单脚本仍可使用 sender 快照继续收集后续链路证据,但 HTTP 503/`S0503` 必须保留为 deployment blocker,不能因订单、测量、分柜或装柜后续通过而降级为 coverage gap。
人工分柜采用两个可选模块,不进入默认全量入口:`manual-order-splitting-fixture.mjs` 基于本轮至少四票 `WAIT_SPLIT` 数据生成部分分配、整票拼柜和未分配对照清单;人工在 Admin 完成计划分配后,`manual-order-splitting-verify.mjs` 读取准备报告和 `loadingPlanId`,只读核对柜次范围、计划箱数及候选余量。完整命令见同一指南的“人工分柜验收”章节。
人工分柜通过后可继续执行 `manual-preload-push-fixture.mjs`:它读取分柜核验报告并冻结推送前计划版本、明细及无号模拟柜状态;人工点击“推送预装”后,再由 `manual-preload-push-verify.mjs` 只读核对 `EDITING` 预装清单、PENDING bridge、版本递增、明细零漂移和 PDA `A0404`。两个模块同样不进入默认全量入口。
@@ -88,6 +90,8 @@ export async function run(context) {
海外 PDA 收货统一扫描冻结箱码:`FBA` 与 `OVERSEAS` 都必须由 `POST /api/pda/v1/last-leg/receiving-containers/current/cartons/resolve` 返回 HTTP 200、业务码 `200` 和当前柜唯一 `inboundCode`,不展示、扫描或提交 SKU。仅实收阶段分流:FBA 使用不含 `skuCode` 的 `/current/receipts/bulk`,逐箱接口必须精确返回 `A0409 FBA destination must use bulk count`;OVERSEAS 使用 `/current/receipts/carton`,bulk 必须精确返回 `A0409 OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden`。实机旧行为反证为 requestId `5e925a3d-8184-41e1-b0d2-f4e1aae86639`、traceId `62fdf603b65fd7f43ec452bb8a6ca185`。跨区广播消费只保留观察证据,不再单独形成 deployment blocker;实际 `PICKUP_READY` 候选门禁仍必须通过。
FBA 无 `ll_inbound_plan_carton` manifest 时,服务端只取完整扫描值的第一个 `#` 前缀,并在当前锁定柜中精确匹配唯一 `inboundCode`;不校验 SKU、`#` 后缀或箱序号,也不能要求前端拆分或自行决定归属。无 `#`、空前缀、未命中、跨柜或同柜多匹配必须拒绝。OVERSEAS 仍按完整箱码精确匹配真实 manifest 行。HTTP 合同使用任意 FBA 后缀验证前缀解析,覆盖上述负向与 OVERSEAS 缺 manifest,并在正负向 resolve 后回查 current 的状态、版本与收货数量零漂移。2026-08-26 最新已部署实机尚返回 `A0404 Carton mark was not found`,requestId `a716e2c2-dc10-4d07-8014-7438afb63090`、traceId `6ab8fcfcd812056ad6570d3b1652157b`;该证据保留为后端 FBA 入仓码前缀解析落盘及重新部署后的回归反证,不据此放宽成功合同。
```powershell
node scripts/admin-test-env-acceptance.mjs --gateway <development-gateway> --overseas-gateway <development-overseas-gateway> --modules phase4-overseas-warehouse,phase4-overseas-pda,phase4-overseas-pda-operations,phase4-cross-region-events,phase4-overseas-business,phase4-overseas-master-data,phase4-carrier-nodes
```
@@ -59,5 +59,6 @@ test('批量待分柜参数固定同一客户并限制为订单测量链', () =>
assert.match(orders, /destinationType === 'PRIVATE'/)
assert.match(orders, /PRIVATE_ADDRESS/)
assert.match(orders, /eligible\.length < 1/)
assert.match(orders, /Client 查询发货客户候选', partnerResponse, '', CONTRACT_OPTIONS/)
assert.doesNotMatch(orders, /customerName: master\.partner\?\.partnerName/)
})
+1 -1
View File
@@ -290,7 +290,7 @@ async function loadMasterData(context, merchantToken) {
const partnerResponse = await request(context, 'GET',
'/api/merchant/v1/partners/candidates?type=SHIPPER', undefined, merchantToken)
const partners = context.apiSuccess(MODULE, 'Client 查询发货客户候选', partnerResponse)
const partners = context.apiSuccess(MODULE, 'Client 查询发货客户候选', partnerResponse, '', CONTRACT_OPTIONS)
const partner = Array.isArray(partners)
? partners.find((item) => item.status === 'ACTIVE' && item.types?.includes?.('SHIPPER'))
: null
@@ -62,7 +62,18 @@ test('PDA module covers the container-number receiving contract and excludes out
assert.match(source, /FBA和OVERSEAS箱码resolve都必须HTTP 200\/code 200并返回当前柜唯一入仓码/)
assert.match(source, /const matches = inbounds\.filter/)
assert.match(source, /!Array\.isArray\(resolved\?\.inboundCode\)/)
assert.match(source, /matches\.length === 1/)
assert.match(source, /destinations\.size === 1/)
assert.doesNotMatch(source, /\.split\([^\n]*['"]#['"]/)
assert.match(source, /`\$\{item\.inboundCode\}#PDA-CONTRACT-\$\{index \+ 1\}`/)
for (const negative of ['无#箱码被服务端拒绝', '空#前缀箱码被服务端拒绝',
'未命中入仓码前缀被服务端拒绝', '其他柜箱码前缀被当前锁定柜拒绝',
'OVERSEAS缺少真实manifest行时不得按标准箱码推导']) {
assert.ok(source.includes(negative), negative)
}
assert.doesNotMatch(source, /FBA标准箱码序号超范围被拒绝/)
assert.match(source, /FBA与OVERSEAS箱码resolve均为纯查询且零状态写入/)
assert.match(source, /receivingSnapshot\(afterResolve\.data\) === beforeResolve/)
assert.match(source, /const fixturesConserved = overseasFixtures\.length === expectedPending/)
assert.match(source, /OVERSEAS实装箱唛与待收箱数守恒/)
assert.match(source, /for \(const \[index, fixture\] of overseasFixtures\.entries\(\)\)/)
@@ -108,6 +119,21 @@ test('real-device legacy FBA carton rejection is retained as regression evidence
assert.match(source, /a0409Message\(response, \/\^OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden\$\//)
})
test('real-device deployed FBA resolve A0404 is retained until server-side inbound-prefix matching lands', () => {
const evidence = {
http: 200,
code: 'A0404',
message: 'Carton mark was not found',
requestId: 'a716e2c2-dc10-4d07-8014-7438afb63090',
traceId: '6ab8fcfcd812056ad6570d3b1652157b',
databaseFact: 'current FBA has no ll_inbound_plan_carton manifest',
}
assert.equal(evidence.code, 'A0404')
assert.equal(evidence.message, 'Carton mark was not found')
assert.match(evidence.databaseFact, /no ll_inbound_plan_carton manifest/)
assert.match(source, /a0404Message\(response, \/\^Carton mark was not found\$\//)
})
test('PDA receiving lifecycle is ordered from candidate claim through photos, mode-specific receipts, pallet and complete', () => {
const orderedTokens = [
"'查询当前客户待收柜号'", "'选择柜号并建立WAIT_SIGN会话'",
@@ -13,12 +13,26 @@ function code(response) { return String(response?.body?.code ?? '') }
function traced(response) { return nonBlank(response?.requestId) && nonBlank(response?.traceId) }
function conflict(response) { return (response?.http === 409 || (response?.http === 200 && ['409', 'A0409'].includes(code(response)))) && traced(response) }
function a0409Message(response, pattern) { return response?.http === 200 && code(response) === 'A0409' && traced(response) && pattern.test(String(response?.body?.message || '')) }
function a0404Message(response, pattern) { return response?.http === 200 && code(response) === 'A0404' && traced(response) && pattern.test(String(response?.body?.message || '')) }
function cartonResolveRejected(response) { return response?.http === 200 && ['A0400', 'A0404', 'A0409'].includes(code(response)) && traced(response) }
function notFound(response) { return (response?.http === 404 || (response?.http === 200 && ['404', 'A0404'].includes(code(response)))) && traced(response) }
function forbidden(response) { return [401, 403].includes(response?.http) || (response?.http === 200 && ['A0401', 'A0403'].includes(code(response))) }
function trace(response) { return `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` }
function key(context, action, suffix = '') { return `${context.marker}-${context.pdaContainerSuffix || ''}-${action}-${suffix}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 120) }
function markerSuffix(context) { return `${context.marker}${context.pdaContainerSuffix || ''}`.replace(/[^A-Za-z0-9]/g, '').slice(-16).toUpperCase() }
function records(data) { return Array.isArray(data?.records) ? data.records : Array.isArray(data) ? data : [] }
function receivingSnapshot(data) {
return JSON.stringify({
containerNo: data?.containerNo, status: data?.status, version: data?.version,
totalPackages: data?.totalPackages, receivedPackages: data?.receivedPackages,
pendingPackages: data?.pendingPackages,
inbounds: (Array.isArray(data?.inbounds) ? data.inbounds : []).map((item) => ({
inboundCode: item?.inboundCode, destinationType: item?.destinationType,
totalPackages: item?.totalPackages, receivedPackages: item?.receivedPackages,
pendingPackages: item?.pendingPackages,
})).sort((left, right) => `${left.inboundCode}:${left.destinationType}`.localeCompare(`${right.inboundCode}:${right.destinationType}`)),
})
}
function blockerGap(context, name, reason, category = 'fixture-unavailable', response = null) {
context.addCoverageGap(MODULE, SCENARIO, name, reason, category, { deploymentBlocker: true })
@@ -293,10 +307,14 @@ async function awaitPickupReadyCandidates(context, token, producers, allProducer
}
async function resolveCartonFixtures(context, token, inbounds, producerCartonMarks = []) {
const cartonCandidates = [...new Set((Array.isArray(producerCartonMarks) ? producerCartonMarks : [producerCartonMarks])
.map(id).filter(nonBlank))]
const manifestCartons = (Array.isArray(producerCartonMarks) ? producerCartonMarks : [producerCartonMarks])
.map(id).filter(nonBlank)
const fbaCartons = inbounds.filter((item) => item.destinationType === 'FBA')
.map((item, index) => `${item.inboundCode}#PDA-CONTRACT-${index + 1}`)
const overseasCartons = inbounds.some((item) => item.destinationType === 'OVERSEAS') ? manifestCartons : []
const cartonCandidates = [...new Set([...fbaCartons, ...overseasCartons])]
if (!cartonCandidates.length) {
blockerGap(context, '统一箱码解析链', '缺少本轮PDA任务冻结箱码,FBA和OVERSEAS均不能使用伪造箱号')
blockerGap(context, '统一箱码解析链', 'FBA缺少当前柜入仓码或OVERSEAS缺少真实manifest箱码')
return null
}
const cartonFixtures = []
@@ -307,7 +325,7 @@ async function resolveCartonFixtures(context, token, inbounds, producerCartonMar
const matches = inbounds.filter((item) => item.inboundCode === resolved?.inboundCode)
const destinations = new Set(matches.map((item) => item?.destinationType).filter(nonBlank))
const unique = success(response) && nonBlank(resolved?.inboundCode) && !Array.isArray(resolved?.inboundCode)
&& matches.length > 0 && destinations.size === 1
&& matches.length === 1 && destinations.size === 1
&& ['FBA', 'OVERSEAS'].includes(resolved?.destinationType)
&& destinations.has(resolved?.destinationType)
context.addStep(MODULE, `统一解析${resolved?.destinationType || 'UNKNOWN'}箱码${index + 1}并返回唯一入仓码`, response,
@@ -323,6 +341,41 @@ async function resolveCartonFixtures(context, token, inbounds, producerCartonMar
return cartonFixtures
}
async function probeCartonResolveNegatives(context, token, current) {
const fba = current.inbounds.find((item) => item.destinationType === 'FBA')
await expected(context, token, 'POST', '无#箱码被服务端拒绝',
'/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve',
{ cartonMark: fba?.inboundCode || `NO-HASH-${markerSuffix(context)}` }, cartonResolveRejected)
await expected(context, token, 'POST', '空#前缀箱码被服务端拒绝',
'/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve',
{ cartonMark: `#${markerSuffix(context)}` }, cartonResolveRejected)
await expected(context, token, 'POST', '未命中入仓码前缀被服务端拒绝',
'/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve',
{ cartonMark: `UNMATCHED-${markerSuffix(context)}#ANY-SUFFIX` }, cartonResolveRejected)
const allProducers = context.createdResources?.phase4InboundProducerFixtures?.containers || []
const crossContainerMark = allProducers
.filter((item) => id(item?.containerNo) !== id(current.containerNo))
.flatMap((item) => Array.isArray(item?.cartonMarks) ? item.cartonMarks : [item?.cartonMark])
.map(id).find(nonBlank)
if (crossContainerMark) {
await expected(context, token, 'POST', '其他柜箱码前缀被当前锁定柜拒绝',
'/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve',
{ cartonMark: crossContainerMark }, cartonResolveRejected)
} else {
blockerGap(context, '箱码跨柜负向', '缺少另一真实柜的冻结箱码,不能伪造跨柜归属反证')
}
const overseas = current.inbounds.find((item) => item.destinationType === 'OVERSEAS')
if (overseas) {
const total = Math.max(1, Number(overseas.totalPackages || 1))
await expected(context, token, 'POST', 'OVERSEAS缺少真实manifest行时不得按标准箱码推导',
'/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve',
{ cartonMark: `${overseas.inboundCode}#${total + 1}-1` },
(response) => a0404Message(response, /^Carton mark was not found$/))
}
}
async function receiveFba(context, token, inbound, pallet, cartonFixtures, index) {
const resolvedCartons = cartonFixtures.filter((item) => item.inbound.inboundCode === inbound.inboundCode
&& item.resolved.destinationType === 'FBA')
@@ -454,10 +507,18 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe
{ ...signBody, photos: [...photos].reverse() }, conflict)
const current = await pda(context, token, 'GET', '签收后按账号恢复当前柜', '/api/pda/v1/last-leg/receiving-containers/current')
if (!success(current.response)) return
const beforeResolve = receivingSnapshot(current.data)
await probeCartonResolveNegatives(context, token, current.data)
const producerCartonMarks = preferredProducer?.cartonMarks?.length
? preferredProducer.cartonMarks : [preferredProducer?.cartonMark]
const cartonFixtures = await resolveCartonFixtures(context, token, current.data.inbounds, producerCartonMarks)
if (!cartonFixtures) return
const afterResolve = await pda(context, token, 'GET', '箱码解析正负向后回查当前柜零写入',
'/api/pda/v1/last-leg/receiving-containers/current')
context.assert(MODULE, 'FBA与OVERSEAS箱码resolve均为纯查询且零状态写入', afterResolve.response,
success(afterResolve.response) && receivingSnapshot(afterResolve.data) === beforeResolve,
`before=${beforeResolve}, after=${receivingSnapshot(afterResolve.data)}; ${trace(afterResolve.response)}`, OPTIONS)
if (!success(afterResolve.response) || receivingSnapshot(afterResolve.data) !== beforeResolve) return
const pallets = new Map()
for (const [index, inbound] of current.data.inbounds.entries()) {
const pallet = await createPallet(context, inbound, index)