This commit is contained in:
2026-08-27 11:07:57 +08:00
parent b4c46280de
commit 4612181ab0
19 changed files with 424 additions and 68 deletions
@@ -5,7 +5,10 @@ import test from 'node:test'
import {
confirmedNetSummary,
normalizedGrossKg,
palletOptionsPath,
selectWeightPallet,
WEIGHT_PALLET_OPTIONS_PAGE_SIZE,
weightPalletOptionRecords,
workspaceWeightMatchesBaseline,
} from './inbound-weight-ocr.mjs'
@@ -42,7 +45,15 @@ test('cleanup restores a non-empty carton fallback when there were no confirmed
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=/)
const optionsPath = palletOptionsPath('10001', '20001', ' TP-09 ')
assert.match(optionsPath, /\/weight-pallet-options\?tenantId=10001&(?:page=1&size=50|size=50&page=1)/)
assert.match(optionsPath, /keyword=TP-09/)
assert.equal(WEIGHT_PALLET_OPTIONS_PAGE_SIZE, 50)
assert.deepEqual(weightPalletOptionRecords({
records: [{ palletId: '1' }], page: 1, size: 50, total: 1000, pages: 20,
}), [{ palletId: '1' }])
assert.equal(weightPalletOptionRecords([{ palletId: '1' }]), null)
assert.equal(weightPalletOptionRecords({ records: [], page: 1, size: 1000 }), null)
assert.ok(source.indexOf('const palletOptionsResponse') < source.lastIndexOf('const uploadPath'))
assert.match(source, /form\.append\('file'/)
assert.doesNotMatch(source, /form\.append\('palletId'/)
@@ -4,6 +4,7 @@ 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'])
@@ -202,8 +203,24 @@ 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)}`
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 weightPalletOptionRecords(page) {
if (!page || Array.isArray(page) || !Array.isArray(page.records)) return null
if (!Number.isInteger(Number(page.page)) || Number(page.page) < 1) return null
if (!Number.isInteger(Number(page.size)) || Number(page.size) < 1
|| Number(page.size) > WEIGHT_PALLET_OPTIONS_PAGE_SIZE) return null
if (page.records.length > Number(page.size)) return null
return page.records
}
async function createUploadedResult(context, target, pallet, image, overrides = {}) {
@@ -382,13 +399,14 @@ export async function run(context) {
const palletOptionsResponse = await context.request(
'GET', palletOptionsPath(target.tenantId, target.inboundId),
)
const palletOptions = context.apiSuccess(MODULE, '读取同仓称重托盘候选', palletOptionsResponse,
const palletOptionsPage = context.apiSuccess(MODULE, '分页读取同仓称重托盘候选', palletOptionsResponse,
`inboundId=${target.inboundId}`, NEW_CONTRACT_OPTIONS)
const palletOptions = weightPalletOptionRecords(palletOptionsPage)
const palletOptionsComplete = Array.isArray(palletOptions) && palletOptions.length > 0
&& palletOptions.every(palletSnapshotComplete)
context.assert(MODULE, '托盘候选包含稳定快照字段且无需状态或占用资格', palletOptionsResponse,
context.assert(MODULE, '托盘候选分页受控且包含稳定快照字段', palletOptionsResponse,
palletOptionsComplete,
`count=${Array.isArray(palletOptions) ? palletOptions.length : 'invalid'}, fields=palletId/palletCode/palletType/capacityCbm/tareWeightKg`,
`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) {
@@ -0,0 +1,114 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8')
const controller = read('../../firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java')
const service = read('../../firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java')
const warehouseClient = read('../../firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java')
const openapi = read('../../firstleg/src/main/resources/openapi/firstleg-v1.yaml')
const adminApi = read('../../../apps/admin-web/src/modules/inbounds/weight/api/weightOcr.ts')
const adminPanel = read('../../../apps/admin-web/src/modules/inbounds/weight/components/WeightOcrPanel.vue')
const adminBehavior = read('../../../apps/admin-web/src/modules/inbounds/weight/components/WeightOcrPanel.behavior.test.ts')
function endpointBlock(source, marker, nextMarker) {
const start = source.indexOf(marker)
assert.notEqual(start, -1, `missing contract marker: ${marker}`)
const end = source.indexOf(nextMarker, start + marker.length)
return source.slice(start, end === -1 ? source.length : end)
}
test('backend Controller exposes a keyword-filtered bounded page instead of an unbounded array', () => {
const block = endpointBlock(controller, '@GetMapping("/weight-pallet-options")', '@BusinessAudit')
assert.match(block, /ApiResponse<PageResult<InboundMeasurementResponses\.PalletSnapshot>>/)
assert.doesNotMatch(block, /ApiResponse<List<InboundMeasurementResponses\.PalletSnapshot>>/)
assert.match(block, /\bpage\b/)
assert.match(block, /\bsize\b/)
assert.match(block, /\bkeyword\b/)
const boundedSize = [...block.matchAll(/@Max\((\d+)\)/g)].map((match) => Number(match[1]))
assert.ok(boundedSize.some((maximum) => maximum > 0 && maximum <= 200),
'size must have a finite Controller maximum no greater than 200')
})
test('Firstleg forwards the bound to Warehouse instead of loading the full warehouse and slicing in memory', () => {
const clientBlock = endpointBlock(warehouseClient, '@GetMapping("/pallets/snapshots")', '@PostMapping')
assert.match(clientBlock, /ApiResponse<PageResult<PalletSnapshot>>/)
assert.doesNotMatch(clientBlock, /ApiResponse<List<PalletSnapshot>>/)
for (const parameter of ['page', 'size', 'keyword']) assert.match(clientBlock, new RegExp(`"${parameter}"`))
const serviceBlock = endpointBlock(service,
'public PageResult<InboundMeasurementResponses.PalletSnapshot> weightPalletOptions(',
'@Transactional')
assert.match(serviceBlock, /loadPalletSnapshotPage\(inbound, page, size, keyword\)/)
assert.match(service, /palletSnapshots\([\s\S]{0,300}?page[\s\S]{0,80}?size[\s\S]{0,80}?keyword/)
assert.doesNotMatch(serviceBlock, /\.stream\(\)[\s\S]*?\.skip\(/)
})
test('OpenAPI publishes page, size, keyword and a finite maximum without an array data contract', () => {
const block = endpointBlock(openapi,
' /api/platform/v1/first-leg/inbounds/{inboundId}/weight-pallet-options:',
' /api/platform/v1/first-leg/inbounds/{inboundId}/weight-results:')
for (const parameter of ['page', 'size', 'keyword']) {
assert.match(block, new RegExp(`name: ${parameter}`))
}
const maxima = [...block.matchAll(/maximum:\s*(\d+)/g)].map((match) => Number(match[1]))
assert.ok(maxima.some((maximum) => maximum > 0 && maximum <= 200),
'OpenAPI size parameter must declare a finite maximum no greater than 200')
const dataLine = block.match(/data:\s*\{[^\n]+/)?.[0] || ''
assert.doesNotMatch(dataLine, /type:\s*array/)
assert.match(block, /(?:records|WeightPalletOptionPage|PageResult)/)
})
test('Admin API requests a bounded remote-search page and does not type the response as an array', () => {
const block = endpointBlock(adminApi, 'export async function getWeightPalletOptions', 'export async function uploadWeightImage')
assert.match(block, /\bpage\b/)
assert.match(block, /\bsize\b/)
assert.match(block, /\bkeyword\b/)
assert.doesNotMatch(block, /ApiEnvelope<WeightPalletOption\[\]>/)
assert.match(block, /(?:records|PageResult|PageResource|PageResponse)/)
})
test('Admin skips candidates without weight edit permission or an editable inbound state', () => {
const loader = endpointBlock(adminPanel, 'async function loadPalletOptions', 'function chooseFile')
const gate = endpointBlock(adminPanel, 'const canLoadPalletOptions', 'const virtualPalletOptions')
assert.match(gate, /canEdit\.value/)
assert.match(gate, /editable\.value/)
assert.match(loader, /!canLoadPalletOptions\.value/)
})
test('Admin behavior tests prove both permission and inbound-state request suppression', () => {
const cases = [...adminBehavior.matchAll(/it\((['"`])([\s\S]*?)\1,\s*async\s*\(\)\s*=>\s*\{([\s\S]*?)\n\s*\}\)/g)]
const suppressionCases = cases.filter(([, , title, body]) => /getWeightPalletOptions/.test(body)
&& /not\.toHaveBeenCalled/.test(body))
assert.ok(suppressionCases.some(([, , title]) => /edit|permission|权限/i.test(title)),
'missing behavior case: no weight:edit must not request pallet candidates')
assert.ok(suppressionCases.some(([, , title]) => /status|editable|状态/i.test(title)),
'missing behavior case: non-editable inbound status must not request pallet candidates')
})
test('Admin remote search is bounded and never accumulates every candidate', () => {
assert.match(adminPanel, /\bremote\b/)
assert.match(adminPanel, /:remote-method=/)
const sizes = [...adminPanel.matchAll(/(?:PALLET|pallet).{0,40}(?:PAGE_SIZE|pageSize)\s*=\s*(\d+)/g)]
.map((match) => Number(match[1]))
assert.ok(sizes.some((size) => size > 0 && size <= 200),
'Admin remote search page size must be finite and no greater than 200')
assert.doesNotMatch(adminPanel, /palletOptions\.value\.(?:push|unshift)\s*\(/)
assert.doesNotMatch(adminPanel, /palletOptions\.value\s*=\s*\[\s*\.\.\.palletOptions\.value/)
assert.match(adminBehavior, /(?:remote search|远程搜索|keyword).{0,220}(?:size|page)/is)
})
test('Admin uses a virtualized selector so a large response cannot create one DOM option per candidate', () => {
assert.match(adminPanel, /<el-select-v2\b/)
assert.doesNotMatch(adminPanel, /<el-option[\s\S]*?v-for="option in palletOptions"/)
})
test('historical pallet snapshot remains read-only visible when it is no longer eligible', () => {
const cards = endpointBlock(adminPanel, '<article v-for="(row, index) in rows"', '</article>')
assert.match(cards, /row\.palletCode/)
assert.match(cards, /row\.palletType/)
assert.match(cards, /row\.palletTareWeightKg/)
assert.match(adminBehavior, /(?:historical|history|历史|失去候选|no longer eligible).{0,220}(?:palletCode|托盘编码|TP-)/is)
})