This commit is contained in:
2026-08-14 17:53:09 +08:00
parent 2752cfb5b2
commit e33b2c8645
2 changed files with 194 additions and 38 deletions
@@ -0,0 +1,59 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import {
confirmedNetSummary,
normalizedGrossKg,
selectWeightPallet,
workspaceWeightMatchesBaseline,
} from './inbound-weight-ocr.mjs'
test('selects a stable complete pallet snapshot without status or availability', () => {
const selected = selectWeightPallet([
{ palletId: '20', palletCode: 'P-20', palletType: 'STANDARD', capacityCbm: 2, tareWeightKg: 18.5 },
{ palletId: '3', palletCode: 'P-03', palletType: 'STANDARD', capacityCbm: 1.5, tareWeightKg: 12 },
])
assert.equal(selected.palletId, '3')
assert.equal(selectWeightPallet([]), null)
assert.equal(selectWeightPallet([
{ palletId: '3', palletCode: 'P-03', palletType: 'STANDARD', capacityCbm: 1.5 },
]), null)
})
test('normalizes gross weight and summarizes confirmed net weight only', () => {
assert.equal(normalizedGrossKg(50.125, 'LB'), 22.736)
assert.equal(normalizedGrossKg(30, 'KG'), 30)
assert.equal(normalizedGrossKg(30, 'UNKNOWN'), null)
assert.deepEqual(confirmedNetSummary({ weightResults: [
{ recognitionStatus: 'CONFIRMED', netWeightKg: 11.5 },
{ recognitionStatus: 'PENDING_CONFIRM', netWeightKg: 99 },
{ recognitionStatus: 'CONFIRMED', netWeightKg: 2.125 },
] }), { count: 2, complete: true, total: 13.625 })
})
test('cleanup restores a non-empty carton fallback when there were no confirmed images', () => {
const initial = { actualGrossWeightKg: 54.4, weightResults: [] }
assert.deepEqual(confirmedNetSummary(initial), { count: 0, complete: true, total: 0 })
assert.equal(workspaceWeightMatchesBaseline({ actualGrossWeightKg: 54.4 }, 54.4), true)
assert.equal(workspaceWeightMatchesBaseline({ actualGrossWeightKg: null }, 54.4), false)
assert.equal(workspaceWeightMatchesBaseline({ actualGrossWeightKg: null }, null), true)
})
test('HTTP scenario uses pallet option and net weight contracts without legacy final weight', () => {
const source = readFileSync(new URL('inbound-weight-ocr.mjs', import.meta.url), 'utf8')
assert.match(source, /\/weight-pallet-options\?tenantId=/)
assert.ok(source.indexOf('const palletOptionsResponse') < source.lastIndexOf('const uploadPath'))
assert.match(source, /form\.append\('file'/)
assert.doesNotMatch(source, /form\.append\('palletId'/)
assert.match(source, /palletId:\s*id\(pallet\.palletId\)/)
assert.match(source, /grossWeightKg/)
assert.match(source, /netWeightKg/)
assert.match(source, /palletTareWeightKg/)
assert.doesNotMatch(source, /finalWeightKg/)
assert.doesNotMatch(source, /pallet\.(?:status|available)/)
assert.doesNotMatch(source, /platform:pallet:list/)
assert.match(source, /verifyDeleted\(context, target, original, baselineWorkspaceWeight\)/)
assert.match(source, /workspaceWeightMatchesBaseline\(cleanup\.workspace, baselineWorkspaceWeight\)/)
assert.doesNotMatch(source, /verifyDeleted\([^\n]+baselineConfirmedNet/)
})
@@ -22,6 +22,47 @@ function closeNumber(actual, expected, tolerance = 0.0005) {
return Number.isFinite(Number(actual)) && Math.abs(Number(actual) - Number(expected)) < tolerance
}
function palletSnapshotComplete(pallet) {
return Boolean(id(pallet?.palletId))
&& typeof pallet?.palletCode === 'string' && pallet.palletCode.length > 0
&& typeof pallet?.palletType === 'string' && pallet.palletType.length > 0
&& Number.isFinite(Number(pallet?.capacityCbm)) && Number(pallet.capacityCbm) > 0
&& Number.isFinite(Number(pallet?.tareWeightKg)) && Number(pallet.tareWeightKg) >= 0
}
export function selectWeightPallet(options) {
if (!Array.isArray(options)) return null
return [...options]
.filter(palletSnapshotComplete)
.sort((left, right) => id(left.palletId).localeCompare(id(right.palletId), 'en', { numeric: true }))[0] || null
}
export function normalizedGrossKg(value, unit) {
const numeric = Number(value)
if (!Number.isFinite(numeric)) return null
const normalizedUnit = String(unit || '').trim().toUpperCase()
if (!['KG', 'KGS', 'LB', 'LBS'].includes(normalizedUnit)) return null
const kg = ['LB', 'LBS'].includes(normalizedUnit) ? numeric * 0.45359237 : numeric
return Math.round(kg * 1000) / 1000
}
export function confirmedNetSummary(workspace) {
const confirmed = (workspace?.weightResults || [])
.filter((row) => row?.recognitionStatus === 'CONFIRMED')
const values = confirmed.map((row) => Number(row?.netWeightKg))
return {
count: confirmed.length,
complete: values.every((value) => Number.isFinite(value) && value > 0),
total: Math.round(values.reduce((sum, value) => sum + value, 0) * 1000) / 1000,
}
}
export function workspaceWeightMatchesBaseline(workspace, baselineWorkspaceWeight) {
return baselineWorkspaceWeight == null
? workspace?.actualGrossWeightKg == null
: closeNumber(workspace?.actualGrossWeightKg, baselineWorkspaceWeight, 0.0011)
}
function expectedConflict(response) {
return response?.http === 200 && ['A0409', '409'].includes(String(response?.body?.code))
}
@@ -161,7 +202,11 @@ function deletePath(tenantId, inboundId, resultId, version) {
return `${resultPath(inboundId, resultId)}?tenantId=${encodeURIComponent(tenantId)}&version=${version}`
}
async function createUploadedResult(context, target, image, overrides = {}) {
function palletOptionsPath(tenantId, inboundId) {
return `/api/platform/v1/first-leg/inbounds/${encodeURIComponent(inboundId)}/weight-pallet-options?tenantId=${encodeURIComponent(tenantId)}`
}
async function createUploadedResult(context, target, pallet, image, overrides = {}) {
const imagePath = `/api/platform/v1/files/measurements/${encodeURIComponent(target.tenantId)}/${encodeURIComponent(target.inboundId)}`
const uploadResponse = await multipart(context, imagePath, image)
const upload = context.apiSuccess(MODULE, '上传称重图片并执行 OCR', uploadResponse,
@@ -195,6 +240,7 @@ async function createUploadedResult(context, target, image, overrides = {}) {
const payload = {
fileId,
palletId: id(pallet.palletId),
ocrProvider: `ADMIN_ACCEPTANCE_${context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-24)}`,
ocrRawText: overrides.manual ? null : (recognized.ocrRawText || null),
ocrWeightValue: overrides.manual ? null : recognized.weightValue,
@@ -215,8 +261,9 @@ async function createUploadedResult(context, target, image, overrides = {}) {
const resultId = id(result?.id)
context.assert(MODULE, '重量结果 ID、文件 ID 和初始版本契约正确', createResponse,
Boolean(resultId) && typeof result?.id === 'string' && typeof result?.fileId === 'string'
&& id(result?.fileId) === fileId && Number(result?.version) === 0,
`resultIdType=${typeof result?.id}, fileIdType=${typeof result?.fileId}, version=${result?.version ?? 'missing'}`,
&& id(result?.fileId) === fileId && id(result?.palletId) === id(pallet.palletId)
&& Number(result?.version) === 0,
`resultIdType=${typeof result?.id}, fileIdType=${typeof result?.fileId}, palletId=${id(result?.palletId) || 'missing'}, version=${result?.version ?? 'missing'}`,
SCENARIO_OPTIONS)
if (!resultId) throw new Error(`文件 ${fileId} 已上传但重量结果登记失败,需要人工清理该测试文件`)
return { upload: recognized, fileId, result, resultId, version: Number(result.version), deleted: false }
@@ -267,14 +314,12 @@ async function deleteResult(context, target, item, name, options = NEW_CONTRACT_
return { response, passed }
}
async function verifyDeleted(context, target, item, baselineWeight) {
async function verifyDeleted(context, target, item, baselineWorkspaceWeight) {
const check = await loadWorkspace(context, target, '删除后回查重量工作区')
const missing = !check.workspace?.weightResults?.some((row) => id(row.id) === item.resultId)
context.assert(MODULE, '删除后结果消失且总毛重恢复基线', check.response,
missing && (baselineWeight == null
? check.workspace?.actualGrossWeightKg == null
: closeNumber(check.workspace?.actualGrossWeightKg, baselineWeight)),
`resultMissing=${missing}, baseline=${baselineWeight ?? 'null'}, actual=${check.workspace?.actualGrossWeightKg ?? 'null'}`,
context.assert(MODULE, '删除后结果消失且工作区重量恢复运行前基线', check.response,
missing && workspaceWeightMatchesBaseline(check.workspace, baselineWorkspaceWeight),
`resultMissing=${missing}, baselineWorkspaceWeight=${baselineWorkspaceWeight ?? 'null'}, actual=${check.workspace?.actualGrossWeightKg ?? 'null'}`,
NEW_CONTRACT_OPTIONS)
const imagePath = `/api/platform/v1/files/measurements/${encodeURIComponent(target.tenantId)}/${encodeURIComponent(target.inboundId)}/${encodeURIComponent(item.fileId)}`
@@ -313,12 +358,50 @@ export async function run(context) {
const image = { bytes: imageBytes, name: basename(imagePath), contentType: 'image/jpeg' }
const initial = await loadWorkspace(context, target, '读取 OCR 测试前重量工作区')
if (!initial.workspace) throw new Error('无法读取测试入库单重量工作区')
const baselineWeight = initial.workspace.actualGrossWeightKg
const baselineWorkspaceWeight = initial.workspace.actualGrossWeightKg
const baselineImageSummary = confirmedNetSummary(initial.workspace)
const baselineIds = new Set((initial.workspace.weightResults || []).map((row) => id(row.id)))
context.assert(MODULE, '测试入库单身份与基线可追踪', initial.response,
id(initial.workspace.inboundId) === target.inboundId
&& initial.workspace.processStatus === target.detail.summary.processStatus,
`existingResults=${baselineIds.size}, baselineWeight=${baselineWeight ?? 'null'}`, SCENARIO_OPTIONS)
&& initial.workspace.processStatus === target.detail.summary.processStatus
&& baselineImageSummary.complete,
`existingResults=${baselineIds.size}, confirmedImageCount=${baselineImageSummary.count}, baselineImageNetTotal=${baselineImageSummary.total}, baselineWorkspaceWeight=${baselineWorkspaceWeight ?? 'null'}`,
NEW_CONTRACT_OPTIONS)
if (!baselineImageSummary.complete) {
const steps = context.steps.slice(scenarioStart)
context.createdResources.inboundWeightOcr.cleaned = true
context.scenarios.push({
name: SCENARIO, module: MODULE, success: false,
deploymentBlocked: steps.some((step) => step.deploymentBlocker),
passed: steps.filter((step) => step.passed).length,
failed: steps.filter((step) => !step.passed).length,
})
return
}
const palletOptionsResponse = await context.request(
'GET', palletOptionsPath(target.tenantId, target.inboundId),
)
const palletOptions = context.apiSuccess(MODULE, '读取同仓称重托盘候选', palletOptionsResponse,
`inboundId=${target.inboundId}`, NEW_CONTRACT_OPTIONS)
const palletOptionsComplete = Array.isArray(palletOptions) && palletOptions.length > 0
&& palletOptions.every(palletSnapshotComplete)
context.assert(MODULE, '托盘候选包含稳定快照字段且无需状态或占用资格', palletOptionsResponse,
palletOptionsComplete,
`count=${Array.isArray(palletOptions) ? palletOptions.length : 'invalid'}, fields=palletId/palletCode/palletType/capacityCbm/tareWeightKg`,
NEW_CONTRACT_OPTIONS)
const pallet = palletOptionsComplete ? selectWeightPallet(palletOptions) : null
if (!pallet) {
const steps = context.steps.slice(scenarioStart)
context.createdResources.inboundWeightOcr.cleaned = true
context.scenarios.push({
name: SCENARIO, module: MODULE, success: false,
deploymentBlocked: steps.some((step) => step.deploymentBlocker),
passed: steps.filter((step) => step.passed).length,
failed: steps.filter((step) => !step.passed).length,
})
return
}
const uploadPath = `/api/platform/v1/files/measurements/${encodeURIComponent(target.tenantId)}/${encodeURIComponent(target.inboundId)}`
const noAuth = await multipart(context, uploadPath, image, '')
@@ -337,7 +420,7 @@ export async function run(context) {
expectedRejection(oversized), `HTTP=${oversized.http}, code=${oversized.body?.code ?? 'missing'}`, SCENARIO_OPTIONS)
try {
const original = await createUploadedResult(context, target, image, { retry: true })
const original = await createUploadedResult(context, target, pallet, image, { retry: true })
created.push(original)
context.createdResources.inboundWeightOcr.resultIds.push(original.resultId)
await verifyImages(context, target, original, image)
@@ -359,29 +442,41 @@ export async function run(context) {
const recognizedValue = Number(original.result.ocrWeightValue)
const recognizedUnit = original.result.ocrWeightUnit
const canConfirmOriginal = Number.isFinite(recognizedValue) && recognizedValue > 0
&& ['KG', 'KGS', 'LB', 'LBS'].includes(String(recognizedUnit || '').trim().toUpperCase())
const recognizedGrossKg = normalizedGrossKg(recognizedValue, recognizedUnit)
const canConfirmOriginal = recognizedGrossKg != null
&& recognizedGrossKg > Number(pallet.tareWeightKg)
const confirmPayload = canConfirmOriginal
? { version: original.version }
: {
version: original.version, adjustedWeightValue: 1.234, adjustedWeightUnit: 'KG',
version: original.version,
adjustedWeightValue: Math.round((Number(pallet.tareWeightKg) + 1.234) * 1000) / 1000,
adjustedWeightUnit: 'KG',
adjustmentReason: `${context.marker} OCR未返回有效重量,人工验收`,
}
const confirmResponse = await context.request('POST',
confirmPath(target.tenantId, target.inboundId, original.resultId), confirmPayload)
const confirmed = context.apiSuccess(MODULE, '确认 OCR 重量并计入实际总毛重', confirmResponse,
const confirmed = context.apiSuccess(MODULE, '确认 OCR 重量并计入货物总重量', confirmResponse,
`usedOriginal=${canConfirmOriginal}`, NEW_CONTRACT_OPTIONS)
context.assert(MODULE, '确认结果状态、版本和最终KG正确', confirmResponse,
const confirmedGross = Number(confirmed?.grossWeightKg)
const confirmedTare = Number(confirmed?.palletTareWeightKg)
const confirmedNet = Number(confirmed?.netWeightKg)
context.assert(MODULE, '确认结果返回托盘快照、毛重和去皮净重', confirmResponse,
confirmed?.recognitionStatus === 'CONFIRMED' && Number(confirmed?.version) === original.version + 1
&& Number(confirmed?.finalWeightKg) > 0,
`status=${confirmed?.recognitionStatus ?? 'missing'}, version=${confirmed?.version ?? 'missing'}, finalKg=${confirmed?.finalWeightKg ?? 'missing'}`,
&& id(confirmed?.palletId) === id(pallet.palletId)
&& confirmed?.palletCode === pallet.palletCode && confirmed?.palletType === pallet.palletType
&& closeNumber(confirmed?.palletCapacityCbm, pallet.capacityCbm)
&& closeNumber(confirmed?.palletTareWeightKg, pallet.tareWeightKg)
&& confirmedGross > confirmedTare && confirmedNet > 0
&& closeNumber(confirmedNet, confirmedGross - confirmedTare, 0.0011),
`status=${confirmed?.recognitionStatus ?? 'missing'}, palletId=${id(confirmed?.palletId) || 'missing'}, grossKg=${confirmed?.grossWeightKg ?? 'missing'}, tareKg=${confirmed?.palletTareWeightKg ?? 'missing'}, netKg=${confirmed?.netWeightKg ?? 'missing'}`,
NEW_CONTRACT_OPTIONS)
original.version = Number(confirmed?.version ?? original.version)
const afterConfirm = await loadWorkspace(context, target, '确认后回查实际总毛重')
const expectedTotal = Number(baselineWeight || 0) + Number(confirmed?.finalWeightKg || 0)
context.assert(MODULE, '确认后实际总毛重按服务端结果重算', afterConfirm.response,
closeNumber(afterConfirm.workspace?.actualGrossWeightKg, expectedTotal),
`baseline=${baselineWeight ?? 'null'}, added=${confirmed?.finalWeightKg ?? 'missing'}, actual=${afterConfirm.workspace?.actualGrossWeightKg ?? 'missing'}`,
const afterConfirm = await loadWorkspace(context, target, '确认后回查货物总重量')
const confirmedSummary = confirmedNetSummary(afterConfirm.workspace)
context.assert(MODULE, '货物总重量按全部已确认净重由服务端汇总', afterConfirm.response,
confirmedSummary.complete && confirmedSummary.count === baselineImageSummary.count + 1
&& closeNumber(afterConfirm.workspace?.actualGrossWeightKg, confirmedSummary.total, 0.0011),
`confirmedCount=${confirmedSummary.count}, confirmedNetTotal=${confirmedSummary.total}, actual=${afterConfirm.workspace?.actualGrossWeightKg ?? 'missing'}`,
NEW_CONTRACT_OPTIONS)
const staleDelete = await context.request('DELETE',
@@ -389,9 +484,9 @@ export async function run(context) {
context.assert(MODULE, '旧版本删除返回并发冲突', staleDelete, expectedConflict(staleDelete),
`HTTP=${staleDelete.http}, code=${staleDelete.body?.code ?? 'missing'}`, NEW_CONTRACT_OPTIONS)
const deletedOriginal = await deleteResult(context, target, original, '删除本轮已确认图片及重量结果')
if (deletedOriginal.passed) await verifyDeleted(context, target, original, baselineWeight)
if (deletedOriginal.passed) await verifyDeleted(context, target, original, baselineWorkspaceWeight)
const manual = await createUploadedResult(context, target, image, { manual: true })
const manual = await createUploadedResult(context, target, pallet, image, { manual: true })
created.push(manual)
context.createdResources.inboundWeightOcr.resultIds.push(manual.resultId)
await rejectWithoutMutation(context, target, manual, '零重量确认被拒绝', {
@@ -408,7 +503,7 @@ export async function run(context) {
adjustedWeightUnit: 'KG', adjustmentReason: context.marker,
})
const adjustedValue = 10.125
const adjustedValue = Math.ceil(((Number(pallet.tareWeightKg) + 5) / 0.45359237) * 1000) / 1000
const adjustedConfirmResponse = await context.request('POST',
confirmPath(target.tenantId, target.inboundId, manual.resultId), {
version: manual.version, adjustedWeightValue: adjustedValue, adjustedWeightUnit: 'LB',
@@ -416,16 +511,19 @@ export async function run(context) {
})
const adjusted = context.apiSuccess(MODULE, '人工改重与确认原子完成', adjustedConfirmResponse,
`adjusted=${adjustedValue} LB`, NEW_CONTRACT_OPTIONS)
const expectedKg = Math.round(adjustedValue * 0.45359237 * 1000) / 1000
context.assert(MODULE, 'LB 人工改重保留输入并正确换算最终KG', adjustedConfirmResponse,
const expectedGrossKg = Math.round(adjustedValue * 0.45359237 * 1000) / 1000
const expectedNetKg = Math.round((expectedGrossKg - Number(pallet.tareWeightKg)) * 1000) / 1000
context.assert(MODULE, 'LB 人工改重保留毛重输入并正确换算去皮净重', adjustedConfirmResponse,
adjusted?.recognitionStatus === 'CONFIRMED' && Number(adjusted?.version) === manual.version + 2
&& closeNumber(adjusted?.adjustedWeightValue, adjustedValue)
&& adjusted?.adjustedWeightUnit === 'LB' && closeNumber(adjusted?.finalWeightKg, expectedKg),
`adjusted=${adjusted?.adjustedWeightValue ?? 'missing'} ${adjusted?.adjustedWeightUnit ?? ''}, finalKg=${adjusted?.finalWeightKg ?? 'missing'}, expectedKg=${expectedKg}`,
&& adjusted?.adjustedWeightUnit === 'LB'
&& closeNumber(adjusted?.grossWeightKg, expectedGrossKg, 0.0011)
&& closeNumber(adjusted?.netWeightKg, expectedNetKg, 0.0011),
`adjusted=${adjusted?.adjustedWeightValue ?? 'missing'} ${adjusted?.adjustedWeightUnit ?? ''}, grossKg=${adjusted?.grossWeightKg ?? 'missing'}, netKg=${adjusted?.netWeightKg ?? 'missing'}, expectedNetKg=${expectedNetKg}`,
NEW_CONTRACT_OPTIONS)
manual.version = Number(adjusted?.version ?? manual.version)
const deletedManual = await deleteResult(context, target, manual, '删除本轮人工改重图片及结果')
if (deletedManual.passed) await verifyDeleted(context, target, manual, baselineWeight)
if (deletedManual.passed) await verifyDeleted(context, target, manual, baselineWorkspaceWeight)
} finally {
for (const item of created.filter((value) => !value.deleted)) {
await deleteResult(context, target, item, '异常收尾清理本轮重量结果', NEW_CONTRACT_OPTIONS)
@@ -433,11 +531,10 @@ export async function run(context) {
const cleanup = await loadWorkspace(context, target, '最终清理后回查重量工作区')
const createdIds = new Set(created.map((item) => item.resultId))
const leftovers = (cleanup.workspace?.weightResults || []).filter((row) => createdIds.has(id(row.id)))
const restored = leftovers.length === 0 && (baselineWeight == null
? cleanup.workspace?.actualGrossWeightKg == null
: closeNumber(cleanup.workspace?.actualGrossWeightKg, baselineWeight))
context.assert(MODULE, '仅清理本轮结果且订单总毛重恢复运行前基线', cleanup.response,
restored, `leftovers=${leftovers.length}, baseline=${baselineWeight ?? 'null'}, actual=${cleanup.workspace?.actualGrossWeightKg ?? 'null'}`,
const restored = leftovers.length === 0
&& workspaceWeightMatchesBaseline(cleanup.workspace, baselineWorkspaceWeight)
context.assert(MODULE, '仅清理本轮结果且工作区重量恢复运行前基线', cleanup.response,
restored, `leftovers=${leftovers.length}, baselineWorkspaceWeight=${baselineWorkspaceWeight ?? 'null'}, actual=${cleanup.workspace?.actualGrossWeightKg ?? 'null'}`,
NEW_CONTRACT_OPTIONS)
context.createdResources.inboundWeightOcr.cleaned = restored
}