fix(dropship): project sealed carton identities into overseas receiving
This commit is contained in:
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user