fix(dropship): project sealed carton identities into overseas receiving

This commit is contained in:
2026-09-09 13:20:11 +08:00
parent 90973532f0
commit 748855793d
19 changed files with 558 additions and 33 deletions
+2
View File
@@ -41,6 +41,8 @@ export async function run(context) {
一件代发的 `inbound-measurement` 前置通过 PDA 正式 `pallets/create-and-bind` 创建系统 DP 码并绑定待清点箱,不再按测试 marker 拼托盘号;默认一件代发后续链复用该前置。验证清点前及绑定后无待绑箱请求均拒绝且无空托、系统发码同操作重放保持托盘/计数不变、封托完成后原操作可重放;离线场景反向注入重复发码和空托,保证回归确实识别这两类故障。
该前置保存成功解析且清点的原始 `boxCode`,随装柜完成生产 fixture 传入默认 `phase4-overseas-pda`。默认矩阵同时创建 FBA 和 OVERSEAS 两种一件代发,必须先将全部原箱码经海外 `current/cartons/resolve` 精确解析到同一入仓码及冻结目的类型,再进入实收及库存验证;OVERSEAS 逐箱实收,A0404、错误箱码/归属或数量缺失均阻断,不扩展推算箱号或排除失败候选。现行 V2 托盘 SKU 分组必须携带 `sourceInboundDetailId`,离线合同同时锁定两个生产分支来源明细和上述真实 HTTP 调用链。
- `request(method, path, body?, token?, extraHeaders?)`:JSON HTTP 请求,自动使用平台 Token;幂等写操作通过 `extraHeaders` 传 `Idempotency-Key`。
- `requestBinary(method, path)`:文件下载,不把二进制写入报告。
- `apiSuccess(module, name, response, evidence?, options?)`:断言 HTTP 200 + body code `200`,成功时返回 data。
@@ -175,7 +175,8 @@ async function exerciseSystemPalletScenario(fault = '') {
throw new Error(`Unexpected scenario request: ${method} ${path}`)
},
}
await completeDropshipPalletization(context, { inboundId: '82', inboundCode: 'CODE', warehouseId: '83', measurement: { actualPackageCount: 2 } })
const sealed = await completeDropshipPalletization(context, { inboundId: '82', inboundCode: 'CODE', warehouseId: '83', measurement: { actualPackageCount: 2 } })
assert.deepEqual(sealed.sealedCartonMarks, ['CODE#2-1', 'CODE#2-2'])
return checks
}
@@ -140,6 +140,7 @@ export async function completeDropshipPalletization(context, { inboundId, inboun
const createPayload = { warehouseId, operationId: `${context.marker}-dropship-create-bind`.slice(0, 128) }
let palletNo = ''
let countedCartons = 0
const sealedCartonMarks = []
let pallet = null
const palletSnapshot = (value) => JSON.stringify({
@@ -190,7 +191,10 @@ export async function completeDropshipPalletization(context, { inboundId, inboun
&& Number(counted?.carton?.actualSkuQuantity) === Number(resolved?.expectedSkuQuantity)
&& counted?.carton?.discrepancyStatus === 'MATCH',
`cartonId=${asId(counted?.carton?.cartonId) || 'missing'}, discrepancy=${counted?.carton?.discrepancyStatus ?? 'missing'}`, OPTIONS)
if (counted?.carton?.boxCode === resolved?.boxCode) countedCartons += 1
if (counted?.carton?.boxCode === resolved?.boxCode) {
countedCartons += 1
sealedCartonMarks.push(resolved.boxCode)
}
if (index === 0) {
context.assert(MODULE, '先扫箱模式将已清点箱暂存为待组托', countResponse,
@@ -253,7 +257,7 @@ export async function completeDropshipPalletization(context, { inboundId, inboun
context.assert(MODULE, '完成后重放不重开任务或新建空托', sealedReplayResponse,
sealedReplay?.taskStatus === 'COMPLETED' && palletSnapshot(sealedReplay) === palletSnapshot(sealed),
`taskStatus=${sealedReplay?.taskStatus || 'missing'}, palletNo=${palletNo}`, OPTIONS)
return sealed
return { ...sealed, sealedCartonMarks }
}
async function provisionWarehouseResources(context, { inboundId, tenantId, warehouseId, palletCount }) {
@@ -1062,10 +1066,12 @@ export async function run(context) {
&& closeTo(finalMeasurement?.volumeWeightKg, volumeWeight),
`processStatus=${finalMeasurement?.processStatus ?? 'missing'}, measurementStatus=${finalMeasurement?.measurementStatus ?? 'missing'}, packages=${finalMeasurement?.actualPackageCount ?? 'missing'}, pallets=${weightEvidence.count}, grossWeight=${finalMeasurement?.actualGrossWeightKg ?? 'missing'}, netWeight=${finalMeasurement?.actualNetWeightKg ?? 'missing'}`, OPTIONS)
let sealedCartonMarks = []
if (dropship && finalMeasurement?.processStatus === 'WAIT_PALLETIZE') {
await completeDropshipPalletization(context, {
const sealed = await completeDropshipPalletization(context, {
inboundId, inboundCode, warehouseId: asId(summary?.warehouseId), measurement: finalMeasurement,
})
sealedCartonMarks = sealed?.taskStatus === 'COMPLETED' ? sealed.sealedCartonMarks : []
completed = await waitForCompletedMeasurement(context, measurementPath, ['WAIT_SPLIT'], 10)
finalResponse = completed.response
finalMeasurement = context.apiSuccess(MODULE, '合托后回查一件代发入仓单', finalResponse,
@@ -1087,6 +1093,7 @@ export async function run(context) {
OPTIONS)
context.createdResources.inboundMeasurement = {
sealedCartonMarks,
marker: context.marker, tenantId, inboundId, measurementId: asId(draft.measurementId),
boxCode, finalStatus: finalMeasurement?.measurementStatus || null,
processStatus: finalMeasurement?.processStatus || null,
@@ -504,6 +504,8 @@ export async function run(context) {
sourceOrderId: orderId,
sourceInboundId: inboundId,
cartonMark: fixture.cartonMark || null,
inboundCode: fixture.inboundCode || null,
sealedCartonMarks: Array.isArray(fixture.sealedCartonMarks) ? fixture.sealedCartonMarks : [],
cartonMarks: Array.isArray(fixture.cartonMarks) ? fixture.cartonMarks : [],
skuCodes: Array.isArray(fixture.skuCodes) ? fixture.skuCodes : [],
skuLines: Array.isArray(fixture.skuLines) ? fixture.skuLines : [],
@@ -1605,6 +1605,8 @@ export async function run(context) {
`inboundStatus=${stagedDetail?.summary?.processStatus || 'missing'}, taskStatus=${stagedTask?.taskStatus || 'missing'}, workflow=${stagedTask?.workflowStatus || 'missing'}, planStatus=${stagedPlan?.status || 'missing'}`, OPTIONS)
context.createdResources.phase3Loading = {
inboundCode: initial?.summary?.inboundCode || source?.inboundCode,
sealedCartonMarks: Array.isArray(source?.sealedCartonMarks) ? source.sealedCartonMarks : [],
tenantId, orderId: id(source?.orderId), inboundId, preloadPlanId: planId,
cartonMark: source?.boxCode || source?.cartonMark || null,
cartonMarks: stableLoadedCartonMarks(stagedTask?.items),
@@ -1633,5 +1635,13 @@ export async function run(context) {
pdaTaskVersion: Number(stagedTask?.version),
marker: context.marker, inboundStatus: 'LOADING', loadingPlanStatus: 'LOADING', finalStatus: 'AWAITING_REVIEW',
}
if (dropshipWholePallet) {
const marks = context.createdResources.phase3Loading.sealedCartonMarks
const loadedCount = (stagedTask?.items || []).reduce((sum, item) => sum + Number(item.pickedPackageCount || 0), 0)
context.assert(MODULE, '封托清点原始箱码覆盖一件代发全部实装箱数', stagedTaskResponse,
marks.length > 0 && new Set(marks).size === marks.length && marks.length === loadedCount,
`sealed=${marks.length}, loaded=${loadedCount}`, OPTIONS)
context.createdResources.phase3Loading.cartonMarks = marks
}
}
import { iso6346ContainerNumber } from './iso6346-container-number.mjs'
@@ -110,13 +110,13 @@ test('measurement and loading pass destination identity into the LOADED producer
assert.match(loading, /一件代发整托生产者不执行可拆分动态增量场景/)
assert.match(loading, /!dropshipWholePallet && Array\.isArray\(incrementalFixture\?\.skuCodes\)/)
assert.match(pda, /find\(\(item\) => item\?\.fulfillmentType === 'ONE_CLICK_DROPSHIP'\)/)
assert.match(pda, /resolveCartonFixtures\(context, token, current\.data\.inbounds, producerCartonMarks\)/)
assert.match(pda, /resolveCartonFixtures\(context, token, current\.data\.inbounds, producerCartonMarks, preferredProducer\)/)
assert.match(pda, /FBA与OVERSEAS箱码resolve均为纯查询且零状态写入/)
})
test('only PDA-operated variants traverse real outbound and carrier nodes before overseas receiving', () => {
const entry = read('../admin-test-env-acceptance.mjs')
assert.match(entry, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS', 'STANDARD', true\)/)
assert.match(entry, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS', 'ONE_CLICK_DROPSHIP', true\)/)
assert.match(entry, /runPhase4ProducerVariant\('FBA-STANDARD', 'FBA', 'STANDARD', false\)/)
assert.match(entry, /runPhase4ProducerVariant\('SECONDARY', 'FBA', 'STANDARD', false\)/)
const start = entry.indexOf('if (pickupRequired && producerKey)')
@@ -4,11 +4,74 @@ import path from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { expandOverseasCartonCandidates, run } from './phase4-overseas-pda.mjs'
import { expandOverseasCartonCandidates, resolveCartonFixtures, run } from './phase4-overseas-pda.mjs'
const here = path.dirname(fileURLToPath(import.meta.url))
const source = fs.readFileSync(path.join(here, 'phase4-overseas-pda.mjs'), 'utf8')
test('dropship original counted cartons must resolve exactly before any receiving or inventory checks', async () => {
const inbounds = [{ inboundCode: 'REAL-INBOUND', destinationType: 'OVERSEAS', totalPackages: 2 }]
const producer = { fulfillmentType: 'ONE_CLICK_DROPSHIP', inboundCode: 'REAL-INBOUND', destinationType: 'OVERSEAS',
sealedCartonMarks: ['REAL-INBOUND#2-2', 'REAL-INBOUND#2-1'] }
for (const fault of [null, 'missing-carton', 'wrong-carton', 'wrong-inbound', 'wrong-destination']) {
const calls = []; const checks = []
const context = {
async requestOverseas(method, requestPath, body) {
calls.push({ method, requestPath, body })
const data = { cartonMark: body.cartonMark, inboundCode: 'REAL-INBOUND', destinationType: 'OVERSEAS' }
if (fault === 'wrong-carton') data.cartonMark = 'ANOTHER-CARTON'
if (fault === 'wrong-inbound') data.inboundCode = 'ANOTHER-INBOUND'
if (fault === 'wrong-destination') data.destinationType = 'FBA'
return { http: 200, body: { code: fault === 'missing-carton' ? 'A0404' : '200',
message: 'Carton mark was not found', data }, requestId: 'request', traceId: 'trace' }
},
assert(module, name, response, passed) { checks.push({ name, passed }) },
addStep(module, name, response, passed) { checks.push({ name, passed }) },
addCoverageGap() {},
}
const result = await resolveCartonFixtures(context, 'token', inbounds, ['OLD-CANDIDATE#20-1'], producer)
assert.ok(calls.every(call => call.method === 'POST' && call.requestPath.endsWith('/current/cartons/resolve')))
assert.ok(calls.every(call => producer.sealedCartonMarks.includes(call.body.cartonMark)))
if (fault) {
assert.equal(result, null, fault)
assert.ok(checks.some(check => !check.passed), fault)
assert.ok(!checks.some(check => check.name.includes('排除未实装箱码候选')), fault)
} else {
assert.deepEqual(result.map(item => item.cartonMark), producer.sealedCartonMarks)
assert.equal(calls.length, 2)
assert.ok(checks.every(check => check.passed))
}
}
for (const sealedCartonMarks of [undefined, [], ['REAL-INBOUND#2-1'], ['REAL-INBOUND#2-1', 'REAL-INBOUND#2-1']]) {
const checks = []
const result = await resolveCartonFixtures({ assert(m,n,r,passed) { checks.push(passed) },
async requestOverseas() { assert.fail('incomplete physical fixture must not issue HTTP') } },
'token', inbounds, producer.sealedCartonMarks, { ...producer, sealedCartonMarks })
assert.equal(result, null)
assert.deepEqual(checks, [false])
}
})
test('current loading event binds each pallet carton group to an explicit source detail', () => {
const model = fs.readFileSync(path.join(here, '../../common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java'), 'utf8')
const producer = fs.readFileSync(path.join(here, '../../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java'), 'utf8')
assert.match(model, /record SkuPackageSnapshot\(String sourceInboundDetailId, String skuCode/)
assert.match(model, /sourceInboundDetailId == null \|\| sourceInboundDetailId\.isBlank\(\)/)
assert.match(producer, /SkuPackageSnapshot\(detail\.detailId\(\)\.toString\(\), detail\.skuCode\(\)/)
assert.match(producer, /SkuPackageSnapshot\(detail\.getSourceInboundDetailId\(\)\.toString\(\), detail\.getSkuCode\(\)/)
const measurement = fs.readFileSync(path.join(here, 'inbound-measurement.mjs'), 'utf8')
const loading = fs.readFileSync(path.join(here, 'phase3-loading.mjs'), 'utf8')
const finalize = fs.readFileSync(path.join(here, 'phase3-loading-finalize.mjs'), 'utf8')
assert.match(measurement, /sealedCartonMarks\.push\(resolved\.boxCode\)/)
assert.match(loading, /phase3Loading\.cartonMarks = marks/)
assert.match(finalize, /sealedCartonMarks: Array\.isArray\(fixture\.sealedCartonMarks\)/)
const entry = fs.readFileSync(path.join(here, '../admin-test-env-acceptance.mjs'), 'utf8')
assert.match(entry, /sealedCartonMarks: Array\.isArray\(fixture\.sealedCartonMarks\)/)
assert.match(entry, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS', 'ONE_CLICK_DROPSHIP', true\)/)
assert.match(source, /resolveCartonFixtures\(context, token, current\.data\.inbounds, producerCartonMarks, preferredProducer\)/)
assert.ok(source.indexOf('const cartonFixtures = await resolveCartonFixtures') < source.indexOf('const completed = await pda'))
})
test('PDA module is injected, token-scoped, traceable, and contains no direct network or secret source', () => {
assert.match(source, /export async function run\(context\)/)
assert.match(source, /requestOverseas\(/)
@@ -412,12 +412,25 @@ async function awaitPickupReadyCandidates(context, token, producers, allProducer
return ready
}
async function resolveCartonFixtures(context, token, inbounds, producerCartonMarks = []) {
export async function resolveCartonFixtures(context, token, inbounds, producerCartonMarks = [], dropshipProducer = null) {
const strictDropship = dropshipProducer?.fulfillmentType === 'ONE_CLICK_DROPSHIP'
if (strictDropship) {
const sealed = dropshipProducer.sealedCartonMarks
const inbound = inbounds.find((item) => item.inboundCode === dropshipProducer.inboundCode)
const valid = Array.isArray(sealed) && sealed.length > 0 && sealed.every(nonBlank)
&& new Set(sealed).size === sealed.length && ['FBA', 'OVERSEAS'].includes(inbound?.destinationType)
&& inbound.destinationType === dropshipProducer.destinationType
&& sealed.length === Number(inbound.totalPackages)
context.assert(MODULE, '国内清点封托原箱码完整传递到当前海外收货柜', null, valid,
`sealed=${sealed?.length ?? 'missing'}, expected=${inbound?.totalPackages ?? 'missing'}`, OPTIONS)
if (!valid) return null
producerCartonMarks = sealed
}
const manifestCartons = (Array.isArray(producerCartonMarks) ? producerCartonMarks : [producerCartonMarks])
.map(id).filter(nonBlank)
const fbaCartons = inbounds.filter((item) => item.destinationType === 'FBA')
const fbaCartons = (strictDropship ? [] : inbounds.filter((item) => item.destinationType === 'FBA'))
.map((item, index) => `${item.inboundCode}#PDA-CONTRACT-${index + 1}`)
const overseasCartons = expandOverseasCartonCandidates(inbounds, manifestCartons)
const overseasCartons = strictDropship ? manifestCartons : expandOverseasCartonCandidates(inbounds, manifestCartons)
const cartonCandidates = [...new Set([...fbaCartons, ...overseasCartons])]
if (!cartonCandidates.length) {
blockerGap(context, '统一箱码解析链', 'FBA缺少当前柜入仓码或OVERSEAS缺少真实manifest箱码')
@@ -427,7 +440,7 @@ async function resolveCartonFixtures(context, token, inbounds, producerCartonMar
for (const [index, cartonMark] of cartonCandidates.entries()) {
const response = await context.requestOverseas('POST',
'/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', { cartonMark }, {}, token)
if (overseasCartons.includes(cartonMark)
if (!strictDropship && overseasCartons.includes(cartonMark)
&& a0404Message(response, /^Carton mark was not found$/)) {
context.addStep(MODULE, `排除未实装箱码候选${index + 1}`, response, true,
`cartonMark=${cartonMark}; ${trace(response)}`, OPTIONS)
@@ -440,6 +453,8 @@ async function resolveCartonFixtures(context, token, inbounds, producerCartonMar
&& matches.length === 1 && destinations.size === 1
&& ['FBA', 'OVERSEAS'].includes(resolved?.destinationType)
&& destinations.has(resolved?.destinationType)
&& (!strictDropship || (resolved?.cartonMark === cartonMark
&& resolved?.inboundCode === dropshipProducer.inboundCode && resolved?.destinationType === dropshipProducer.destinationType))
context.addStep(MODULE, `统一解析${resolved?.destinationType || 'UNKNOWN'}箱码${index + 1}并返回唯一入仓码`, response,
unique, `cartonMark=${cartonMark}, inboundCode=${resolved?.inboundCode || 'missing'}, matches=${matches.length}; ${trace(response)}`, OPTIONS)
if (!unique) {
@@ -626,7 +641,7 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe
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)
const cartonFixtures = await resolveCartonFixtures(context, token, current.data.inbounds, producerCartonMarks, preferredProducer)
if (!cartonFixtures) return
const afterResolve = await pda(context, token, 'GET', '箱码解析正负向后回查当前柜零写入',
'/api/pda/v1/last-leg/receiving-containers/current')