1412 lines
87 KiB
JavaScript
1412 lines
87 KiB
JavaScript
import { createHash } from 'node:crypto'
|
|
|
|
const MODULE = 'inbound-measurement'
|
|
const SCENARIO = 'mojia-carton-measurement'
|
|
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
|
const REUSABLE_PALLET_CODES = Object.freeze(['TP-001', 'YW-TP-002'])
|
|
const reusablePalletsByWarehouse = new Map()
|
|
const WEIGHT_IMAGE = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82,
|
|
0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84,
|
|
8, 215, 99, 248, 207, 192, 240, 31, 0, 5, 0, 1, 255, 137, 153, 61, 29, 0, 0, 0, 0,
|
|
73, 69, 78, 68, 174, 66, 96, 130])
|
|
|
|
function asId(value) { return value == null ? '' : String(value) }
|
|
function asNumber(value) { return Number(value) }
|
|
function closeTo(actual, expected, tolerance = 0.000001) {
|
|
return Number.isFinite(asNumber(actual)) && Math.abs(asNumber(actual) - expected) <= tolerance
|
|
}
|
|
function ceiling(value, scale) {
|
|
const factor = 10 ** scale
|
|
const floatingPointNoise = Number.EPSILON * Math.max(1, Math.abs(value)) * 4
|
|
return Math.ceil((value - floatingPointNoise) * factor) / factor
|
|
}
|
|
function halfUp(value, scale) {
|
|
const factor = 10 ** scale
|
|
return Math.round((value + Number.EPSILON) * factor) / factor
|
|
}
|
|
function query(tenantId) { return `tenantId=${encodeURIComponent(tenantId)}` }
|
|
function validPdf(response) {
|
|
return response.http === 200 && response.contentType.toLowerCase().includes('pdf')
|
|
&& response.bytes?.length > 4 && new TextDecoder().decode(response.bytes.slice(0, 4)) === '%PDF'
|
|
}
|
|
|
|
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)}`)
|
|
const measurement = context.apiSuccess(MODULE, `${name}测量批次明细及重量事实`, response, '', OPTIONS)
|
|
const inboundResponse = await context.request('GET', `/api/platform/inbounds/${inboundId}?${query(tenantId)}`)
|
|
const inbound = context.apiSuccess(MODULE, `${name}入库状态及历史`, inboundResponse, '', OPTIONS)
|
|
const valid = asId(measurement?.inboundId) === inboundId && asId(measurement?.measurementId) === measurementId
|
|
&& Array.isArray(measurement?.items) && measurement.items.length > 0
|
|
&& Array.isArray(measurement?.weightResults) && measurement.weightResults.length > 0
|
|
&& new Set(measurement.items.map(item => asId(item.id))).size === measurement.items.length
|
|
&& new Set(measurement.weightResults.map(item => asId(item.id))).size === measurement.weightResults.length
|
|
&& asId(inbound?.summary?.id) === inboundId && asId(inbound?.summary?.tenantId) === tenantId
|
|
&& Array.isArray(inbound?.statusHistory)
|
|
context.assert(MODULE, `${name}原入库原批次明细及历史可核对`, response, valid,
|
|
`measurementId=${asId(measurement?.measurementId)}, items=${measurement?.items?.length ?? 'missing'}`
|
|
+ `, weights=${measurement?.weightResults?.length ?? 'missing'}`, OPTIONS)
|
|
if (!valid) throw new Error('测量重放核验缺少原批次或完整明细历史事实')
|
|
return { response, facts: { measurement, inbound } }
|
|
}
|
|
|
|
export async function verifyMeasurementSubmitReplay(context, input, payload) {
|
|
const before = await readMeasurementReplayFacts(context, input, '重复提交前')
|
|
const response = await context.request('POST',
|
|
`/api/platform/v1/first-leg/inbounds/${input.inboundId}/measurement/submit?${query(input.tenantId)}`, payload)
|
|
const rejected = response?.http === 200 && String(response.body?.code) === 'A0409'
|
|
context.assert(MODULE, '已提交测量再次提交明确A0409', response, rejected,
|
|
`HTTP=${response?.http}, code=${response?.body?.code ?? 'missing'}`, OPTIONS)
|
|
const after = await readMeasurementReplayFacts(context, input, '重复提交后')
|
|
const beforeDigest = measurementReplayDigest(before.facts)
|
|
const afterDigest = measurementReplayDigest(after.facts)
|
|
const unchanged = beforeDigest === afterDigest
|
|
context.assert(MODULE, '重复测量提交拒绝后批次版本明细重量及入库历史零漂移', after.response,
|
|
unchanged, `items=${after.facts.measurement.items.length}, weights=${after.facts.measurement.weightResults.length}`
|
|
+ `, histories=${after.facts.inbound.statusHistory.length}`, OPTIONS)
|
|
return { measurementId: input.measurementId, rejected, unchanged, beforeDigest, afterDigest,
|
|
itemIds: after.facts.measurement.items.map(item => asId(item.id)),
|
|
weightIds: after.facts.measurement.weightResults.map(item => asId(item.id)),
|
|
measurementVersion: after.facts.measurement.version,
|
|
inboundVersion: after.facts.measurement.inboundVersion, historyCount: after.facts.inbound.statusHistory.length }
|
|
}
|
|
|
|
export async function verifyMeasurementApprovalReplay(context, input, payload, idempotencyKey) {
|
|
async function read(name) {
|
|
const snapshot = await readMeasurementReplayFacts(context, input, name)
|
|
const reviews = []; let total = null; let page = 1
|
|
while (total == null || reviews.length < total) {
|
|
const response = await context.request('GET',
|
|
`/api/platform/v1/inbounds/${input.inboundId}/measurement-review-history?${query(input.tenantId)}&page=${page}&size=200`)
|
|
const data = context.apiSuccess(MODULE, `${name}审核历史第${page}页`, response, '', OPTIONS)
|
|
const valid = Array.isArray(data?.records) && Number.isInteger(Number(data?.total)) && Number(data.total) >= 0
|
|
&& (total == null || total === Number(data.total))
|
|
&& (data.records.length > 0 || Number(data.total) === 0)
|
|
context.assert(MODULE, `${name}审核历史完整分页`, response, valid,
|
|
`page=${page}, total=${data?.total ?? 'missing'}`, OPTIONS)
|
|
if (!valid) throw new Error('测量审核重放缺少完整审核历史')
|
|
total = Number(data.total); reviews.push(...data.records); page += 1
|
|
}
|
|
const feeResponse = await context.request('GET',
|
|
`/api/platform/v1/inbounds/${input.inboundId}/fees/latest?${query(input.tenantId)}`)
|
|
const fee = context.apiSuccess(MODULE, `${name}原初算费用ID及版本`, feeResponse, '', OPTIONS)
|
|
const detailsResponse = await context.request('GET',
|
|
`/api/platform/v1/inbounds/${input.inboundId}/fee-details?accountId=${encodeURIComponent(input.tenantId)}&includeHistory=true`)
|
|
const feeDetails = context.apiSuccess(MODULE, `${name}全部版本费用明细`, detailsResponse, '', OPTIONS)
|
|
const approvedReviewCount = reviews.filter(item => asId(item.measurementId) === input.measurementId
|
|
&& item.reviewResult === 'APPROVED').length
|
|
const valid = snapshot.facts.measurement.measurementStatus === 'APPROVED'
|
|
&& snapshot.facts.measurement.measureReviewStatus === 'APPROVED'
|
|
&& reviews.length === total && new Set(reviews.map(item => asId(item.id))).size === total
|
|
&& approvedReviewCount === 1
|
|
&& Boolean(asId(fee?.feeId)) && Number.isInteger(Number(fee?.feeVersion)) && Number(fee.feeVersion) >= 1
|
|
&& Array.isArray(feeDetails) && new Set(feeDetails.map(item => asId(item.id))).size === feeDetails.length
|
|
context.assert(MODULE, `${name}唯一审核及已生成初算费用事实有效`, detailsResponse, valid,
|
|
`reviews=${reviews.length}, feeId=${asId(fee?.feeId)}, feeVersion=${fee?.feeVersion ?? 'missing'}`
|
|
+ `, feeDetails=${feeDetails?.length ?? 'missing'}, approvedReviewCount=${approvedReviewCount}`
|
|
+ `, measurementStatus=${snapshot.facts.measurement.measurementStatus}`
|
|
+ `, measureReviewStatus=${snapshot.facts.measurement.measureReviewStatus}`, OPTIONS)
|
|
if (!valid) throw new Error('测量审核重放缺少唯一审核或已生成初算费用事实')
|
|
return { response: snapshot.response, facts: { ...snapshot.facts, reviews, fee, feeDetails } }
|
|
}
|
|
const before = await read('审核同键重放前')
|
|
const response = await context.request('POST',
|
|
`/api/platform/v1/inbounds/${input.inboundId}/measurements/approve?${query(input.tenantId)}`,
|
|
payload, context.token, { 'Idempotency-Key': idempotencyKey })
|
|
const replay = context.apiSuccess(MODULE, '测量审核同原键原请求重放成功', response, '', OPTIONS)
|
|
const after = await read('审核同键重放后')
|
|
const beforeDigest = measurementReplayDigest(before.facts)
|
|
const afterDigest = measurementReplayDigest(after.facts)
|
|
const unchanged = beforeDigest === afterDigest && replay?.initialPricingSucceeded === true
|
|
&& asId(replay?.initialFee?.feeId) === asId(before.facts.fee.feeId)
|
|
&& Number(replay?.initialFee?.feeVersion) === Number(before.facts.fee.feeVersion)
|
|
context.assert(MODULE, '测量审核重放保持批次费用ID版本全部明细审核及入库历史零新增', after.response,
|
|
unchanged, `feeId=${asId(after.facts.fee.feeId)}, version=${after.facts.fee.feeVersion}`
|
|
+ `, reviews=${after.facts.reviews.length}, details=${after.facts.feeDetails.length}`, OPTIONS)
|
|
return { measurementId: input.measurementId, unchanged, beforeDigest, afterDigest,
|
|
feeId: asId(after.facts.fee.feeId), feeVersion: after.facts.fee.feeVersion,
|
|
feeDetailIds: after.facts.feeDetails.map(item => asId(item.id)),
|
|
reviewIds: after.facts.reviews.map(item => asId(item.id)),
|
|
inboundHistoryCount: after.facts.inbound.statusHistory.length }
|
|
}
|
|
|
|
/** 固定造数只允许指定托盘;显式通用模式才读取其他已有托盘。缺失不自动替换。 */
|
|
export async function reusablePallets(context, warehouseId) {
|
|
const cacheKey = asId(warehouseId)
|
|
const allowAnyExistingPallet = context.context?.allowAnyExistingPallet === true
|
|
const scopedCacheKey = `${cacheKey}:${allowAnyExistingPallet ? 'any' : 'fixed'}`
|
|
if (reusablePalletsByWarehouse.has(scopedCacheKey)) return reusablePalletsByWarehouse.get(scopedCacheKey)
|
|
|
|
const pallets = []
|
|
const missingFixed = []
|
|
if (!allowAnyExistingPallet) {
|
|
for (const palletCode of REUSABLE_PALLET_CODES) {
|
|
const response = await context.request('GET',
|
|
`/api/platform/v1/pallets?page=1&size=20&tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}&palletCode=${encodeURIComponent(palletCode)}`)
|
|
const page = context.apiSuccess(MODULE, `查询原有可复用托盘 ${palletCode}`, response,
|
|
`warehouseId=${warehouseId}`, OPTIONS)
|
|
const exact = (page?.records || []).find((item) => asId(item?.warehouseId) === cacheKey
|
|
&& String(item?.palletCode || '') === palletCode && asId(item?.id))
|
|
context.assert(MODULE, `查询原有称重托盘 ${palletCode}`, response, true,
|
|
`warehouseId=${warehouseId}, matched=${exact?.palletCode || 'missing'}`, OPTIONS)
|
|
if (!exact) {
|
|
missingFixed.push(palletCode)
|
|
continue
|
|
}
|
|
pallets.push({ id: asId(exact.id), code: palletCode })
|
|
}
|
|
if (pallets.length > 0) {
|
|
reusablePalletsByWarehouse.set(scopedCacheKey, pallets)
|
|
return pallets
|
|
}
|
|
context.assert(MODULE, '指定称重托盘池至少存在一只可复用托盘', null, false,
|
|
`warehouseId=${warehouseId}, missing=${missingFixed.join(',')}`, OPTIONS)
|
|
context.addCoverageGap?.(MODULE, 'measurement-pallet-fixture', '固定称重托盘主数据缺失',
|
|
`warehouseId=${warehouseId}, missing=${missingFixed.join(',')};停止固定托盘造数`,
|
|
'fixture', { blocking: true })
|
|
return null
|
|
}
|
|
|
|
const response = await context.request('GET',
|
|
`/api/platform/v1/pallets?page=1&size=100&tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`)
|
|
const page = context.apiSuccess(MODULE, '查询当前仓库已有可复用托盘', response,
|
|
`warehouseId=${warehouseId}`, OPTIONS)
|
|
const candidates = (page?.records || []).filter((item) => asId(item?.warehouseId) === cacheKey
|
|
&& asId(item?.id) && String(item?.palletCode || '').trim())
|
|
context.assert(MODULE, '当前仓库至少存在一个可复用托盘', response, candidates.length > 0,
|
|
`warehouseId=${warehouseId}, matched=${candidates.map((item) => item.palletCode).join('|') || 'missing'}`, OPTIONS)
|
|
if (candidates.length === 0) return null
|
|
const selected = candidates.slice(0, 2).map((item) => ({ id: asId(item.id), code: String(item.palletCode) }))
|
|
reusablePalletsByWarehouse.set(scopedCacheKey, selected)
|
|
return selected
|
|
}
|
|
|
|
function randomlyReusePalletIds(pallets, palletCount, randomValue) {
|
|
const seed = Number.isInteger(Number(randomValue)) ? Number(randomValue) : Date.now()
|
|
return Array.from({ length: palletCount }, (_, index) =>
|
|
pallets[(seed >>> (index % 31)) % pallets.length].id)
|
|
}
|
|
|
|
async function waitForCompletedMeasurement(context, measurementPath, expectedStatuses = ['WAIT_SPLIT'], attempts = 30) {
|
|
let response = null
|
|
let measurement = null
|
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
response = await context.request('GET', measurementPath)
|
|
measurement = response?.http === 200 && String(response?.body?.code) === '200'
|
|
? response.body.data : null
|
|
if (expectedStatuses.includes(measurement?.processStatus)) return { response, measurement, attempts: attempt }
|
|
if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, 1_000))
|
|
}
|
|
return { response, measurement, attempts }
|
|
}
|
|
|
|
export async function completeDropshipPalletization(context, { inboundId, inboundCode, warehouseId, measurement,
|
|
targetPalletNo = null, deferSeal = false, mergeToFirstPallet = false }) {
|
|
const pendingResponse = await context.request('GET',
|
|
`/api/platform/v1/first-leg/pda-dropship/tasks/pending?warehouseId=${warehouseId}&keyword=${encodeURIComponent(inboundCode)}`)
|
|
const pending = context.apiSuccess(MODULE, '查询一件代发待合托任务', pendingResponse,
|
|
`inboundId=${inboundId}`, OPTIONS)
|
|
const task = (pending || []).find((item) => asId(item?.inboundId) === inboundId
|
|
&& asId(item?.warehouseId) === warehouseId)
|
|
context.assert(MODULE, '测量审核后生成当前入仓单唯一合托任务', pendingResponse,
|
|
Boolean(asId(task?.taskId)) && Number(task?.totalCartons) === Number(measurement?.actualPackageCount),
|
|
`taskId=${asId(task?.taskId) || 'missing'}, totalCartons=${task?.totalCartons ?? 'missing'}`, OPTIONS)
|
|
if (!task?.taskId) return null
|
|
|
|
const taskId = asId(task.taskId)
|
|
const claimResponse = await context.request('POST',
|
|
`/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/claim`, { warehouseId })
|
|
const claimed = context.apiSuccess(MODULE, '领取一件代发合托任务', claimResponse,
|
|
`taskId=${taskId}`, OPTIONS)
|
|
context.assert(MODULE, '合托任务绑定当前操作员且进入CLAIMED', claimResponse,
|
|
claimed?.taskStatus === 'CLAIMED' && asId(claimed?.inboundId) === inboundId,
|
|
`taskStatus=${claimed?.taskStatus ?? 'missing'}`, OPTIONS)
|
|
|
|
const totalCartons = Number(measurement?.actualPackageCount)
|
|
const expectedPalletCount = Number(measurement?.expectedPalletCount)
|
|
const taskPath = `/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}?warehouseId=${warehouseId}`
|
|
if (!claimed?.tenantId || claimed.taskStatus !== 'CLAIMED') return null
|
|
// Task.pallets contains pallets with actual bound cartons, not unused pre-generated pallets.
|
|
const palletResponse = await context.request('GET',
|
|
`/api/platform/v1/first-leg/pda-dropship/inbound-orders/${inboundId}/pallets?accountId=${asId(claimed.tenantId)}`)
|
|
const palletData = context.apiSuccess(MODULE, '读取本轮入仓单预生成托盘', palletResponse, '', OPTIONS)
|
|
const generatedPallets = Array.isArray(palletData) ? palletData : []
|
|
const palletSetupValid = Number(claimed.generatedPalletCount) === expectedPalletCount
|
|
&& claimed.palletSetupReady === true && generatedPallets.length === expectedPalletCount
|
|
&& generatedPallets.every((item) =>
|
|
item?.status === 'OPEN' && Number(item?.cartonCount) === 0 && /^DP-\d{6}-\d{4}$/.test(item?.palletNo || ''))
|
|
context.assert(MODULE, '审核通过时已按预计托数预生成全部DP托盘码', palletResponse,
|
|
palletSetupValid,
|
|
`expected=${expectedPalletCount}, generated=${generatedPallets.length}`, OPTIONS)
|
|
if (!palletSetupValid) return null
|
|
|
|
let countedCartons = 0
|
|
const sealedCartonMarks = []
|
|
const sealedSkuSources = {}
|
|
|
|
for (let index = 0; index < totalCartons; index += 1) {
|
|
const boxCode = `${inboundCode}#${totalCartons}-${index + 1}`
|
|
const resolveResponse = await context.request('GET',
|
|
`/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/cartons/resolve?warehouseId=${warehouseId}&boxCode=${encodeURIComponent(boxCode)}`)
|
|
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
|
|
&& Number(line?.expectedQuantity) > 0),
|
|
`canCount=${resolved?.canCount ?? 'missing'}, skuLines=${resolvedSkuLines.length}`, OPTIONS)
|
|
if (resolved?.canCount !== true || resolvedSkuLines.length < 1) continue
|
|
|
|
const countPayload = {
|
|
warehouseId,
|
|
boxCode,
|
|
skuLines: resolvedSkuLines.map((line) => ({
|
|
inboundDetailId: asId(line.inboundDetailId),
|
|
skuCode: line.skuCode,
|
|
actualQuantity: Number(line.expectedQuantity),
|
|
discrepancyAction: 'MATCH',
|
|
discrepancyRemark: null,
|
|
})),
|
|
palletNo: index === 0 ? null : (targetPalletNo || generatedPallets[Math.min(index, generatedPallets.length - 1)].palletNo),
|
|
operationId: `${context.marker}-dropship-count-${index + 1}`.slice(0, 128),
|
|
}
|
|
const countResponse = await context.request('POST',
|
|
`/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/cartons/count`, countPayload)
|
|
const counted = context.apiSuccess(MODULE, `清点第${index + 1}箱并冻结实收SKU数`, countResponse,
|
|
`boxCode=${boxCode}`, OPTIONS)
|
|
context.assert(MODULE, `第${index + 1}箱清点结果与冻结箱唛一致`, countResponse,
|
|
counted?.carton?.boxCode === resolved?.boxCode
|
|
&& Array.isArray(counted?.carton?.skuLines)
|
|
&& counted.carton.skuLines.length === resolvedSkuLines.length
|
|
&& counted.carton.skuLines.every((line) => Number(line?.actualQuantity) === Number(line?.expectedQuantity)
|
|
&& line?.discrepancyStatus === 'MATCH'),
|
|
`cartonId=${asId(counted?.carton?.cartonId) || 'missing'}, skuLines=${counted?.carton?.skuLines?.length ?? 'missing'}`, OPTIONS)
|
|
if (counted?.carton?.boxCode === resolved?.boxCode) {
|
|
countedCartons += 1
|
|
sealedCartonMarks.push(resolved.boxCode)
|
|
}
|
|
|
|
if (index === 0) {
|
|
context.assert(MODULE, '先扫箱模式将已清点箱暂存为待组托', countResponse,
|
|
Number(counted?.unboundCartons) === 1 && counted?.pallet == null,
|
|
`unboundCartons=${counted?.unboundCartons ?? 'missing'}`, OPTIONS)
|
|
const replayResponse = await context.request('POST',
|
|
`/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/cartons/count`, countPayload)
|
|
const replayed = context.apiSuccess(MODULE, '重复清点操作按operationId幂等返回', replayResponse,
|
|
`boxCode=${boxCode}`, OPTIONS)
|
|
context.assert(MODULE, '重复清点没有重复累计箱数', replayResponse,
|
|
Number(replayed?.countedCartons) === Number(counted?.countedCartons)
|
|
&& Number(replayed?.unboundCartons) === Number(counted?.unboundCartons),
|
|
`counted=${replayed?.countedCartons ?? 'missing'}, unbound=${replayed?.unboundCartons ?? 'missing'}`, OPTIONS)
|
|
|
|
const firstPallet = targetPalletNo ? { palletNo: targetPalletNo } : generatedPallets[0]
|
|
const bindPayload = { warehouseId, palletNo: firstPallet.palletNo,
|
|
operationId: `${context.marker}-dropship-bind-first`.slice(0, 128) }
|
|
const bindResponse = await context.request('POST',
|
|
`/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/pallets/bind-pending`, bindPayload)
|
|
const bound = context.apiSuccess(MODULE, '后扫预生成托盘码并绑定全部待组托箱', bindResponse,
|
|
`taskId=${taskId}`, OPTIONS)
|
|
const pallet = bound?.pallets?.find((item) => item.palletNo === firstPallet.palletNo) || null
|
|
context.assert(MODULE, '待组托箱绑定到OPEN托盘且不再游离', bindResponse,
|
|
Number(bound?.unboundCartons) === 0
|
|
&& (targetPalletNo
|
|
? pallet?.cartons?.filter((item) => asId(item.inboundId) === inboundId).length === 1
|
|
: Number(pallet?.cartonCount) === 1)
|
|
&& pallet?.status === 'OPEN' && pallet?.palletNo === firstPallet.palletNo
|
|
&& bound?.pallets?.length === 1,
|
|
`unbound=${bound?.unboundCartons ?? 'missing'}, palletStatus=${pallet?.status ?? 'missing'}`, OPTIONS)
|
|
const replayBindResponse = await context.request('POST',
|
|
`/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/pallets/bind-pending`, bindPayload)
|
|
const replayBind = context.apiSuccess(MODULE, '预生成托盘绑定同键重放', replayBindResponse, '', OPTIONS)
|
|
context.assert(MODULE, '预生成托盘绑定重放不新增托盘且计数稳定', replayBindResponse,
|
|
replayBind?.pallets?.length === bound?.pallets?.length
|
|
&& Number(replayBind?.boundCartons) === Number(bound?.boundCartons)
|
|
&& Number(replayBind?.unboundCartons) === 0, '', OPTIONS)
|
|
} else {
|
|
const expectedPallet = targetPalletNo ? { palletNo: targetPalletNo }
|
|
: generatedPallets[Math.min(index, generatedPallets.length - 1)]
|
|
context.assert(MODULE, `第${index + 1}箱直接进入当前OPEN托盘`, countResponse,
|
|
counted?.pallet?.palletNo === expectedPallet.palletNo && counted?.pallet?.status === 'OPEN',
|
|
`palletNo=${counted?.pallet?.palletNo ?? 'missing'}, status=${counted?.pallet?.status ?? 'missing'}`, OPTIONS)
|
|
}
|
|
}
|
|
const countedTaskResponse = await context.request('GET', taskPath)
|
|
let sealed = context.apiSuccess(MODULE, '清点完成后回查预生成托盘分配', countedTaskResponse, '', OPTIONS)
|
|
context.assert(MODULE, '预生成托盘覆盖测量冻结的全部箱数', countedTaskResponse,
|
|
totalCartons > 0 && countedCartons === totalCartons
|
|
&& Number(sealed?.boundCartons) === totalCartons
|
|
&& sealed?.pallets?.length === (targetPalletNo ? 1 : expectedPalletCount)
|
|
&& sealed?.pallets?.every((item) => Number(item?.cartonCount) > 0),
|
|
`counted=${countedCartons}, bound=${sealed?.boundCartons ?? 'missing'}, expected=${totalCartons || 'missing'}`, OPTIONS)
|
|
|
|
if (mergeToFirstPallet) {
|
|
const target = sealed?.pallets?.[0]
|
|
const sources = (sealed?.pallets || []).slice(1)
|
|
context.assert(MODULE, '单票合托前确有多个实物托盘', countedTaskResponse,
|
|
Boolean(target) && sources.length > 0, '', OPTIONS)
|
|
if (!target || sources.length === 0) return null
|
|
for (const source of sources) {
|
|
for (const carton of source.cartons || []) {
|
|
const payload = { warehouseId, targetPalletNo: target.palletNo,
|
|
operationId: `${context.marker}-merge-${carton.cartonId}`.slice(0, 128) }
|
|
const path = `/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/cartons/${carton.cartonId}/move`
|
|
const movedResponse = await context.request('POST', path, payload)
|
|
sealed = context.apiSuccess(MODULE, '通过正式移箱接口合并单票托盘', movedResponse, '', OPTIONS)
|
|
const replayResponse = await context.request('POST', path, payload)
|
|
const replay = context.apiSuccess(MODULE, '单票合托移箱同键重放', replayResponse, '', OPTIONS)
|
|
context.assert(MODULE, '移箱重放保持总箱数守恒', replayResponse,
|
|
Number(replay?.boundCartons) === totalCartons && Number(replay?.unboundCartons) === 0, '', OPTIONS)
|
|
}
|
|
}
|
|
context.assert(MODULE, '单票合托后仅一个实物托盘且箱唛不丢失', null,
|
|
sealed?.pallets?.length === 1 && sealed.pallets[0].palletNo === target.palletNo
|
|
&& sealed.pallets[0].cartons?.length === totalCartons
|
|
&& new Set(sealed.pallets[0].cartons.map((item) => item.boxCode)).size === totalCartons,
|
|
'', OPTIONS)
|
|
}
|
|
if (deferSeal) {
|
|
context.assert(MODULE, '多票合托首票归托后保持开放等待下一票', null,
|
|
sealed?.taskStatus !== 'COMPLETED' && sealed?.pallets?.every((item) => item.status === 'OPEN'), '', OPTIONS)
|
|
return { ...sealed, sealedCartonMarks, sealedSkuSources, sealedSourcePalletNos: [] }
|
|
}
|
|
let lastSealResponse = countedTaskResponse
|
|
for (let index = 0; index < (sealed?.pallets || []).length; index += 1) {
|
|
const pallet = sealed.pallets.find((item) => item.status === 'OPEN')
|
|
if (!pallet) break
|
|
const sealResponse = await context.request('POST',
|
|
`/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/pallets/${asId(pallet.palletId)}/seal`, {
|
|
warehouseId, expectedVersion: Number(pallet.version),
|
|
operationId: `${context.marker}-dropship-seal-${index + 1}`.slice(0, 128),
|
|
})
|
|
lastSealResponse = sealResponse
|
|
sealed = context.apiSuccess(MODULE, `封托第${index + 1}个预生成托盘`, sealResponse,
|
|
`taskId=${taskId}, palletNo=${pallet.palletNo}`, OPTIONS)
|
|
}
|
|
context.assert(MODULE, '全部箱数封托后入仓单允许进入WAIT_SPLIT', lastSealResponse,
|
|
sealed?.taskStatus === 'COMPLETED'
|
|
&& Number(sealed?.countedCartons) === Number(sealed?.totalCartons)
|
|
&& Number(sealed?.boundCartons) === Number(sealed?.totalCartons)
|
|
&& 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, sealedSkuSources,
|
|
sealedSourcePalletNos: (sealed?.pallets || []).map((item) => item.palletNo).filter(Boolean) }
|
|
}
|
|
|
|
async function provisionWarehouseResources(context, { inboundId, tenantId, warehouseId, palletCount }) {
|
|
if (!warehouseId || !Number.isInteger(palletCount) || palletCount < 1) return null
|
|
|
|
const resources = []
|
|
const candidates = await reusablePallets(context, warehouseId)
|
|
if (!candidates) return null
|
|
const palletIds = randomlyReusePalletIds(candidates, palletCount,
|
|
context.context?.waitSplitVariant?.randomValue)
|
|
const candidateKeyword = candidates.find((item) => item.id === palletIds[0])?.code || ''
|
|
const candidateResponse = await context.request('GET',
|
|
`/api/platform/v1/first-leg/inbounds/${inboundId}/weight-pallet-options?${query(tenantId)}&page=1&size=20&keyword=${encodeURIComponent(candidateKeyword)}`)
|
|
const candidatePage = context.apiSuccess(MODULE, '按关键词分页查询当前入库仓称重托盘候选', candidateResponse,
|
|
`warehouseId=${warehouseId}, keyword=${candidateKeyword}`, OPTIONS)
|
|
const pageControlled = Array.isArray(candidatePage?.records) && candidatePage.records.length <= 20
|
|
&& Number(candidatePage?.page) === 1 && Number(candidatePage?.size) === 20
|
|
&& Number(candidatePage?.total) >= candidatePage.records.length
|
|
&& candidatePage.records.some((item) => asId(item?.palletId) === palletIds[0])
|
|
context.assert(MODULE, '托盘候选保持服务端关键词分页且不返回无界数组', candidateResponse,
|
|
pageControlled,
|
|
`records=${candidatePage?.records?.length ?? 'missing'}, page=${candidatePage?.page ?? 'missing'}, size=${candidatePage?.size ?? 'missing'}, total=${candidatePage?.total ?? 'missing'}`,
|
|
OPTIONS)
|
|
if (!pageControlled) return null
|
|
for (let index = 1; index <= palletCount; index += 1) {
|
|
const palletId = palletIds[index - 1]
|
|
context.assert(MODULE, `随机复用原有称重托盘 ${index}/${palletCount}`, null,
|
|
Boolean(candidates.find((item) => item.id === palletId)?.code),
|
|
`warehouseId=${warehouseId}, palletId=${palletId}`, OPTIONS)
|
|
// 库位由 Warehouse 在测量审核时按正式规则分配;普通位不足会复用唯一的 A0 共享溢出位。
|
|
// 验收脚本不得为了制造容量而新增普通库位。
|
|
resources.push({ palletId, locationId: null })
|
|
}
|
|
|
|
context.createdResources.inboundWarehouseResources ??= {}
|
|
context.createdResources.inboundWarehouseResources[inboundId] = {
|
|
marker: context.marker, tenantId: '0', warehouseId: asId(warehouseId), palletCount,
|
|
reusablePalletCodes: candidates.map((item) => item.code), resources,
|
|
}
|
|
return { palletId: palletIds[0], palletIds, palletCount, resources }
|
|
}
|
|
|
|
async function createConfirmedWeightEvidence(context, {
|
|
inboundId, tenantId, palletIds, grossWeightPerPalletKg,
|
|
}) {
|
|
if (!Array.isArray(palletIds) || palletIds.length < 1) return null
|
|
const confirmedResults = []
|
|
const guideEvolution = []
|
|
for (let index = 0; index < palletIds.length; index += 1) {
|
|
const palletId = palletIds[index]
|
|
const uploadResponse = await context.requestMultipart('POST',
|
|
`/api/platform/v1/files/measurements/${tenantId}/${inboundId}`, {
|
|
content: WEIGHT_IMAGE, type: 'image/png', name: `weight-${inboundId}-${index + 1}.png`,
|
|
})
|
|
const upload = context.apiSuccess(MODULE, `上传称重图片并执行 OCR ${index + 1}/${palletIds.length}`,
|
|
uploadResponse, `inboundId=${inboundId}`, OPTIONS)
|
|
const fileId = asId(upload?.fileId)
|
|
if (!fileId) return null
|
|
|
|
const createResponse = await context.request('POST',
|
|
`/api/platform/v1/first-leg/inbounds/${inboundId}/weight-results?${query(tenantId)}`, {
|
|
fileId, palletId, ocrProvider: 'ADMIN_ACCEPTANCE', ocrRawText: null,
|
|
ocrWeightValue: null, ocrWeightUnit: null, confidence: null,
|
|
})
|
|
const created = context.apiSuccess(MODULE, `登记称重图片与托盘快照 ${index + 1}/${palletIds.length}`,
|
|
createResponse, `fileId=${fileId}, palletId=${palletId}`, OPTIONS)
|
|
if (!created?.id) return null
|
|
context.assert(MODULE, `OCR无有效重量时转人工确认 ${index + 1}/${palletIds.length}`, createResponse,
|
|
created.recognitionStatus === 'NEEDS_MANUAL' && created.ocrWeightValue == null,
|
|
`recognitionStatus=${created.recognitionStatus || 'missing'}, ocrWeightValue=${created.ocrWeightValue ?? 'null'}`,
|
|
OPTIONS)
|
|
|
|
const grossWeight = Number(grossWeightPerPalletKg) + index * 5
|
|
const confirmResponse = await context.request('POST',
|
|
`/api/platform/v1/first-leg/inbounds/${inboundId}/weight-results/${created.id}/confirm?${query(tenantId)}`, {
|
|
version: Number(created.version), adjustedWeightValue: grossWeight,
|
|
adjustedWeightUnit: 'KG', adjustmentReason: `${context.marker} 自动化人工确认称重`,
|
|
})
|
|
const confirmed = context.apiSuccess(MODULE, `人工确认称重图片净重 ${index + 1}/${palletIds.length}`,
|
|
confirmResponse, `resultId=${created.id}, palletId=${palletId}`, OPTIONS)
|
|
const netWeightKg = Number(confirmed?.netWeightKg)
|
|
const grossWeightKg = Number(confirmed?.grossWeightKg)
|
|
if (confirmed?.recognitionStatus !== 'CONFIRMED' || !Number.isFinite(netWeightKg) || netWeightKg <= 0) {
|
|
return null
|
|
}
|
|
const staleConfirmResponse = await context.request('POST',
|
|
`/api/platform/v1/first-leg/inbounds/${inboundId}/weight-results/${created.id}/confirm?${query(tenantId)}`, {
|
|
version: Number(created.version), adjustedWeightValue: grossWeight + 1,
|
|
adjustedWeightUnit: 'KG', adjustmentReason: `${context.marker} 旧版本冲突验证`,
|
|
})
|
|
context.assert(MODULE, `称重旧版本确认被拒绝且不覆盖已确认结果 ${index + 1}/${palletIds.length}`,
|
|
staleConfirmResponse,
|
|
staleConfirmResponse.http === 200 && String(staleConfirmResponse.body?.code) === 'A0409',
|
|
`HTTP=${staleConfirmResponse.http}, code=${staleConfirmResponse.body?.code ?? 'missing'}, requestId=${staleConfirmResponse.requestId || 'missing'}`,
|
|
OPTIONS)
|
|
confirmedResults.push({
|
|
id: asId(created.id), palletId, netWeightKg, grossWeightKg,
|
|
version: Number(confirmed?.version),
|
|
})
|
|
|
|
if (context.options?.verifyPalletLocationGuide) {
|
|
const guideResponse = await context.request('GET',
|
|
`/api/platform/v1/first-leg/inbounds/${inboundId}/measurement?${query(tenantId)}`)
|
|
const guideWorkspace = context.apiSuccess(MODULE,
|
|
`逐张确认后回查整票库位块 ${index + 1}/${palletIds.length}`, guideResponse,
|
|
`resultId=${asId(created.id)}, confirmedCount=${confirmedResults.length}`, OPTIONS)
|
|
const verified = verifyPalletLocationGuide(context, guideResponse, guideWorkspace,
|
|
confirmedResults.map((result) => result.id), `第${index + 1}张确认后`)
|
|
const previous = guideEvolution.at(-1)
|
|
if (previous) {
|
|
const priorSlotsStable = previous.slots.every((prior) => {
|
|
const current = verified.slots.find((slot) => Number(slot?.slotNo) === Number(prior?.slotNo))
|
|
return current
|
|
&& asId(current.reservationId) === asId(prior.reservationId)
|
|
&& asId(current.locationId) === asId(prior.locationId)
|
|
&& String(current.locationCode || '') === String(prior.locationCode || '')
|
|
&& Boolean(current.sharedOverflow) === Boolean(prior.sharedOverflow)
|
|
})
|
|
context.assert(MODULE, `第${index + 1}张确认后扩容只追加且既有槽身份不漂移`, guideResponse,
|
|
asId(verified.guide?.blockId) === previous.blockId
|
|
&& verified.slots.length >= previous.slots.length && priorSlotsStable,
|
|
`beforeBlock=${previous.blockId || 'missing'}, afterBlock=${asId(verified.guide?.blockId) || 'missing'}, beforeSlots=${previous.slots.length}, afterSlots=${verified.slots.length}`,
|
|
OPTIONS)
|
|
}
|
|
guideEvolution.push({
|
|
blockId: asId(verified.guide?.blockId),
|
|
slots: verified.slots.map((slot) => ({
|
|
slotNo: Number(slot?.slotNo), reservationId: asId(slot?.reservationId),
|
|
locationId: asId(slot?.locationId), locationCode: String(slot?.locationCode || ''),
|
|
sharedOverflow: Boolean(slot?.sharedOverflow),
|
|
})),
|
|
})
|
|
}
|
|
}
|
|
|
|
const workspaceResponse = await context.request('GET',
|
|
`/api/platform/v1/first-leg/inbounds/${inboundId}/measurement?${query(tenantId)}`)
|
|
const workspace = context.apiSuccess(MODULE, '回查已确认称重图片和货物总重量', workspaceResponse,
|
|
`resultIds=${confirmedResults.map((result) => result.id).join(',')}`, OPTIONS)
|
|
const confirmedRows = (workspace?.weightResults || [])
|
|
.filter((result) => result?.recognitionStatus === 'CONFIRMED')
|
|
const grossWeightKg = confirmedResults.reduce((sum, result) => sum + result.grossWeightKg, 0)
|
|
const netWeightKg = confirmedResults.reduce((sum, result) => sum + result.netWeightKg, 0)
|
|
const persisted = confirmedRows.length === palletIds.length
|
|
&& confirmedResults.every((expected) => confirmedRows.some((row) =>
|
|
asId(row?.id) === expected.id && asId(row?.palletId) === expected.palletId
|
|
&& String(row?.palletCode || '').trim() && String(row?.palletType || '').trim()
|
|
&& Number.isFinite(Number(row?.palletTareWeightKg))))
|
|
&& closeTo(workspace?.actualGrossWeightKg, grossWeightKg, 0.001)
|
|
&& closeTo(workspace?.actualNetWeightKg, netWeightKg, 0.001)
|
|
context.assert(MODULE, '图片张数决定托数且净重汇总已落地', workspaceResponse, persisted,
|
|
`confirmedImages=${confirmedRows.length}, expectedPallets=${palletIds.length}, grossWeight=${grossWeightKg}, netWeight=${netWeightKg}, actualGross=${workspace?.actualGrossWeightKg ?? 'missing'}, actualNet=${workspace?.actualNetWeightKg ?? 'missing'}`,
|
|
OPTIONS)
|
|
return persisted
|
|
? { count: confirmedRows.length, grossWeightKg, netWeightKg, confirmedResults, guideEvolution }
|
|
: null
|
|
}
|
|
|
|
function verifyPalletLocationGuide(context, response, workspace, expectedWeightResultIds, name) {
|
|
const guide = workspace?.palletLocationGuide
|
|
const slots = Array.isArray(guide?.slots) ? guide.slots : []
|
|
const weights = Array.isArray(workspace?.weightResults) ? workspace.weightResults : []
|
|
const boundSlots = slots.filter((slot) => asId(slot?.boundWeightResultId))
|
|
const emptySlots = slots.filter((slot) => slot?.slotStatus === 'RESERVED_EMPTY')
|
|
const identityComplete = asId(guide?.blockId)
|
|
&& slots.length === Number(guide?.reservedSlotCount)
|
|
&& Number(guide?.actualPalletCount) === boundSlots.length
|
|
&& Number(guide?.reservedEmptySlotCount) === emptySlots.length
|
|
&& slots.every((slot) => Number.isInteger(Number(slot?.slotNo)) && Number(slot.slotNo) > 0
|
|
&& asId(slot?.reservationId) && asId(slot?.locationId)
|
|
&& String(slot?.locationCode || '').trim() && typeof slot?.sharedOverflow === 'boolean'
|
|
&& ['RESERVED_EMPTY', 'BOUND', 'TRANSFERRED', 'RELEASED'].includes(String(slot?.slotStatus || '')))
|
|
context.assert(MODULE, `${name}整票库位块计数与槽身份完整`, response, Boolean(identityComplete),
|
|
`blockId=${asId(guide?.blockId) || 'missing'}, reserved=${guide?.reservedSlotCount ?? 'missing'}, actual=${guide?.actualPalletCount ?? 'missing'}, empty=${guide?.reservedEmptySlotCount ?? 'missing'}, slots=${slots.length}`,
|
|
OPTIONS)
|
|
|
|
const bindingsStable = expectedWeightResultIds.every((resultId) => {
|
|
const row = weights.find((item) => asId(item?.id) === resultId)
|
|
const slot = slots.find((item) => asId(item?.boundWeightResultId) === resultId)
|
|
return row && slot && asId(row.blockId) === asId(guide?.blockId)
|
|
&& Number(row.slotNo) === Number(slot.slotNo)
|
|
&& asId(row.reservationId) === asId(slot.reservationId)
|
|
&& asId(row.locationId) === asId(slot.locationId)
|
|
&& String(row.locationCode || '') === String(slot.locationCode || '')
|
|
&& Boolean(row.sharedOverflow) === Boolean(slot.sharedOverflow)
|
|
})
|
|
context.assert(MODULE, `${name}称重图片按 resultId 显式绑定槽位`, response, bindingsStable,
|
|
`expectedResultIds=${expectedWeightResultIds.join('|') || 'none'}, boundResultIds=${boundSlots.map((slot) => asId(slot.boundWeightResultId)).join('|') || 'none'}`,
|
|
OPTIONS)
|
|
return { guide, slots, weights, identityComplete: Boolean(identityComplete), bindingsStable }
|
|
}
|
|
|
|
async function recreateConfirmedWeightEvidence(context, {
|
|
inboundId, tenantId, palletId, grossWeightKg,
|
|
}) {
|
|
const uploadResponse = await context.requestMultipart('POST',
|
|
`/api/platform/v1/files/measurements/${tenantId}/${inboundId}`, {
|
|
content: WEIGHT_IMAGE, type: 'image/png', name: `weight-${inboundId}-replacement.png`,
|
|
})
|
|
const upload = context.apiSuccess(MODULE, '重新上传替代称重图片', uploadResponse,
|
|
`inboundId=${inboundId}`, OPTIONS)
|
|
const fileId = asId(upload?.fileId)
|
|
if (!fileId) return null
|
|
|
|
const createResponse = await context.request('POST',
|
|
`/api/platform/v1/first-leg/inbounds/${inboundId}/weight-results?${query(tenantId)}`, {
|
|
fileId, palletId, ocrProvider: 'ADMIN_ACCEPTANCE', ocrRawText: null,
|
|
ocrWeightValue: null, ocrWeightUnit: null, confidence: null,
|
|
})
|
|
const created = context.apiSuccess(MODULE, '登记替代称重图片与托盘快照', createResponse,
|
|
`fileId=${fileId}, palletId=${palletId}`, OPTIONS)
|
|
if (!created?.id) return null
|
|
|
|
const confirmResponse = await context.request('POST',
|
|
`/api/platform/v1/first-leg/inbounds/${inboundId}/weight-results/${created.id}/confirm?${query(tenantId)}`, {
|
|
version: Number(created.version), adjustedWeightValue: Number(grossWeightKg),
|
|
adjustedWeightUnit: 'KG', adjustmentReason: `${context.marker} 删除后替代称重确认`,
|
|
})
|
|
const confirmed = context.apiSuccess(MODULE, '确认替代称重图片并复用空槽', confirmResponse,
|
|
`resultId=${asId(created.id)}, palletId=${palletId}`, OPTIONS)
|
|
const valid = confirmed?.recognitionStatus === 'CONFIRMED'
|
|
&& Number.isFinite(Number(confirmed?.grossWeightKg))
|
|
&& Number.isFinite(Number(confirmed?.netWeightKg))
|
|
context.assert(MODULE, '替代图片形成新的已确认称重结果', confirmResponse, valid,
|
|
`resultId=${asId(confirmed?.id) || 'missing'}, status=${confirmed?.recognitionStatus || 'missing'}`,
|
|
OPTIONS)
|
|
return valid ? {
|
|
id: asId(confirmed.id), palletId: asId(confirmed.palletId),
|
|
grossWeightKg: Number(confirmed.grossWeightKg), netWeightKg: Number(confirmed.netWeightKg),
|
|
version: Number(confirmed.version),
|
|
} : null
|
|
}
|
|
|
|
function findIsolatedInbound(context) {
|
|
const roots = [context.createdResources, context.context]
|
|
const visited = new Set()
|
|
const candidates = []
|
|
|
|
function visit(value, inheritedMarker = '') {
|
|
if (!value || typeof value !== 'object' || visited.has(value)) return
|
|
visited.add(value)
|
|
const marker = String(value.marker || value.testMarker || inheritedMarker || '')
|
|
const inboundId = asId(value.inboundId || value.inbound?.id || value.inbound?.inboundId)
|
|
const tenantId = asId(value.tenantId || value.customerId || value.inbound?.tenantId)
|
|
if (marker === context.marker && inboundId && tenantId) {
|
|
candidates.push({ inboundId, tenantId, marker, source: value })
|
|
}
|
|
for (const [key, child] of Object.entries(value)) {
|
|
if (key === 'temporaryPassword' || key === 'token') continue
|
|
if (child && typeof child === 'object') visit(child, marker)
|
|
}
|
|
}
|
|
|
|
roots.forEach((root) => visit(root))
|
|
return candidates[0] || null
|
|
}
|
|
|
|
async function multipartRequest(context, path, data) {
|
|
let lastResponse = null
|
|
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
const form = new FormData()
|
|
form.append('data', new Blob([JSON.stringify(data)], { type: 'application/json' }))
|
|
form.append('image', new Blob([WEIGHT_IMAGE], { type: 'image/png' }), 'carton-evidence.png')
|
|
try {
|
|
const response = await fetch(`${context.options.gateway}${path}`, {
|
|
method: 'POST',
|
|
headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` },
|
|
body: form,
|
|
signal: AbortSignal.timeout(60_000),
|
|
})
|
|
const content = await response.text()
|
|
let body = null
|
|
try { body = content ? JSON.parse(content) : null } catch { /* Preserve only a bounded error string. */ }
|
|
lastResponse = {
|
|
method: 'POST', path, http: response.status, body,
|
|
raw: body === null ? content.slice(0, 500) : null,
|
|
requestId: body?.requestId || response.headers.get('x-request-id'),
|
|
traceId: body?.traceId || response.headers.get('x-trace-id'),
|
|
}
|
|
if (lastResponse.http !== 0 || attempt === 3) return lastResponse
|
|
} catch (error) {
|
|
lastResponse = { method: 'POST', path, http: 0, body: null,
|
|
raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null }
|
|
if (attempt === 3) return lastResponse
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 400 * attempt))
|
|
}
|
|
return lastResponse
|
|
}
|
|
|
|
async function requestWithHeaders(context, method, path, body, extraHeaders) {
|
|
let lastResponse = null
|
|
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
try {
|
|
const response = await fetch(`${context.options.gateway}${path}`, {
|
|
method,
|
|
headers: {
|
|
Accept: 'application/json', Authorization: `Bearer ${context.token}`,
|
|
'Content-Type': 'application/json; charset=utf-8', ...extraHeaders,
|
|
},
|
|
body: JSON.stringify(body), signal: AbortSignal.timeout(30_000),
|
|
})
|
|
const content = await response.text()
|
|
let payload = null
|
|
try { payload = content ? JSON.parse(content) : null } catch { /* Preserve only a bounded error string. */ }
|
|
lastResponse = {
|
|
method, path, http: response.status, body: payload,
|
|
raw: payload === null ? content.slice(0, 500) : null,
|
|
requestId: payload?.requestId || response.headers.get('x-request-id'),
|
|
traceId: payload?.traceId || response.headers.get('x-trace-id'),
|
|
}
|
|
if (lastResponse.http !== 0 || attempt === 3) return lastResponse
|
|
} catch (error) {
|
|
lastResponse = { method, path, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null }
|
|
if (attempt === 3) return lastResponse
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 400 * attempt))
|
|
}
|
|
return lastResponse
|
|
}
|
|
|
|
async function updateConfirmedPackageCount(context, measurementPath, itemId, body) {
|
|
const updatePath = measurementPath.replace(/\?.*$/, '')
|
|
+ `/items/${encodeURIComponent(itemId)}/confirmed-package-count?${measurementPath.split('?')[1] || ''}`
|
|
let lastResponse = null
|
|
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
|
lastResponse = await context.request('PUT', updatePath, body)
|
|
if (lastResponse?.http !== 0) return lastResponse
|
|
const recovered = await context.request('GET', measurementPath)
|
|
const detail = recovered?.http === 200 && String(recovered?.body?.code) === '200'
|
|
? recovered.body?.data : null
|
|
const item = detail?.items?.find((candidate) => asId(candidate?.id) === asId(itemId))
|
|
if (Number(item?.confirmedPackageCount) === Number(body.confirmedPackageCount)
|
|
&& Number(detail?.version) > Number(body.measurementVersion)) {
|
|
return {
|
|
...recovered, method: 'GET', path: measurementPath, recoveredFromTransportFailure: true,
|
|
body: { ...recovered.body, data: { ...item, measurementVersion: Number(detail.version) } },
|
|
}
|
|
}
|
|
if (attempt < 2) await new Promise((resolve) => setTimeout(resolve, 500))
|
|
}
|
|
return lastResponse
|
|
}
|
|
|
|
function finishScenario(context, start) {
|
|
const steps = context.steps.slice(start)
|
|
context.scenarios.push({
|
|
name: SCENARIO, module: MODULE, success: steps.every((step) => step.passed),
|
|
deploymentBlocked: steps.some((step) => step.deploymentBlocker),
|
|
passed: steps.filter((step) => step.passed).length,
|
|
failed: steps.filter((step) => !step.passed).length,
|
|
})
|
|
}
|
|
|
|
export async function run(context) {
|
|
const start = context.steps.length
|
|
const isolated = findIsolatedInbound(context)
|
|
if (!isolated) {
|
|
context.addStep(MODULE, '取得隔离的待测量入库单', null, false,
|
|
`orders 模块未提供 marker=${context.marker} 的 inboundId/tenantId;为避免修改随机历史订单,本模块停止写入`, OPTIONS)
|
|
finishScenario(context, start)
|
|
return
|
|
}
|
|
|
|
const { inboundId, tenantId } = isolated
|
|
const detailPath = `/api/platform/inbounds/${inboundId}?${query(tenantId)}`
|
|
const inboundResponse = await context.request('GET', detailPath)
|
|
const inboundDetail = context.apiSuccess(MODULE, '查询隔离入库单详情', inboundResponse,
|
|
`inboundId=${inboundId}, tenantId=${tenantId}`, OPTIONS)
|
|
const summary = inboundDetail?.summary
|
|
const provenanceOk = asId(summary?.id) === inboundId && asId(summary?.tenantId) === tenantId
|
|
&& ['WAIT_MEASURE', 'MEASURING'].includes(summary?.processStatus)
|
|
context.assert(MODULE, '隔离入库单身份与可测量状态正确', inboundResponse, provenanceOk,
|
|
`identity=${asId(summary?.id) === inboundId && asId(summary?.tenantId) === tenantId}, status=${summary?.processStatus ?? 'missing'}`, OPTIONS)
|
|
if (!provenanceOk) {
|
|
finishScenario(context, start)
|
|
return
|
|
}
|
|
|
|
const inboundCode = String(summary.inboundCode || '')
|
|
if (!inboundCode) {
|
|
context.assert(MODULE, '箱码生成前置数据完整', inboundResponse, false,
|
|
'inboundCode=missing', OPTIONS)
|
|
finishScenario(context, start)
|
|
return
|
|
}
|
|
|
|
const labelPath = `/api/platform/inbounds/${inboundId}/carton-label-pdf?${query(tenantId)}`
|
|
const labelResponse = await context.requestBinary('POST', labelPath)
|
|
const pdfOk = validPdf(labelResponse)
|
|
context.assert(MODULE, '生成箱唛 PDF 并冻结稳定箱码', labelResponse, pdfOk,
|
|
`contentType=${labelResponse.contentType || 'missing'}, bytes=${labelResponse.bytes?.length ?? 0}`, OPTIONS)
|
|
if (!pdfOk) {
|
|
finishScenario(context, start)
|
|
return
|
|
}
|
|
|
|
const frozenInboundResponse = await context.request('GET', detailPath)
|
|
const frozenInbound = context.apiSuccess(MODULE, '首次打印后回查冻结箱数', frozenInboundResponse, '', OPTIONS)
|
|
const frozenSummary = frozenInbound?.summary
|
|
const originalPackageCount = Number(frozenSummary?.totalPackageCount)
|
|
context.assert(MODULE, '首次打印按订单货物冻结原始总箱数', frozenInboundResponse,
|
|
frozenSummary?.inboundCode === inboundCode && Number.isInteger(originalPackageCount) && originalPackageCount >= 1,
|
|
`inboundCodeStable=${frozenSummary?.inboundCode === inboundCode}, totalPackageCount=${frozenSummary?.totalPackageCount ?? 'missing'}`, OPTIONS)
|
|
if (!Number.isInteger(originalPackageCount) || originalPackageCount < 1) {
|
|
finishScenario(context, start)
|
|
return
|
|
}
|
|
|
|
const boxCode = `${inboundCode}#${originalPackageCount}-1`
|
|
const reprintResponse = await context.requestBinary('POST', labelPath)
|
|
context.assert(MODULE, '重复整单打印复用已冻结箱码映射', reprintResponse, validPdf(reprintResponse),
|
|
`boxCode=${boxCode}, contentType=${reprintResponse.contentType || 'missing'}, bytes=${reprintResponse.bytes?.length ?? 0}`, OPTIONS)
|
|
|
|
const partialPrintPath = `${labelPath}&cartonIndex=1`
|
|
const partialPrintResponse = await context.requestBinary('POST', partialPrintPath)
|
|
context.assert(MODULE, '指定第1箱补打复用已冻结箱码映射', partialPrintResponse, validPdf(partialPrintResponse),
|
|
`boxCode=${boxCode}, cartonIndex=1, contentType=${partialPrintResponse.contentType || 'missing'}, bytes=${partialPrintResponse.bytes?.length ?? 0}`, OPTIONS)
|
|
if (!validPdf(reprintResponse) || !validPdf(partialPrintResponse)) {
|
|
finishScenario(context, start)
|
|
return
|
|
}
|
|
|
|
const scanPath = `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/carton-scans?${query(tenantId)}`
|
|
const measurementProfile = context.context?.waitSplitVariant?.measurementProfile
|
|
const firstDimensions = measurementProfile?.first || { length: 59.1, width: 40.2, height: 30.3 }
|
|
const firstShipmentId = `FBA-${context.marker.slice(-12)}-01`
|
|
const firstScanResponse = await multipartRequest(context, scanPath, {
|
|
boxCode, ...firstDimensions, scannedShipmentId: firstShipmentId,
|
|
machineNo: 'MJ-ADMIN-E2E-01', measuredAt: new Date().toISOString().slice(0, 19),
|
|
})
|
|
const firstScan = context.apiSuccess(MODULE, '模拟墨家首次扫描单箱', firstScanResponse,
|
|
`boxCode=${boxCode}, machineNo=MJ-ADMIN-E2E-01`, OPTIONS)
|
|
context.assert(MODULE, '首次扫描新增一条且默认代表1箱', firstScanResponse,
|
|
firstScan?.created === true && asId(firstScan?.boxCode) === boxCode
|
|
&& Number(firstScan?.confirmedPackageCount) === 1 && asId(firstScan?.itemId),
|
|
`created=${firstScan?.created}, confirmedPackageCount=${firstScan?.confirmedPackageCount ?? 'missing'}`, OPTIONS)
|
|
context.assert(MODULE, '重复打印及指定箱补打后原箱码仍可解析', firstScanResponse,
|
|
firstScan?.created === true && firstScan?.boxCode === boxCode,
|
|
`boxCodeStable=${firstScan?.boxCode === boxCode}, resolvedInboundId=${inboundId}`, OPTIONS)
|
|
if (!firstScan?.itemId || !firstScan?.measurementId) {
|
|
finishScenario(context, start)
|
|
return
|
|
}
|
|
|
|
const measurementPath = `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement?${query(tenantId)}`
|
|
const firstQueryResponse = await context.request('GET', measurementPath)
|
|
const firstQuery = context.apiSuccess(MODULE, '查询箱级测量详情', firstQueryResponse,
|
|
`measurementId=${asId(firstScan.measurementId)}`, OPTIONS)
|
|
const firstItem = firstQuery?.items?.find((item) => asId(item.id) === asId(firstScan.itemId))
|
|
context.assert(MODULE, '箱级查询回显箱码、尺寸、图片与设备信息', firstQueryResponse,
|
|
firstItem?.boxCode === boxCode && asId(firstItem?.intelligentImageFileId)
|
|
&& firstItem?.machineNo === 'MJ-ADMIN-E2E-01'
|
|
&& firstItem?.scannedShipmentId === firstShipmentId
|
|
&& closeTo(firstItem?.lengthCm, firstDimensions.length)
|
|
&& closeTo(firstItem?.widthCm, firstDimensions.width)
|
|
&& closeTo(firstItem?.heightCm, firstDimensions.height),
|
|
`itemFound=${Boolean(firstItem)}, imageFileId=${asId(firstItem?.intelligentImageFileId) || 'missing'}`, OPTIONS)
|
|
|
|
const countResponse = await updateConfirmedPackageCount(context, measurementPath, firstScan.itemId,
|
|
{ measurementVersion: Number(firstScan.measurementVersion), confirmedPackageCount: originalPackageCount })
|
|
const countResult = context.apiSuccess(MODULE, '人工确认当前测量代表箱数', countResponse,
|
|
`confirmedPackageCount=${originalPackageCount}, transportRecovered=${countResponse?.recoveredFromTransportFailure === true}`, OPTIONS)
|
|
context.assert(MODULE, '人工箱数更新且测量版本递增', countResponse,
|
|
Number(countResult?.confirmedPackageCount) === originalPackageCount
|
|
&& Number(countResult?.measurementVersion) === Number(firstScan.measurementVersion) + 1,
|
|
`version=${countResult?.measurementVersion ?? 'missing'}`, OPTIONS)
|
|
|
|
const replacement = measurementProfile?.replacement || { length: 59.5, width: 41.2, height: 31.3 }
|
|
const replacementShipmentId = `FBA-${context.marker.slice(-12)}-02`
|
|
const rescanResponse = await multipartRequest(context, scanPath, {
|
|
boxCode, ...replacement, scannedShipmentId: replacementShipmentId,
|
|
machineNo: 'MJ-ADMIN-E2E-02', measuredAt: new Date().toISOString().slice(0, 19),
|
|
})
|
|
const rescan = context.apiSuccess(MODULE, '同箱号重新扫描覆盖当前记录', rescanResponse,
|
|
`boxCode=${boxCode}, machineNo=MJ-ADMIN-E2E-02`, OPTIONS)
|
|
context.assert(MODULE, '覆盖保留同一条记录和人工箱数', rescanResponse,
|
|
rescan?.created === false && asId(rescan?.itemId) === asId(firstScan.itemId)
|
|
&& Number(rescan?.confirmedPackageCount) === originalPackageCount
|
|
&& closeTo(rescan?.lengthCm, replacement.length),
|
|
`sameItem=${asId(rescan?.itemId) === asId(firstScan.itemId)}, confirmedPackageCount=${rescan?.confirmedPackageCount ?? 'missing'}`, OPTIONS)
|
|
|
|
let measuredResponse = await context.request('GET', measurementPath)
|
|
let measured = context.apiSuccess(MODULE, '回查覆盖后的尺寸与体积结果', measuredResponse, '', OPTIONS)
|
|
let measuredItem = measured?.items?.find((item) => asId(item.id) === asId(firstScan.itemId))
|
|
const weightedOuter = {
|
|
length: Number(measuredItem?.calculatedOuterLengthCm ?? measuredItem?.outerLengthCm),
|
|
width: Number(measuredItem?.calculatedOuterWidthCm ?? measuredItem?.outerWidthCm),
|
|
height: Number(measuredItem?.calculatedOuterHeightCm ?? measuredItem?.outerHeightCm),
|
|
}
|
|
const calculatedVolume = weightedOuter.length * weightedOuter.width * weightedOuter.height / 1_000_000
|
|
const usesMatchedAddRule = String(measuredItem?.calculationRuleVersion || '').startsWith('CUSTOMER_DIMENSION_')
|
|
const unitVolume = usesMatchedAddRule ? halfUp(calculatedVolume, 3) : ceiling(calculatedVolume, 4)
|
|
const totalVolume = unitVolume * originalPackageCount
|
|
const volumeWeight = ceiling(totalVolume * 167, 3)
|
|
// Device scans retain the measured girth while the weighted dimensions are
|
|
// used for volume. The subsequent manual draft explicitly keeps ADD so the
|
|
// same customer dimension rule remains frozen for review.
|
|
const girth = ceiling(replacement.length + 2 * (replacement.width + replacement.height), 3)
|
|
context.assert(MODULE, '覆盖尺寸、箱数和服务端体积公式正确', measuredResponse,
|
|
measuredItem && closeTo(measuredItem.lengthCm, replacement.length)
|
|
&& closeTo(measuredItem.widthCm, replacement.width) && closeTo(measuredItem.heightCm, replacement.height)
|
|
&& measuredItem.scannedShipmentId === replacementShipmentId
|
|
&& Number(measuredItem.confirmedPackageCount) === originalPackageCount
|
|
&& closeTo(measuredItem.calculatedOuterLengthCm, weightedOuter.length)
|
|
&& closeTo(measuredItem.calculatedOuterWidthCm, weightedOuter.width)
|
|
&& closeTo(measuredItem.calculatedOuterHeightCm, weightedOuter.height)
|
|
&& (measuredItem.coefficientMode == null
|
|
|| ['ACTUAL', 'ADD', 'MULTIPLY'].includes(measuredItem.coefficientMode))
|
|
&& Boolean(measuredItem.calculationRuleVersion)
|
|
&& closeTo(measuredItem.unitVolumeCbm, unitVolume) && closeTo(measuredItem.totalVolumeCbm, totalVolume)
|
|
&& closeTo(measuredItem.volumeWeightKg, volumeWeight) && closeTo(measuredItem.girthCm, girth)
|
|
&& closeTo(measured.actualVolumeCbm, totalVolume) && closeTo(measured.volumeWeightKg, volumeWeight),
|
|
`weightedOuter=${weightedOuter.length}x${weightedOuter.width}x${weightedOuter.height}, unitVolume=${unitVolume}, totalVolume=${totalVolume}, volumeWeight=${volumeWeight}, girth=${girth}`, OPTIONS)
|
|
|
|
const incrementalDetailCount = Number(context.context?.phase3LoadingIncrementalDetailCount || 1)
|
|
if (incrementalDetailCount > 1) {
|
|
const packagesPerDetail = Number(context.context?.phase3LoadingIncrementalPackageCount || 1)
|
|
const configuredPackageCounts = context.context?.waitSplitVariant?.packageCounts
|
|
const detailPackageCounts = Array.isArray(configuredPackageCounts)
|
|
&& configuredPackageCounts.length === incrementalDetailCount
|
|
? configuredPackageCounts.map(Number)
|
|
: Array.from({ length: incrementalDetailCount }, () => packagesPerDetail)
|
|
const expectedPackages = detailPackageCounts.reduce((sum, count) => sum + count, 0)
|
|
context.assert(MODULE, '动态增量多明细箱数前置与冻结总数一致', measuredResponse,
|
|
expectedPackages === originalPackageCount,
|
|
`detailCount=${incrementalDetailCount}, packageCounts=${detailPackageCounts.join('+')}, frozen=${originalPackageCount}`, OPTIONS)
|
|
if (expectedPackages === originalPackageCount) {
|
|
const firstDetailPackageCount = detailPackageCounts[0]
|
|
const resetFirstCountResponse = await updateConfirmedPackageCount(context, measurementPath, firstScan.itemId,
|
|
{ measurementVersion: Number(measured?.version), confirmedPackageCount: firstDetailPackageCount })
|
|
const resetFirstCount = context.apiSuccess(MODULE,
|
|
'增量 fixture 将首条货物确认箱数恢复为自身箱数', resetFirstCountResponse,
|
|
`itemId=${asId(firstScan.itemId)}, confirmedPackageCount=${firstDetailPackageCount}`, OPTIONS)
|
|
context.assert(MODULE, '增量首条货物箱数和版本更新正确', resetFirstCountResponse,
|
|
Number(resetFirstCount?.confirmedPackageCount) === firstDetailPackageCount
|
|
&& Number(resetFirstCount?.measurementVersion) === Number(measured?.version) + 1,
|
|
`version=${resetFirstCount?.measurementVersion ?? 'missing'}`, OPTIONS)
|
|
|
|
for (let detailIndex = 1; detailIndex < incrementalDetailCount; detailIndex += 1) {
|
|
const detailPackageCount = detailPackageCounts[detailIndex]
|
|
const cartonIndex = detailPackageCounts.slice(0, detailIndex).reduce((sum, count) => sum + count, 0) + 1
|
|
const detailBoxCode = `${inboundCode}#${originalPackageCount}-${cartonIndex}`
|
|
const scanResponse = await multipartRequest(context, scanPath, {
|
|
boxCode: detailBoxCode, ...replacement,
|
|
scannedShipmentId: `FBA-${context.marker.slice(-12)}-${String(detailIndex + 2).padStart(2, '0')}`,
|
|
machineNo: `MJ-ADMIN-E2E-INC-${detailIndex + 1}`,
|
|
measuredAt: new Date().toISOString().slice(0, 19),
|
|
})
|
|
const scanned = context.apiSuccess(MODULE,
|
|
`增量 fixture 扫描第${detailIndex + 1}条独立货物箱码`, scanResponse,
|
|
`boxCode=${detailBoxCode}`, OPTIONS)
|
|
context.assert(MODULE, `增量第${detailIndex + 1}条扫描解析独立货物`, scanResponse,
|
|
scanned?.created === true && asId(scanned?.itemId)
|
|
&& asId(scanned?.itemId) !== asId(firstScan.itemId),
|
|
`itemId=${asId(scanned?.itemId) || 'missing'}, inboundDetailId=${asId(scanned?.inboundDetailId) || 'pending-query'}`, OPTIONS)
|
|
if (!scanned?.itemId) continue
|
|
const countDetailResponse = await updateConfirmedPackageCount(context, measurementPath, scanned.itemId,
|
|
{ measurementVersion: Number(scanned.measurementVersion), confirmedPackageCount: detailPackageCount })
|
|
const countDetail = context.apiSuccess(MODULE,
|
|
`增量第${detailIndex + 1}条货物确认自身箱数`, countDetailResponse,
|
|
`confirmedPackageCount=${detailPackageCount}`, OPTIONS)
|
|
context.assert(MODULE, `增量第${detailIndex + 1}条货物箱数更新正确`, countDetailResponse,
|
|
Number(countDetail?.confirmedPackageCount) === detailPackageCount,
|
|
`measurementVersion=${countDetail?.measurementVersion ?? 'missing'}`, OPTIONS)
|
|
}
|
|
measuredResponse = await context.request('GET', measurementPath)
|
|
measured = context.apiSuccess(MODULE, '回查增量 fixture 动态测量明细', measuredResponse, '', OPTIONS)
|
|
measuredItem = measured?.items?.find((item) => asId(item.id) === asId(firstScan.itemId))
|
|
const detailIds = new Set((measured?.items || []).map((item) => asId(item?.inboundDetailId)).filter(Boolean))
|
|
const mixedDropship = context.createdResources?.orders?.fulfillmentType === 'ONE_CLICK_DROPSHIP'
|
|
context.assert(MODULE, mixedDropship
|
|
? '混箱一件代发复用物理箱测量明细' : '增量 fixture 形成动态真实独立货物测量明细', measuredResponse,
|
|
mixedDropship ? detailIds.size === 1 : detailIds.size >= incrementalDetailCount
|
|
&& measured.items.filter((item) => detailIds.has(asId(item?.inboundDetailId))).length >= incrementalDetailCount,
|
|
`measurementItems=${measured?.items?.length ?? 'missing'}, distinctInboundDetails=${detailIds.size}`, OPTIONS)
|
|
}
|
|
}
|
|
|
|
let inboundDetailId = measuredItem?.inboundDetailId
|
|
if (!inboundDetailId) {
|
|
const orderId = asId(frozenSummary?.orderId || context.createdResources?.orders?.orderId)
|
|
const orderDetailResponse = await context.request('GET',
|
|
`/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}?${query(tenantId)}`)
|
|
const orderDetail = context.apiSuccess(MODULE, '测量详情缺少货物ID时回查隔离订单', orderDetailResponse,
|
|
`orderId=${orderId}`, OPTIONS)
|
|
const cargoes = Array.isArray(orderDetail?.cargoes) ? [...orderDetail.cargoes] : []
|
|
cargoes.sort((left, right) => Number(left?.itemNo ?? Number.MAX_SAFE_INTEGER)
|
|
- Number(right?.itemNo ?? Number.MAX_SAFE_INTEGER) || asId(left?.id).localeCompare(asId(right?.id)))
|
|
const firstCargo = cargoes[0]
|
|
const cargoBelongsToRun = asId(orderDetail?.order?.id) === orderId
|
|
&& String(firstCargo?.cargoRemark || '').includes(context.marker)
|
|
inboundDetailId = cargoBelongsToRun ? firstCargo?.id : null
|
|
context.assert(MODULE, '草稿绑定本轮箱码对应的隔离货物', orderDetailResponse,
|
|
cargoBelongsToRun && asId(inboundDetailId),
|
|
`cargoCount=${cargoes.length}, markerMatched=${cargoBelongsToRun}, inboundDetailId=${asId(inboundDetailId) || 'missing'}`, OPTIONS)
|
|
}
|
|
if (!inboundDetailId || !measuredItem?.intelligentImageFileId) {
|
|
context.assert(MODULE, '草稿保存前置字段完整', measuredResponse, false,
|
|
`inboundDetailId=${asId(inboundDetailId) || 'missing'}, imageFileId=${asId(measuredItem?.intelligentImageFileId) || 'missing'}`, OPTIONS)
|
|
finishScenario(context, start)
|
|
return
|
|
}
|
|
|
|
const draftItems = incrementalDetailCount > 1
|
|
? (measured?.items || []).filter((item) => asId(item?.inboundDetailId) && asId(item?.intelligentImageFileId))
|
|
.slice(0, incrementalDetailCount).map((item) => ({
|
|
inboundDetailId: item.inboundDetailId, itemNo: item.itemNo,
|
|
boxSpecification: item.boxSpecification, packageCount: Number(item.confirmedPackageCount),
|
|
lengthCm: Number(item.lengthCm), widthCm: Number(item.widthCm), heightCm: Number(item.heightCm),
|
|
intelligentImageFileId: item.intelligentImageFileId,
|
|
overLimitWarning: item.overLimitWarning ?? null,
|
|
outerLengthCm: item.outerLengthCm ?? null, outerWidthCm: item.outerWidthCm ?? null,
|
|
outerHeightCm: item.outerHeightCm ?? null,
|
|
coefficientMode: String(item.calculationRuleVersion || '').startsWith('CUSTOMER_DIMENSION_')
|
|
? 'ADD' : item.coefficientMode ?? null,
|
|
remark: context.marker,
|
|
}))
|
|
: [{
|
|
inboundDetailId, itemNo: measuredItem.itemNo,
|
|
boxSpecification: measuredItem.boxSpecification, packageCount: originalPackageCount,
|
|
lengthCm: replacement.length, widthCm: replacement.width, heightCm: replacement.height,
|
|
intelligentImageFileId: measuredItem.intelligentImageFileId,
|
|
overLimitWarning: measuredItem.overLimitWarning ?? null,
|
|
outerLengthCm: measuredItem.outerLengthCm ?? null, outerWidthCm: measuredItem.outerWidthCm ?? null,
|
|
outerHeightCm: measuredItem.outerHeightCm ?? null,
|
|
coefficientMode: String(measuredItem.calculationRuleVersion || '').startsWith('CUSTOMER_DIMENSION_')
|
|
? 'ADD' : measuredItem.coefficientMode ?? null,
|
|
remark: context.marker,
|
|
}]
|
|
const palletCount = Math.max(1, Math.min(originalPackageCount,
|
|
Number(context.context?.waitSplitVariant?.palletCount || 1)))
|
|
const draftPayload = {
|
|
measurementId: measured.measurementId, version: Number(measured.version),
|
|
actualGoodsName: `墨家测量验收-${context.marker.slice(-12)}`,
|
|
expectedPalletCount: context.createdResources?.orders?.fulfillmentType === 'ONE_CLICK_DROPSHIP'
|
|
? palletCount : null,
|
|
items: draftItems,
|
|
}
|
|
const draftResponse = await context.request('PUT',
|
|
`/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/draft?${query(tenantId)}`, draftPayload)
|
|
const draft = context.apiSuccess(MODULE, '保存同一测量批次草稿', draftResponse,
|
|
`measurementId=${asId(measured.measurementId)}, items=${draftItems.length}`, OPTIONS)
|
|
context.assert(MODULE, '草稿沿用批次并递增版本', draftResponse,
|
|
asId(draft?.measurementId) === asId(measured.measurementId)
|
|
&& Number(draft?.version) === Number(measured.version) + 1 && draft?.status === 'DRAFT',
|
|
`status=${draft?.status ?? 'missing'}, version=${draft?.version ?? 'missing'}`, OPTIONS)
|
|
if (!draft?.measurementId) {
|
|
finishScenario(context, start)
|
|
return
|
|
}
|
|
|
|
const warehouseResourcesReady = await provisionWarehouseResources(context, {
|
|
inboundId, tenantId, warehouseId: asId(summary?.warehouseId), palletCount,
|
|
})
|
|
context.assert(MODULE, '测量提交前按称重图片数准备真实库位', draftResponse,
|
|
Boolean(warehouseResourcesReady),
|
|
`warehouseId=${asId(summary?.warehouseId) || 'missing'}, palletCount=${palletCount}`,
|
|
OPTIONS)
|
|
if (!warehouseResourcesReady) {
|
|
finishScenario(context, start)
|
|
return
|
|
}
|
|
|
|
const weightEvidence = await createConfirmedWeightEvidence(context, {
|
|
inboundId, tenantId, palletIds: warehouseResourcesReady.palletIds,
|
|
grossWeightPerPalletKg: Number(context.context?.waitSplitVariant?.grossWeightPerPalletKg || 100),
|
|
})
|
|
context.assert(MODULE, '测量提交前已通过正式接口确认称重图片', draftResponse,
|
|
Boolean(weightEvidence),
|
|
`confirmedImages=${weightEvidence?.count ?? 0}, expected=${palletCount}`,
|
|
OPTIONS)
|
|
if (!weightEvidence) {
|
|
finishScenario(context, start)
|
|
return
|
|
}
|
|
|
|
if (context.options?.verifyPalletLocationGuide) {
|
|
const guideResponse = await context.request('GET', measurementPath)
|
|
const guideWorkspace = context.apiSuccess(MODULE, '提交前读取整票库位块与称重图片槽绑定', guideResponse,
|
|
`inboundId=${inboundId}, processStatus=${guideResponse.body?.data?.processStatus ?? 'missing'}`, OPTIONS)
|
|
const beforeDelete = verifyPalletLocationGuide(context, guideResponse, guideWorkspace,
|
|
weightEvidence.confirmedResults.map((item) => item.id), '删除前')
|
|
if (!beforeDelete.identityComplete || !beforeDelete.bindingsStable) {
|
|
context.createdResources.inboundMeasurementLocationGuide = {
|
|
marker: context.marker, inboundId, tenantId, blockId: asId(beforeDelete.guide?.blockId),
|
|
preSubmitGuideAvailable: false, processStatus: guideWorkspace?.processStatus || null,
|
|
}
|
|
finishScenario(context, start)
|
|
return
|
|
}
|
|
const overflowSlots = beforeDelete.slots.filter((slot) => slot?.sharedOverflow === true)
|
|
context.addStep(MODULE, '正式数据 A0 共享溢出观测', guideResponse, true,
|
|
overflowSlots.length > 0
|
|
? `sharedOverflow=true, slots=${overflowSlots.map((slot) => `${slot.slotNo}:${slot.reservationId}:${slot.locationCode}`).join('|')}`
|
|
: '本票未触发 A0;为保护共享测试仓,不批量停用普通库位制造容量不足',
|
|
{ scenario: SCENARIO })
|
|
|
|
const deletedWeight = weightEvidence.confirmedResults[0]
|
|
const previousSlot = beforeDelete.slots.find((slot) =>
|
|
asId(slot?.boundWeightResultId) === deletedWeight.id)
|
|
const deleteResponse = await context.request('DELETE',
|
|
`/api/platform/v1/first-leg/inbounds/${inboundId}/weight-results/${deletedWeight.id}?${query(tenantId)}&version=${deletedWeight.version}`)
|
|
const deleteSucceeded = deleteResponse.http === 200 && String(deleteResponse.body?.code) === '200'
|
|
context.assert(MODULE, '可编辑状态删除一张已确认称重图片', deleteResponse, deleteSucceeded,
|
|
`HTTP=${deleteResponse.http}, code=${deleteResponse.body?.code ?? 'missing'}, message=${deleteResponse.body?.message ?? 'missing'}, requestId=${deleteResponse.requestId || 'missing'}, traceId=${deleteResponse.traceId || 'missing'}`,
|
|
OPTIONS)
|
|
if (!deleteSucceeded) {
|
|
context.createdResources.inboundMeasurementLocationGuide = {
|
|
marker: context.marker, inboundId, tenantId, blockId: asId(beforeDelete.guide?.blockId),
|
|
deletedWeightResultId: deletedWeight.id, deleteSucceeded: false,
|
|
deleteCode: deleteResponse.body?.code || null, deleteMessage: deleteResponse.body?.message || null,
|
|
deleteRequestId: deleteResponse.requestId || null, deleteTraceId: deleteResponse.traceId || null,
|
|
sharedOverflowObserved: overflowSlots.length > 0,
|
|
}
|
|
finishScenario(context, start)
|
|
return
|
|
}
|
|
|
|
const afterDeleteResponse = await context.request('GET', measurementPath)
|
|
const afterDeleteWorkspace = context.apiSuccess(MODULE, '删除后回查整票库位块', afterDeleteResponse,
|
|
`deletedWeightResultId=${deletedWeight.id}`, OPTIONS)
|
|
const afterDelete = verifyPalletLocationGuide(context, afterDeleteResponse, afterDeleteWorkspace,
|
|
weightEvidence.confirmedResults.slice(1).map((item) => item.id), '删除后')
|
|
const retainedSlot = afterDelete.slots.find((slot) =>
|
|
asId(slot?.reservationId) === asId(previousSlot?.reservationId)
|
|
&& asId(slot?.locationId) === asId(previousSlot?.locationId))
|
|
const retainedEmpty = Boolean(previousSlot) && retainedSlot?.slotStatus === 'RESERVED_EMPTY'
|
|
&& !asId(retainedSlot?.boundWeightResultId)
|
|
&& Number(afterDelete.guide?.reservedSlotCount) === Number(beforeDelete.guide?.reservedSlotCount)
|
|
context.assert(MODULE, '删除只解绑图片且原 reservation/location 变为 RESERVED_EMPTY', afterDeleteResponse,
|
|
retainedEmpty,
|
|
`reservationId=${asId(retainedSlot?.reservationId) || 'missing'}, locationId=${asId(retainedSlot?.locationId) || 'missing'}, slotStatus=${retainedSlot?.slotStatus || 'missing'}`,
|
|
OPTIONS)
|
|
if (!retainedEmpty) {
|
|
finishScenario(context, start)
|
|
return
|
|
}
|
|
|
|
const replacementWeight = await recreateConfirmedWeightEvidence(context, {
|
|
inboundId, tenantId, palletId: deletedWeight.palletId, grossWeightKg: deletedWeight.grossWeightKg,
|
|
})
|
|
if (!replacementWeight) {
|
|
finishScenario(context, start)
|
|
return
|
|
}
|
|
const reboundResponse = await context.request('GET', measurementPath)
|
|
const reboundWorkspace = context.apiSuccess(MODULE, '替代图片确认后回查空槽复用', reboundResponse,
|
|
`replacementWeightResultId=${replacementWeight.id}`, OPTIONS)
|
|
const activeResults = [...weightEvidence.confirmedResults.slice(1), replacementWeight]
|
|
const rebound = verifyPalletLocationGuide(context, reboundResponse, reboundWorkspace,
|
|
activeResults.map((item) => item.id), '重绑后')
|
|
const reboundSlot = rebound.slots.find((slot) =>
|
|
asId(slot?.boundWeightResultId) === replacementWeight.id)
|
|
const reusedSlot = reboundSlot?.slotStatus === 'BOUND'
|
|
&& asId(reboundSlot?.reservationId) === asId(previousSlot?.reservationId)
|
|
&& asId(reboundSlot?.locationId) === asId(previousSlot?.locationId)
|
|
&& Number(rebound.guide?.reservedEmptySlotCount) === Number(beforeDelete.guide?.reservedEmptySlotCount)
|
|
context.assert(MODULE, '替代图片按新 resultId 复用原空槽', reboundResponse, reusedSlot,
|
|
`oldResultId=${deletedWeight.id}, newResultId=${replacementWeight.id}, reservationId=${asId(reboundSlot?.reservationId) || 'missing'}, locationId=${asId(reboundSlot?.locationId) || 'missing'}, slotStatus=${reboundSlot?.slotStatus || 'missing'}`,
|
|
OPTIONS)
|
|
if (!reusedSlot) {
|
|
finishScenario(context, start)
|
|
return
|
|
}
|
|
weightEvidence.confirmedResults = activeResults
|
|
weightEvidence.grossWeightKg = activeResults.reduce((sum, item) => sum + item.grossWeightKg, 0)
|
|
weightEvidence.netWeightKg = activeResults.reduce((sum, item) => sum + item.netWeightKg, 0)
|
|
context.createdResources.inboundMeasurementLocationGuide = {
|
|
marker: context.marker, inboundId, tenantId, blockId: asId(rebound.guide?.blockId),
|
|
deletedWeightResultId: deletedWeight.id, replacementWeightResultId: replacementWeight.id,
|
|
deleteSucceeded: true, slotReused: true, reservationId: asId(reboundSlot?.reservationId),
|
|
locationId: asId(reboundSlot?.locationId), sharedOverflowObserved: overflowSlots.length > 0,
|
|
}
|
|
}
|
|
|
|
const submissionPayload = { measurementId: draft.measurementId, version: Number(draft.version) }
|
|
const replayInput = { inboundId, tenantId, measurementId: asId(draft.measurementId) }
|
|
const submitResponse = await context.request('POST',
|
|
`/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/submit?${query(tenantId)}`,
|
|
submissionPayload)
|
|
const submitted = context.apiSuccess(MODULE, '提交测量审核', submitResponse,
|
|
`measurementId=${asId(draft.measurementId)}`, OPTIONS)
|
|
context.assert(MODULE, '提交后进入待测量审核状态', submitResponse,
|
|
submitted?.processStatus === 'MEASURE_PENDING' && submitted?.measureReviewStatus === 'PENDING',
|
|
`processStatus=${submitted?.processStatus ?? 'missing'}, reviewStatus=${submitted?.measureReviewStatus ?? 'missing'}`, OPTIONS)
|
|
const submissionReplay = await verifyMeasurementSubmitReplay(context, replayInput, submissionPayload)
|
|
|
|
const rejectedScanResponse = await multipartRequest(context, scanPath, {
|
|
boxCode, ...replacement, machineNo: 'MJ-ADMIN-E2E-03', measuredAt: new Date().toISOString().slice(0, 19),
|
|
})
|
|
const rejected = rejectedScanResponse.http === 200 && String(rejectedScanResponse.body?.code) === 'A0409'
|
|
&& String(rejectedScanResponse.body?.message || '').includes('测量已提交')
|
|
context.assert(MODULE, '测量提交后拒绝设备覆盖并返回中文提示', rejectedScanResponse, rejected,
|
|
`HTTP=${rejectedScanResponse.http}, code=${rejectedScanResponse.body?.code ?? 'missing'}, chineseMessage=${String(rejectedScanResponse.body?.message || '').includes('测量已提交')}`, OPTIONS)
|
|
|
|
const pendingInboundResponse = await context.request('GET', detailPath)
|
|
const pendingInbound = context.apiSuccess(MODULE, '审核前读取最新入库单版本', pendingInboundResponse, '', OPTIONS)
|
|
const pendingSummary = pendingInbound?.summary
|
|
context.assert(MODULE, '审核前状态和版本有效', pendingInboundResponse,
|
|
pendingSummary?.processStatus === 'MEASURE_PENDING' && pendingSummary?.measureReviewStatus === 'PENDING'
|
|
&& Number.isInteger(Number(pendingSummary?.version)),
|
|
`status=${pendingSummary?.processStatus ?? 'missing'}, version=${pendingSummary?.version ?? 'missing'}`, OPTIONS)
|
|
|
|
const approvalPath = `/api/platform/v1/inbounds/${inboundId}/measurements/approve?${query(tenantId)}`
|
|
const approvalPayload = {
|
|
measurementId: draft.measurementId, expectedVersion: Number(pendingSummary?.version), reason: null,
|
|
}
|
|
const approvalKey = `${context.marker}-mojia-approve`.slice(0, 128)
|
|
const approvalResponse = await requestWithHeaders(context, 'POST', approvalPath, approvalPayload,
|
|
{ 'Idempotency-Key': approvalKey })
|
|
context.apiSuccess(MODULE, 'Admin 审核通过墨家测量', approvalResponse,
|
|
`measurementId=${asId(draft.measurementId)}`, OPTIONS)
|
|
|
|
// Measurement approval may enqueue pricing/inbound completion. Poll the
|
|
// public projection instead of treating the transient WAIT_PRICING state as
|
|
// a smart-loading candidate or manufacturing fixture state in the script.
|
|
const dropship = context.createdResources?.orders?.fulfillmentType === 'ONE_CLICK_DROPSHIP'
|
|
let completed = await waitForCompletedMeasurement(context, measurementPath,
|
|
dropship ? ['WAIT_PALLETIZE'] : ['WAIT_SPLIT'])
|
|
let finalResponse = completed.response
|
|
let finalMeasurement = context.apiSuccess(MODULE, '查询审核后的最终测量状态', finalResponse,
|
|
`pollAttempts=${completed.attempts}`, OPTIONS)
|
|
context.assert(MODULE, '审核后入库与测量状态闭环且结果未变化', finalResponse,
|
|
finalMeasurement?.processStatus === (dropship ? 'WAIT_PALLETIZE' : 'WAIT_SPLIT')
|
|
&& finalMeasurement?.measureReviewStatus === 'APPROVED'
|
|
&& finalMeasurement?.measurementStatus === 'APPROVED'
|
|
&& Number(finalMeasurement?.actualPackageCount) === originalPackageCount
|
|
&& closeTo(finalMeasurement?.actualGrossWeightKg, weightEvidence.grossWeightKg, 0.001)
|
|
&& closeTo(finalMeasurement?.actualNetWeightKg, weightEvidence.netWeightKg, 0.001)
|
|
&& closeTo(finalMeasurement?.actualVolumeCbm, totalVolume)
|
|
&& 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)
|
|
|
|
const approvalReplay = await verifyMeasurementApprovalReplay(context, replayInput, approvalPayload, approvalKey)
|
|
|
|
let sealedCartonMarks = []
|
|
let sealedSourcePalletNos = []
|
|
if (dropship && finalMeasurement?.processStatus === 'WAIT_PALLETIZE') {
|
|
if (context.options?.prepareDropshipManualCount) {
|
|
const warehouseId = asId(summary?.warehouseId)
|
|
const pendingResponse = await context.request('GET',
|
|
`/api/platform/v1/first-leg/pda-dropship/tasks/pending?warehouseId=${warehouseId}&keyword=${encodeURIComponent(inboundCode)}`)
|
|
const pending = context.apiSuccess(MODULE, '准备PDA人工清点任务', pendingResponse,
|
|
`inboundId=${inboundId}`, OPTIONS)
|
|
const task = (pending || []).find((item) => asId(item?.inboundId) === inboundId)
|
|
context.assert(MODULE, '任务停在未领取、未清点、未归托状态', pendingResponse,
|
|
Boolean(asId(task?.taskId)) && task?.taskStatus === 'PENDING'
|
|
&& Number(task?.countedCartons) === 0 && Number(task?.boundCartons) === 0
|
|
&& Number(task?.totalCartons) === Number(finalMeasurement?.actualPackageCount),
|
|
`taskId=${asId(task?.taskId) || 'missing'}, status=${task?.taskStatus ?? 'missing'}, counted=${task?.countedCartons ?? 'missing'}`, OPTIONS)
|
|
context.createdResources.dropshipManualCount = {
|
|
taskId: asId(task?.taskId), inboundId, inboundCode, warehouseId,
|
|
warehouseName: task?.warehouseName || null, totalCartons: Number(task?.totalCartons || 0),
|
|
taskStatus: task?.taskStatus || null,
|
|
}
|
|
} else {
|
|
const sealed = await completeDropshipPalletization(context, {
|
|
inboundId, inboundCode, warehouseId: asId(summary?.warehouseId), measurement: finalMeasurement,
|
|
})
|
|
sealedCartonMarks = sealed?.taskStatus === 'COMPLETED' ? sealed.sealedCartonMarks : []
|
|
sealedSourcePalletNos = sealed?.taskStatus === 'COMPLETED'
|
|
? (sealed.sealedSourcePalletNos || []).filter(Boolean) : []
|
|
completed = await waitForCompletedMeasurement(context, measurementPath, ['WAIT_SPLIT'], 10)
|
|
finalResponse = completed.response
|
|
finalMeasurement = context.apiSuccess(MODULE, '合托后回查一件代发入仓单', finalResponse,
|
|
`pollAttempts=${completed.attempts}`, OPTIONS)
|
|
context.assert(MODULE, '一件代发合托完成后进入智能分柜门禁', finalResponse,
|
|
finalMeasurement?.processStatus === 'WAIT_SPLIT'
|
|
&& finalMeasurement?.measureReviewStatus === 'APPROVED'
|
|
&& finalMeasurement?.measurementStatus === 'APPROVED',
|
|
`processStatus=${finalMeasurement?.processStatus ?? 'missing'}`, OPTIONS)
|
|
}
|
|
}
|
|
|
|
const approvedScanResponse = await multipartRequest(context, scanPath, {
|
|
boxCode, ...replacement, machineNo: 'MJ-ADMIN-E2E-AFTER-APPROVAL',
|
|
measuredAt: new Date().toISOString().slice(0, 19),
|
|
})
|
|
context.assert(MODULE, '审核及合托完成后箱码尺寸与图片快照不可再覆盖', approvedScanResponse,
|
|
approvedScanResponse.http === 200 && String(approvedScanResponse.body?.code) === 'A0409',
|
|
`HTTP=${approvedScanResponse.http}, code=${approvedScanResponse.body?.code ?? 'missing'}, requestId=${approvedScanResponse.requestId || 'missing'}`,
|
|
OPTIONS)
|
|
|
|
context.createdResources.inboundMeasurement = {
|
|
replayEvidence: { submission: submissionReplay, approval: approvalReplay },
|
|
sealedCartonMarks, sealedSourcePalletNos,
|
|
marker: context.marker, tenantId, inboundId,
|
|
orderId: asId(summary?.orderId || context.createdResources?.orders?.orderId),
|
|
measurementId: asId(draft.measurementId),
|
|
boxCode, finalStatus: finalMeasurement?.measurementStatus || null,
|
|
processStatus: finalMeasurement?.processStatus || null,
|
|
palletCount: weightEvidence.count, actualGrossWeightKg: weightEvidence.grossWeightKg,
|
|
actualPackageCount: Number(finalMeasurement?.actualPackageCount || 0),
|
|
destinationType: context.createdResources?.orders?.destinationType || null,
|
|
destinationWarehouseId: context.createdResources?.orders?.destinationWarehouseId || null,
|
|
destinationWarehouseCode: context.createdResources?.orders?.destinationWarehouseCode || null,
|
|
}
|
|
finishScenario(context, start)
|
|
}
|