607 lines
33 KiB
JavaScript
607 lines
33 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 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
|
|
}
|
|
|
|
export function validRecognition(result) {
|
|
if (!result || !OCR_STATUSES.has(result.recognitionStatus)) return false
|
|
if (result.confidence != null && (!Number.isFinite(Number(result.confidence)) || Number(result.confidence) < 0 || Number(result.confidence) > 1)) return false
|
|
if (result.recognitionStatus === 'FAILED') return result.weightValue == null
|
|
return result.weightValue != null && Number(result.weightValue) > 0
|
|
&& normalizedGrossKg(result.weightValue, result.weightUnit) != null
|
|
}
|
|
|
|
export function sameOptionalWeight(actual, expected) {
|
|
return actual == null || expected == null ? actual == null && expected == null : closeNumber(actual, expected)
|
|
}
|
|
|
|
export function confirmedVersion(version, hasAdjustment) {
|
|
return Number(version) + (hasAdjustment ? 2 : 1)
|
|
}
|
|
|
|
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,
|
|
validRecognition(upload),
|
|
`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 && validRecognition(retry),
|
|
`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
|
|
if (!imagePath) throw new Error('请显式配置 ADMIN_TEST_OCR_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)
|
|
&& sameOptionalWeight(registeredRow.ocrWeightValue, original.result.ocrWeightValue)
|
|
&& (registeredRow.ocrWeightUnit ?? null) === (original.result.ocrWeightUnit ?? null),
|
|
`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) === confirmedVersion(original.version, !canConfirmOriginal)
|
|
&& 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,
|
|
})
|
|
}
|