Files
baiyue-firstleg/scripts/admin-test-env-modules/inbound-weight-ocr.mjs
T

595 lines
32 KiB
JavaScript

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'
export const WEIGHT_PALLET_OPTIONS_PAGE_SIZE = 50
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'
}
export function isTransientGatewayFailure(response) {
return response?.http === 0 || response?.http === 502 || response?.http === 503
}
async function unauthenticatedMultipart(context, path, file) {
let response = null
for (let attempt = 1; attempt <= 3; attempt += 1) {
response = await multipart(context, path, file, '')
if (!isTransientGatewayFailure(response) || attempt === 3) return response
await new Promise((resolve) => setTimeout(resolve, attempt * 750))
}
return response
}
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}`
}
export function palletOptionsPath(tenantId, inboundId, keyword = '') {
const params = new URLSearchParams({
tenantId: id(tenantId),
page: '1',
size: String(WEIGHT_PALLET_OPTIONS_PAGE_SIZE),
})
const normalizedKeyword = String(keyword || '').trim()
if (normalizedKeyword) params.set('keyword', normalizedKeyword)
return `/api/platform/v1/first-leg/inbounds/${encodeURIComponent(inboundId)}/weight-pallet-options?${params.toString()}`
}
export function normalizeWeightPalletOptionPage(page) {
if (!page || Array.isArray(page) || !Array.isArray(page.records)) return null
const normalized = Object.fromEntries(['page', 'size', 'total', 'pages']
.map((key) => [key, Number(page[key])]))
if (!Number.isInteger(normalized.page) || normalized.page < 1) return null
if (!Number.isInteger(normalized.size) || normalized.size < 1
|| normalized.size > WEIGHT_PALLET_OPTIONS_PAGE_SIZE) return null
if (!Number.isInteger(normalized.total) || normalized.total < 0) return null
if (!Number.isInteger(normalized.pages) || normalized.pages < 0) return null
if (page.records.length > normalized.size) return null
return { records: page.records, ...normalized }
}
export function weightPalletOptionRecords(page) {
return normalizeWeightPalletOptionPage(page)?.records ?? null
}
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 palletOptionsPage = context.apiSuccess(MODULE, '分页读取同仓称重托盘候选', palletOptionsResponse,
`inboundId=${target.inboundId}`, NEW_CONTRACT_OPTIONS)
const normalizedPalletOptionsPage = normalizeWeightPalletOptionPage(palletOptionsPage)
const palletOptions = normalizedPalletOptionsPage?.records ?? null
context.assert(MODULE, '托盘候选成功响应为正式分页对象且数字字符串可归一化', palletOptionsResponse,
normalizedPalletOptionsPage !== null,
`page=${palletOptionsPage?.page ?? 'missing'}, size=${palletOptionsPage?.size ?? 'missing'}, total=${palletOptionsPage?.total ?? 'missing'}, pages=${palletOptionsPage?.pages ?? 'missing'}`,
NEW_CONTRACT_OPTIONS)
const palletOptionsComplete = Array.isArray(palletOptions) && palletOptions.length > 0
&& palletOptions.every(palletSnapshotComplete)
context.assert(MODULE, '托盘候选分页受控且包含稳定快照字段', palletOptionsResponse,
palletOptionsComplete,
`page=${palletOptionsPage?.page ?? 'missing'}, size=${palletOptionsPage?.size ?? 'missing'}, count=${Array.isArray(palletOptions) ? palletOptions.length : 'invalid'}, maxSize=${WEIGHT_PALLET_OPTIONS_PAGE_SIZE}, 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 unauthenticatedMultipart(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?.actualNetWeightKg, confirmedSummary.total, 0.0011),
`confirmedCount=${confirmedSummary.count}, confirmedNetTotal=${confirmedSummary.total}, actual=${afterConfirm.workspace?.actualNetWeightKg ?? '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,
})
}