import { readFile } from 'node:fs/promises' import { basename } from 'node:path' import process from 'node:process' const MODULE = 'inbound-weight-ocr' const SCENARIO = 'weight-image-recognition-and-confirmation' const DEFAULT_IMAGE = 'C:/Users/BY-OY/Desktop/上传图片包/测量图片/测量图片/orig_1cf27695-b2c3-44b3-98e3-c0be8b80f79a.JPG' const EDITABLE_STATUSES = new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED']) const OCR_STATUSES = new Set(['RECOGNIZED', 'FAILED']) const NEW_CONTRACT_OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } const SCENARIO_OPTIONS = { scenario: SCENARIO } function id(value) { return value == null ? '' : String(value) } function apiOk(response) { return response?.http === 200 && String(response?.body?.code) === '200' } 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)) } function expectedRejection(response) { return response?.http === 200 && response?.body && String(response.body.code) !== '200' } function markerVisible(detail, marker) { const values = [ detail?.publicRemark, detail?.summary?.inboundCode, detail?.summary?.orderNo, detail?.summary?.customerName, detail?.summary?.companyName, ...(Array.isArray(detail?.internalNotes) ? detail.internalNotes.map((note) => note?.content) : []), ] return values.some((value) => typeof value === 'string' && value.includes(marker)) } function candidateValues(context) { const shared = context.context || {} return [ shared.inboundWeightOcr, shared.ocrInbound, shared.testInbound, shared.createdInbound, shared.inboundId && shared.tenantId ? { inboundId: shared.inboundId, tenantId: shared.tenantId, marker: shared.inboundMarker } : null, ].filter(Boolean) } async function resolveMarkedInbound(context) { for (const candidate of candidateValues(context)) { const tenantId = id(candidate.tenantId || candidate.customerId) const inboundId = id(candidate.inboundId || candidate.id) if (!tenantId || !inboundId) continue const response = await context.request( 'GET', `/api/platform/inbounds/${encodeURIComponent(inboundId)}?tenantId=${encodeURIComponent(tenantId)}`, ) const detail = apiOk(response) ? response.body.data : null const marked = markerVisible(detail, context.marker) const editable = EDITABLE_STATUSES.has(detail?.summary?.processStatus) if (detail && marked && editable) return { tenantId, inboundId, detail, response } } const filters = [ `inboundCode=${encodeURIComponent(context.marker)}`, `orderNo=${encodeURIComponent(context.marker)}`, ] for (const filter of filters) { const pageResponse = await context.request('GET', `/api/platform/inbounds?page=1&size=20&${filter}`) if (!apiOk(pageResponse)) continue for (const row of pageResponse.body.data?.records || []) { const tenantId = id(row.tenantId) const inboundId = id(row.id) if (!tenantId || !inboundId || !EDITABLE_STATUSES.has(row.processStatus)) continue const detailResponse = await context.request( 'GET', `/api/platform/inbounds/${encodeURIComponent(inboundId)}?tenantId=${encodeURIComponent(tenantId)}`, ) const detail = apiOk(detailResponse) ? detailResponse.body.data : null if (detail && markerVisible(detail, context.marker)) { return { tenantId, inboundId, detail, response: detailResponse } } } } return null } async function multipart(context, path, file, token = context.token) { const form = new FormData() form.append('file', new Blob([file.bytes], { type: file.contentType }), file.name) const headers = { Accept: 'application/json' } if (token) headers.Authorization = `Bearer ${token}` try { const response = await fetch(`${context.options.gateway}${path}`, { method: 'POST', headers, body: form, signal: AbortSignal.timeout(120_000), }) const content = await response.text() let payload = null try { payload = content ? JSON.parse(content) : null } catch { /* Keep bounded diagnostic text only. */ } return { method: 'POST', 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'), } } catch (error) { return { method: 'POST', path, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null, } } } function jpegDimensions(bytes) { if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return null const standalone = new Set([0x01, 0xd8, 0xd9, 0xd0, 0xd1, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7]) const startsOfFrame = new Set([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf]) let offset = 2 while (offset + 3 < bytes.length) { while (offset < bytes.length && bytes[offset] !== 0xff) offset += 1 while (offset < bytes.length && bytes[offset] === 0xff) offset += 1 if (offset >= bytes.length) break const marker = bytes[offset] offset += 1 if (standalone.has(marker)) continue if (offset + 1 >= bytes.length) break const length = bytes[offset] * 256 + bytes[offset + 1] if (length < 2 || offset + length > bytes.length) break if (startsOfFrame.has(marker) && length >= 7) { return { height: bytes[offset + 3] * 256 + bytes[offset + 4], width: bytes[offset + 5] * 256 + bytes[offset + 6], } } offset += length } return null } function workspacePath(tenantId, inboundId) { return `/api/platform/v1/first-leg/inbounds/${encodeURIComponent(inboundId)}/measurement?tenantId=${encodeURIComponent(tenantId)}` } function resultPath(inboundId, resultId) { return `/api/platform/v1/first-leg/inbounds/${encodeURIComponent(inboundId)}/weight-results/${encodeURIComponent(resultId)}` } function confirmPath(tenantId, inboundId, resultId) { return `${resultPath(inboundId, resultId)}/confirm?tenantId=${encodeURIComponent(tenantId)}` } function deletePath(tenantId, inboundId, resultId, version) { return `${resultPath(inboundId, resultId)}?tenantId=${encodeURIComponent(tenantId)}&version=${version}` } 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, `file=${image.name}, size=${image.bytes.length}`, SCENARIO_OPTIONS) const fileId = id(upload?.fileId) context.assert(MODULE, '上传响应保持文件 ID 字符串契约', uploadResponse, Boolean(fileId) && typeof upload?.fileId === 'string', `fileIdType=${typeof upload?.fileId}, status=${upload?.recognitionStatus ?? 'missing'}`, SCENARIO_OPTIONS) context.assert(MODULE, 'OCR 上传响应字段完整且置信度受限', uploadResponse, upload && OCR_STATUSES.has(upload.recognitionStatus) && Object.hasOwn(upload, 'weightValue') && Object.hasOwn(upload, 'weightUnit') && Object.hasOwn(upload, 'confidence') && (upload.confidence == null || (Number(upload.confidence) >= 0 && Number(upload.confidence) <= 1)), `status=${upload?.recognitionStatus ?? 'missing'}, hasWeight=${upload?.weightValue != null}, confidence=${upload?.confidence ?? 'null'}`, SCENARIO_OPTIONS) if (!fileId) throw new Error('称重图片上传未返回 fileId,无法登记和清理测试资源') let recognized = upload if (overrides.retry) { const retryPath = `${imagePath}/${encodeURIComponent(fileId)}/recognize` const retryResponse = await context.request('POST', retryPath) const retry = context.apiSuccess(MODULE, '重试当前称重图片 OCR', retryResponse, `fileId=${fileId}`, SCENARIO_OPTIONS) context.assert(MODULE, 'OCR 重试保持文件身份并返回识别契约', retryResponse, id(retry?.fileId) === fileId && OCR_STATUSES.has(retry?.recognitionStatus) && Object.hasOwn(retry || {}, 'weightValue') && Object.hasOwn(retry || {}, 'confidence'), `sameFile=${id(retry?.fileId) === fileId}, status=${retry?.recognitionStatus ?? 'missing'}`, SCENARIO_OPTIONS) if (retry) recognized = retry } 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, ocrWeightUnit: overrides.manual ? null : recognized.weightUnit, confidence: overrides.manual ? null : recognized.confidence, } const createPath = `/api/platform/v1/first-leg/inbounds/${encodeURIComponent(target.inboundId)}/weight-results?tenantId=${encodeURIComponent(target.tenantId)}` let createResponse = await context.request('POST', createPath, payload) let result = context.apiSuccess(MODULE, overrides.manual ? '登记人工录入重量结果' : '登记 OCR 原值', createResponse, `fileId=${fileId}`, SCENARIO_OPTIONS) if (!result && !overrides.manual) { createResponse = await context.request('POST', createPath, { ...payload, ocrRawText: null, ocrWeightValue: null, ocrWeightUnit: null, confidence: null, }) result = context.apiSuccess(MODULE, 'OCR 原值登记失败后建立可清理人工结果', createResponse, `fileId=${fileId}`, NEW_CONTRACT_OPTIONS) } 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 && 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 } } async function verifyImages(context, target, item, image) { const base = `/api/platform/v1/files/measurements/${encodeURIComponent(target.tenantId)}/${encodeURIComponent(target.inboundId)}/${encodeURIComponent(item.fileId)}` const original = await context.requestBinary('GET', base) context.assert(MODULE, '鉴权读取原始称重图片', original, original.http === 200 && original.bytes.length === image.bytes.length && /^image\/(jpeg|png|bmp)/i.test(original.contentType), `contentType=${original.contentType || 'missing'}, bytes=${original.bytes.length}`, SCENARIO_OPTIONS) const thumbnail = await context.requestBinary('GET', `${base}/thumbnail`) const dimensions = jpegDimensions(thumbnail.bytes) context.assert(MODULE, '缩略图返回受限尺寸 JPEG', thumbnail, thumbnail.http === 200 && /^image\/jpeg/i.test(thumbnail.contentType) && thumbnail.bytes.length > 0 && dimensions && dimensions.width <= 480 && dimensions.height <= 320, `contentType=${thumbnail.contentType || 'missing'}, bytes=${thumbnail.bytes.length}, dimensions=${dimensions ? `${dimensions.width}x${dimensions.height}` : 'unreadable'}`, NEW_CONTRACT_OPTIONS) } async function loadWorkspace(context, target, name) { const response = await context.request('GET', workspacePath(target.tenantId, target.inboundId)) const workspace = context.apiSuccess(MODULE, name, response, `inboundId=${target.inboundId}`, SCENARIO_OPTIONS) return { response, workspace } } async function rejectWithoutMutation(context, target, item, name, payload) { const path = confirmPath(target.tenantId, target.inboundId, item.resultId) const response = await context.request('POST', path, payload) context.assert(MODULE, name, response, expectedRejection(response), `HTTP=${response.http}, code=${response.body?.code ?? 'missing'}`, SCENARIO_OPTIONS) const check = await loadWorkspace(context, target, `${name}后回查工作区`) const persisted = check.workspace?.weightResults?.find((row) => id(row.id) === item.resultId) context.assert(MODULE, `${name}未改变重量结果`, check.response, persisted && Number(persisted.version) === item.version && persisted.recognitionStatus !== 'CONFIRMED', `version=${persisted?.version ?? 'missing'}, status=${persisted?.recognitionStatus ?? 'missing'}`, SCENARIO_OPTIONS) } async function deleteResult(context, target, item, name, options = NEW_CONTRACT_OPTIONS) { const path = deletePath(target.tenantId, target.inboundId, item.resultId, item.version) const response = await context.request('DELETE', path) const passed = apiOk(response) context.addStep(MODULE, name, response, passed, `resultId=${item.resultId}, version=${item.version}`, options) if (passed) item.deleted = true return { response, passed } } 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 && 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)}` const image = await context.requestBinary('GET', imagePath) context.assert(MODULE, '删除结果后原始图片不可再读取', image, image.http === 200 && image.body && String(image.body.code) !== '200', `HTTP=${image.http}, code=${image.body?.code ?? 'missing'}`, NEW_CONTRACT_OPTIONS) } export async function run(context) { const scenarioStart = context.steps.length const created = [] const target = await resolveMarkedInbound(context) context.assert(MODULE, '仅选择带本轮测试标记的可编辑入库单', target?.response || null, Boolean(target), target ? `tenantId=${target.tenantId}, inboundId=${target.inboundId}, status=${target.detail.summary.processStatus}` : `marker=${context.marker}, 未找到本轮创建且状态为 ${[...EDITABLE_STATUSES].join('/')} 的入库单`, SCENARIO_OPTIONS) if (!target) { context.scenarios.push({ name: SCENARIO, module: MODULE, success: false, deploymentBlocked: false, passed: 0, failed: 1, }) return } context.context.inboundWeightOcr = { tenantId: target.tenantId, inboundId: target.inboundId, marker: context.marker, } context.createdResources.inboundWeightOcr = { tenantId: target.tenantId, inboundId: target.inboundId, resultIds: [], cleaned: false, } const imagePath = process.env.ADMIN_TEST_OCR_IMAGE || DEFAULT_IMAGE const imageBytes = await readFile(imagePath) const image = { bytes: imageBytes, name: basename(imagePath), contentType: 'image/jpeg' } const initial = await loadWorkspace(context, target, '读取 OCR 测试前重量工作区') if (!initial.workspace) throw new Error('无法读取测试入库单重量工作区') 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 && 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, '') context.assert(MODULE, '未认证请求不能上传称重图片', noAuth, noAuth.http === 401 || noAuth.http === 403, `HTTP=${noAuth.http}, code=${noAuth.body?.code ?? 'none'}`, SCENARIO_OPTIONS) const wrongType = await multipart(context, uploadPath, { bytes: new TextEncoder().encode(context.marker), name: `${context.marker}.txt`, contentType: 'text/plain', }) context.assert(MODULE, '非图片类型被拒绝且不模拟成功', wrongType, expectedRejection(wrongType), `HTTP=${wrongType.http}, code=${wrongType.body?.code ?? 'missing'}`, SCENARIO_OPTIONS) const oversized = await multipart(context, uploadPath, { bytes: new Uint8Array(5 * 1024 * 1024 + 1), name: `${context.marker}.jpg`, contentType: 'image/jpeg', }) context.assert(MODULE, '超过5MB图片被拒绝', oversized, expectedRejection(oversized), `HTTP=${oversized.http}, code=${oversized.body?.code ?? 'missing'}`, SCENARIO_OPTIONS) try { 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) const registered = await loadWorkspace(context, target, '登记后回查 OCR 重量结果') const registeredRow = registered.workspace?.weightResults?.find((row) => id(row.id) === original.resultId) context.assert(MODULE, '工作区保留本轮 OCR 原值且未覆盖历史结果', registered.response, registeredRow && !baselineIds.has(original.resultId) && closeNumber(registeredRow.ocrWeightValue, original.result.ocrWeightValue) && registeredRow.ocrWeightUnit === original.result.ocrWeightUnit, `resultFound=${Boolean(registeredRow)}, baselineCount=${baselineIds.size}, currentCount=${registered.workspace?.weightResults?.length ?? 'n/a'}`, SCENARIO_OPTIONS) const staleConfirm = await context.request('POST', confirmPath(target.tenantId, target.inboundId, original.resultId), { version: original.version + 1 }) context.assert(MODULE, '旧版本确认返回并发冲突', staleConfirm, expectedConflict(staleConfirm), `HTTP=${staleConfirm.http}, code=${staleConfirm.body?.code ?? 'missing'}`, NEW_CONTRACT_OPTIONS) const recognizedValue = Number(original.result.ocrWeightValue) const recognizedUnit = original.result.ocrWeightUnit const recognizedGrossKg = normalizedGrossKg(recognizedValue, recognizedUnit) const canConfirmOriginal = recognizedGrossKg != null && recognizedGrossKg > Number(pallet.tareWeightKg) const confirmPayload = canConfirmOriginal ? { version: original.version } : { 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, `usedOriginal=${canConfirmOriginal}`, NEW_CONTRACT_OPTIONS) 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 && 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 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', deletePath(target.tenantId, target.inboundId, original.resultId, Math.max(0, original.version - 1))) 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, baselineWorkspaceWeight) 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, '零重量确认被拒绝', { version: manual.version, adjustedWeightValue: 0, adjustedWeightUnit: 'KG', adjustmentReason: context.marker, }) await rejectWithoutMutation(context, target, manual, '负重量确认被拒绝', { version: manual.version, adjustedWeightValue: -1, adjustedWeightUnit: 'KG', adjustmentReason: context.marker, }) await rejectWithoutMutation(context, target, manual, 'LB 0.001换算为零后被拒绝', { version: manual.version, adjustedWeightValue: 0.001, adjustedWeightUnit: 'LB', adjustmentReason: context.marker, }) await rejectWithoutMutation(context, target, manual, '超出15位整数重量被拒绝', { version: manual.version, adjustedWeightValue: 1_000_000_000_000_000, adjustedWeightUnit: 'KG', adjustmentReason: context.marker, }) 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', adjustmentReason: `${context.marker} 人工确认 LB`, }) const adjusted = context.apiSuccess(MODULE, '人工改重与确认原子完成', adjustedConfirmResponse, `adjusted=${adjustedValue} LB`, NEW_CONTRACT_OPTIONS) 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?.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, baselineWorkspaceWeight) } finally { for (const item of created.filter((value) => !value.deleted)) { await deleteResult(context, target, item, '异常收尾清理本轮重量结果', NEW_CONTRACT_OPTIONS) } 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 && 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 } const steps = context.steps.slice(scenarioStart) 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, }) }