diff --git a/scripts/admin-test-env-modules/inbound-weight-ocr-contract.test.mjs b/scripts/admin-test-env-modules/inbound-weight-ocr-contract.test.mjs new file mode 100644 index 000000000..c26281c7d --- /dev/null +++ b/scripts/admin-test-env-modules/inbound-weight-ocr-contract.test.mjs @@ -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/) +}) diff --git a/scripts/admin-test-env-modules/inbound-weight-ocr.mjs b/scripts/admin-test-env-modules/inbound-weight-ocr.mjs index 96c01e29e..a0962a2d1 100644 --- a/scripts/admin-test-env-modules/inbound-weight-ocr.mjs +++ b/scripts/admin-test-env-modules/inbound-weight-ocr.mjs @@ -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 }