This commit is contained in:
2026-09-30 17:13:11 +08:00
parent babc7de4b6
commit 32fb9ec7b3
26 changed files with 1484 additions and 70 deletions
@@ -34,6 +34,116 @@ export function measurementReplayDigest(facts) {
return createHash('sha256').update(JSON.stringify(facts)).digest('hex')
}
// Compare both task projections to independently frozen source identities, not
// to the first carton returned by a shared pallet.
export function sharedPalletFreezeEvidence(tasks, sources) {
const errors = []
const expected = new Map()
for (const source of sources) {
for (const boxCode of source.boxCodes || []) {
if (expected.has(boxCode)) errors.push(`duplicate source box ${boxCode}`)
expected.set(boxCode, source)
}
}
if (sources.length < 2 || new Set(sources.map(source => asId(source.inboundId))).size !== sources.length) {
errors.push('requires distinct source inbounds')
}
const snapshots = tasks.map(task => {
if (task?.taskStatus !== 'COMPLETED' || task.pallets?.length !== 1) errors.push('task not completed on one pallet')
const pallet = task?.pallets?.[0]
if (!pallet?.palletId || !pallet.palletNo || pallet.status !== 'SEALED'
|| !pallet.sealedAt || !Number.isInteger(Number(pallet.version))) errors.push('missing sealed pallet identity')
const seen = new Set()
const cartons = (pallet?.cartons || []).map(carton => {
const source = expected.get(carton.boxCode)
if (seen.has(carton.boxCode)) errors.push(`duplicate carton ${carton.boxCode}`)
seen.add(carton.boxCode)
if (!source || asId(carton.inboundId) !== asId(source.inboundId)) errors.push(`wrong owner ${carton.boxCode}`)
if (asId(carton.palletId) !== asId(pallet?.palletId) || carton.palletNo !== pallet?.palletNo) {
errors.push(`wrong pallet ${carton.boxCode}`)
}
const skuLines = (carton.skuLines || []).map(line => ({
inboundDetailId: asId(line.inboundDetailId), skuCode: line.skuCode,
expectedQuantity: Number(line.expectedQuantity), actualQuantity: Number(line.actualQuantity),
})).sort((left, right) => left.inboundDetailId.localeCompare(right.inboundDetailId))
const expectedLines = (source?.skuLinesByBox?.[carton.boxCode] || []).map(line => ({
inboundDetailId: asId(line.inboundDetailId), skuCode: line.skuCode,
expectedQuantity: Number(line.quantity), actualQuantity: Number(line.quantity),
})).sort((left, right) => left.inboundDetailId.localeCompare(right.inboundDetailId))
if (!expectedLines.length || JSON.stringify(skuLines) !== JSON.stringify(expectedLines)) {
errors.push(`sku source mismatch ${carton.boxCode}`)
}
return { boxCode: carton.boxCode, inboundId: asId(carton.inboundId), skuLines }
}).sort((left, right) => left.boxCode.localeCompare(right.boxCode))
if (seen.size !== expected.size || [...expected.keys()].some(box => !seen.has(box))) errors.push('carton set incomplete')
return { palletId: asId(pallet?.palletId), palletNo: pallet?.palletNo, status: pallet?.status,
sealedAt: pallet?.sealedAt, version: Number(pallet?.version), cartons }
})
if (tasks.length !== sources.length || snapshots.some(snapshot => JSON.stringify(snapshot) !== JSON.stringify(snapshots[0]))) {
errors.push('shared task freeze projections differ')
}
return { passed: errors.length === 0, errors, snapshot: snapshots[0] || null,
digest: measurementReplayDigest(snapshots[0] || null), sources }
}
export async function verifySharedPalletFreeze(context, sources) {
const tasks = []
for (const source of sources) {
const response = await context.request('GET',
`/api/platform/v1/first-leg/pda-dropship/tasks/${source.taskId}?warehouseId=${source.warehouseId}`)
tasks.push(context.apiSuccess(MODULE, '共托封托后逐票读取完整箱和SKU快照', response,
`inboundId=${source.inboundId}, taskId=${source.taskId}`, OPTIONS))
}
const evidence = sharedPalletFreezeEvidence(tasks, sources)
context.assert(MODULE, '两票共托逐箱唯一归属及全部SKU冻结组成一致', null,
evidence.passed, JSON.stringify({ digest: evidence.digest, errors: evidence.errors,
palletId: evidence.snapshot?.palletId, cartonCount: evidence.snapshot?.cartons.length }), OPTIONS)
context.createdResources.sharedPalletFreeze = { marker: context.marker, ...evidence }
return evidence
}
export async function prepareSharedPalletFreeze(context) {
const { run: createOrder } = await import('./orders.mjs')
const originalMarker = context.marker
const originalContext = { ...context.context }
const originalManualCount = context.options.prepareDropshipManualCount
const sources = []
try {
for (const label of ['A', 'B']) {
context.marker = `${originalMarker}-PALLET-${label}`
for (const key of ['orders', 'inboundMeasurement', 'dropshipManualCount']) delete context.createdResources[key]
for (const key of ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']) delete context.context[key]
context.context.orderDestinationType = 'OVERSEAS'
context.context.orderFulfillmentType = 'ONE_CLICK_DROPSHIP'
context.context.orderProfile = { taxMode: 'BUY_DECLARATION', pickupMethod: 'SELF_EXPRESS', containerMode: 'LCL' }
context.context.waitSplitVariant = { skuCount: 1, packageCountPerSku: 2, packageCounts: [2],
totalPackageCount: 2, palletCount: 1, skuCodes: [`SKU-COMMON-${originalMarker}`.slice(0, 64)] }
context.options.prepareDropshipManualCount = true
const start = context.steps.length
await createOrder(context)
await run(context)
const source = context.createdResources.dropshipManualCount
if (!source?.taskId || context.steps.slice(start).some(step => !step.passed)) throw new Error('shared pallet preparation failed')
const task = await completeDropshipPalletization(context, { ...source,
measurement: { actualPackageCount: source.totalCartons, expectedPalletCount: 1 },
...(label === 'A' ? { deferSeal: true } : { targetPalletNo: sources[0].palletNo }),
})
if (!task || context.steps.slice(start).some(step => !step.passed)) throw new Error('shared pallet count or seal failed')
sources.push({ taskId: asId(source.taskId), inboundId: asId(source.inboundId),
inboundCode: source.inboundCode, warehouseId: asId(source.warehouseId),
palletNo: task.pallets?.[0]?.palletNo, boxCodes: task.sealedCartonMarks,
skuLinesByBox: task.sealedSkuSources })
context.createdResources.sharedPalletFreezePreparation = { marker: originalMarker, sources }
}
context.marker = originalMarker
return await verifySharedPalletFreeze(context, sources)
} finally {
context.marker = originalMarker
context.context = originalContext
context.options.prepareDropshipManualCount = originalManualCount
}
}
async function readMeasurementReplayFacts(context, { inboundId, tenantId, measurementId }, name) {
const response = await context.request('GET',
`/api/platform/v1/first-leg/inbounds/${inboundId}/measurement?${query(tenantId)}`)
@@ -236,6 +346,7 @@ export async function completeDropshipPalletization(context, { inboundId, inboun
let countedCartons = 0
const sealedCartonMarks = []
const sealedSkuSources = {}
for (let index = 0; index < totalCartons; index += 1) {
const boxCode = `${inboundCode}#${totalCartons}-${index + 1}`
@@ -244,6 +355,9 @@ export async function completeDropshipPalletization(context, { inboundId, inboun
const resolved = context.apiSuccess(MODULE, `解析第${index + 1}箱的冻结箱唛`, resolveResponse,
`boxCode=${boxCode}`, OPTIONS)
const resolvedSkuLines = Array.isArray(resolved?.skuLines) ? resolved.skuLines : []
sealedSkuSources[boxCode] = resolvedSkuLines.map(line => ({
inboundDetailId: asId(line.inboundDetailId), skuCode: line.skuCode, quantity: Number(line.expectedQuantity),
}))
context.assert(MODULE, `第${index + 1}箱允许清点且每箱SKU明细完整`, resolveResponse,
resolved?.canCount === true && resolvedSkuLines.length > 0
&& resolvedSkuLines.every((line) => asId(line?.inboundDetailId) && line?.skuCode
@@ -361,7 +475,7 @@ export async function completeDropshipPalletization(context, { inboundId, inboun
if (deferSeal) {
context.assert(MODULE, '多票合托首票归托后保持开放等待下一票', null,
sealed?.taskStatus !== 'COMPLETED' && sealed?.pallets?.every((item) => item.status === 'OPEN'), '', OPTIONS)
return { ...sealed, sealedCartonMarks, sealedSourcePalletNos: [] }
return { ...sealed, sealedCartonMarks, sealedSkuSources, sealedSourcePalletNos: [] }
}
let lastSealResponse = countedTaskResponse
for (let index = 0; index < (sealed?.pallets || []).length; index += 1) {
@@ -383,7 +497,7 @@ export async function completeDropshipPalletization(context, { inboundId, inboun
&& Number(sealed?.unboundCartons) === 0
&& sealed?.pallets?.every((item) => item?.status === 'SEALED'),
`taskStatus=${sealed?.taskStatus ?? 'missing'}, counted=${sealed?.countedCartons ?? 'missing'}, bound=${sealed?.boundCartons ?? 'missing'}`, OPTIONS)
return { ...sealed, sealedCartonMarks,
return { ...sealed, sealedCartonMarks, sealedSkuSources,
sealedSourcePalletNos: (sealed?.pallets || []).map((item) => item.palletNo).filter(Boolean) }
}