This commit is contained in:
2026-10-21 15:10:44 +08:00
parent 40a03978cd
commit 6fe632f28e
34 changed files with 372 additions and 110 deletions
+3 -1
View File
@@ -2,7 +2,9 @@
`fleet-return-redispatch-acceptance.mjs` 的原出发证明读取本轮 `LOADING_COMPLETE` 回执与 `departureDeliveries`;重派在 PDA 装车完成后回查自动生成的出库/派送及两箱已发运事实,不再调用手工安排派送或确认发车。原键重放和跨操作员拒绝仍由共享装车验证覆盖,原签收不漂移、源订单完成事件审计及资源分别释放仍须独立核对。离线模拟不能代替匹配部署的真实 HTTP。
固定托盘造数中,`inbound-measurement.mjs` 缺少 `TP-001` 或 `YW-TP-002` 时记录阻断并停止,不自动选择其他托盘。仅明确设置内部 `allowAnyExistingPallet` 的通用场景可读取当前仓库已有资源;两者均不创建托盘。
固定托盘造数中,`inbound-measurement.mjs` 只复用 `TP-001`、`YW-TP-002` 指定池中的现有托盘;一只可复用,不要求两只同时存在,两只均缺失才阻断,不自动选择池外托盘。仅明确设置内部 `allowAnyExistingPallet` 的通用场景可读取当前仓库已有资源;两者均不创建托盘。
OCR模块必须通过`ADMIN_TEST_OCR_IMAGE`显式指定本轮图片,不依赖个人桌面路径。合成称重测试图只证明上传、识别响应及人工确认协议,不代表现场称重照片或设备验收。费用项目模块按受控编码验证112条正式基线,合法扩展项目保留并单独核对分页和筛选。散板普通状态接口仅验证取消及非法派车拒绝,原单须出现在待匹配需求池;需求池接车到签收另列覆盖缺口,不以手工跳状态冒充履约。
先运行 `node scripts/acceptance-overview.mjs`(后端根目录,无网络)查看当前默认与可选模块。范围来自真实注册表;本文件描述各模块用途与依赖,不代表所有模块默认执行或已通过。报告解读及跨端/专项边界见 [总索引](../README.md#先看范围再看证据),最新运行状态只查外层部署契约。
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs'
import test from 'node:test'
import { normalizeModuleOrder, parseArguments } from '../admin-test-env-acceptance.mjs'
import { cleanupSucceeded, isAcceptanceFeeItem } from './fee-items.mjs'
import { cleanupSucceeded, isAcceptanceFeeItem, formalFeeItems } from './fee-items.mjs'
const source = readFileSync(new URL('./fee-items.mjs', import.meta.url), 'utf8')
@@ -31,7 +31,15 @@ test('stale cleanup only recognizes the acceptance-owned code and name pair', ()
test('baseline checks run only after stale acceptance cleanup', () => {
assert.ok(source.indexOf('cleanupStaleAcceptanceItems(context)')
< source.indexOf("'正式目录112条且默认20条对应6页'"))
< source.indexOf("'全部目录默认20条分页与完整列表一致'"))
})
test('正式费用按编码识别,扩展停用项目保留且不掩盖缺失正式编码', () => {
const rows = [{ feeCode: 'FBA_HEAD_FREIGHT' }, { feeCode: 'FEE_TYPE_001' },
{ feeCode: 'PRIVATE_LAST_MILE_DELIVERY' }, { feeCode: 'FEE_EXT_OTHER', status: 'DISABLED' }]
assert.deepEqual(formalFeeItems(rows), rows.slice(0, 3))
assert.equal(rows.length, 4)
assert.equal(formalFeeItems([{ feeCode: 'FEE_TYPE_111' }]).length, 0)
})
test('formal baseline includes both system fee items in addition to the 110 controlled items', () => {
+19 -10
View File
@@ -51,6 +51,11 @@ function expectedCodes() {
...Array.from({ length: 110 }, (_, index) => `FEE_TYPE_${String(index + 1).padStart(3, '0')}`),
].sort()
}
/** 正式基线按受控编码识别;其他业务合法扩展不计入112条基线。 */
export function formalFeeItems(rows) {
const codes = new Set(expectedCodes())
return rows.filter(row => codes.has(row.feeCode))
}
export function isAcceptanceFeeItem(row) {
return /^ACPT_\d+$/.test(String(row?.feeCode || ''))
&& String(row?.feeTypeName || '').startsWith('全量验收费用-')
@@ -91,16 +96,16 @@ export async function run(context) {
const initialPageResponse = await context.request('GET', query({ page: 1, size: 20 }))
const initialPage = context.apiSuccess(MODULE, '费用项目查看权限与分页查询', initialPageResponse,
'page=1,size=20', OPTIONS)
context.assert(MODULE, '正式目录112条且默认20条对应6页', initialPageResponse,
records(initialPage).length === 20 && Number(initialPage?.total) === BASELINE_COUNT
&& Number(initialPage?.pages) === 6 && Number(initialPage?.page) === 1,
const initial = await listAll(context)
context.assert(MODULE, '全部目录默认20条分页与完整列表一致', initialPageResponse,
records(initialPage).length === Math.min(20, initial.rows.length) && Number(initialPage?.total) === initial.rows.length
&& Number(initialPage?.pages) === Math.ceil(initial.rows.length / 20) && Number(initialPage?.page) === 1,
`records=${records(initialPage).length}, total=${initialPage?.total ?? 'n/a'}, pages=${initialPage?.pages ?? 'n/a'}`, OPTIONS)
context.assert(MODULE, '费用项目查询不传 accountId 仍成功', initialPageResponse,
success(initialPageResponse) && !String(initialPageResponse.path || initialPageResponse.request?.path || '').includes('accountId'),
`path=${initialPageResponse.path || 'missing'}`, OPTIONS)
const initial = await listAll(context)
const initialRows = initial.rows
const initialRows = formalFeeItems(initial.rows)
const domestic = initialRows.filter((row) => row.tradeScope === 'DOMESTIC')
const international = initialRows.filter((row) => row.tradeScope === 'INTERNATIONAL')
const actualCodes = initialRows.map((row) => row.feeCode).sort()
@@ -126,7 +131,8 @@ export async function run(context) {
const response = await context.request('GET', query({ tradeScope: scope, page: 1, size: 200 }))
const page = context.apiSuccess(MODULE, `按 ${scope} 贸易范围筛选`, response, '', OPTIONS)
context.assert(MODULE, `${scope} 筛选数量和范围正确`, response,
Number(page?.total) === expected && records(page).every((row) => row.tradeScope === scope),
Number(page?.total) === initial.rows.filter(row => row.tradeScope === scope).length
&& formalFeeItems(records(page)).length === expected && records(page).every((row) => row.tradeScope === scope),
`total=${page?.total ?? 'n/a'}, records=${records(page).length}`, OPTIONS)
}
@@ -142,8 +148,9 @@ export async function run(context) {
const activeResponse = await context.request('GET', query({ status: 'ACTIVE', page: 1, size: 200 }))
const activePage = context.apiSuccess(MODULE, '按 ACTIVE 状态筛选费用项目', activeResponse, '', OPTIONS)
context.assert(MODULE, 'ACTIVE 筛选返回112条且无停用记录', activeResponse,
Number(activePage?.total) === BASELINE_COUNT && records(activePage).every((row) => row.status === 'ACTIVE'),
context.assert(MODULE, 'ACTIVE 筛选覆盖112条正式项目且无停用记录', activeResponse,
Number(activePage?.total) === initial.rows.filter(row => row.status === 'ACTIVE').length
&& formalFeeItems(records(activePage)).length === BASELINE_COUNT && records(activePage).every((row) => row.status === 'ACTIVE'),
`total=${activePage?.total ?? 'n/a'}`, OPTIONS)
const merchantLoginResponse = await context.request('POST', '/api/auth/login', {
@@ -273,8 +280,10 @@ export async function run(context) {
`feeItemId=${feeItemId}`, OPTIONS)
}
const final = await listAll(context)
context.assert(MODULE, '清理后恢复112条正式基线且无临时编码或名称', final.response,
final.rows.length === BASELINE_COUNT && !final.rows.some((row) => row.feeCode === createPayload.feeCode
context.assert(MODULE, '清理后保留原目录与112条正式基线且无本轮临时项目', final.response,
formalFeeItems(final.rows).length === BASELINE_COUNT
&& final.rows.map(row => row.feeCode).sort().join(',') === initial.rows.map(row => row.feeCode).sort().join(',')
&& !final.rows.some((row) => row.feeCode === createPayload.feeCode
|| String(row.feeTypeName).includes(suffix)),
`rows=${final.rows.length}, temporary=${final.rows.filter((row) => row.feeCode === createPayload.feeCode).length}`, OPTIONS)
context.createdResources.feeItems = {
@@ -269,11 +269,11 @@ export async function reusablePallets(context, warehouseId) {
}
pallets.push({ id: asId(exact.id), code: palletCode })
}
if (missingFixed.length === 0) {
if (pallets.length > 0) {
reusablePalletsByWarehouse.set(scopedCacheKey, pallets)
return pallets
}
context.assert(MODULE, '固定称重托盘主数据完整', null, false,
context.assert(MODULE, '指定称重托盘池至少存在一只可复用托盘', null, false,
`warehouseId=${warehouseId}, missing=${missingFixed.join(',')}`, OPTIONS)
context.addCoverageGap?.(MODULE, 'measurement-pallet-fixture', '固定称重托盘主数据缺失',
`warehouseId=${warehouseId}, missing=${missingFixed.join(',')};停止固定托盘造数`,
@@ -5,7 +5,6 @@ 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 }
@@ -392,7 +391,8 @@ export async function run(context) {
tenantId: target.tenantId, inboundId: target.inboundId, resultIds: [], cleaned: false,
}
const imagePath = process.env.ADMIN_TEST_OCR_IMAGE || DEFAULT_IMAGE
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 测试前重量工作区')
@@ -13,7 +13,7 @@ test('固定托盘缺失停止造数,不查询其他托盘;补齐后同仓
const query = new URL(path, 'http://offline').searchParams
const code = query.get('palletCode')
assert.ok(code, '固定模式禁止用无编号列表替换缺失资源')
return { records: complete || code === 'TP-001'
return { records: complete
? [{ id: code === 'TP-001' ? '1' : '2', palletCode: code, warehouseId: 'offline-fixed-pallet' }] : [] }
},
apiSuccess(_module, _name, response) { return response },
@@ -31,6 +31,20 @@ test('固定托盘缺失停止造数,不查询其他托盘;补齐后同仓
assert.equal(requests.length, 4, '失败不得缓存为成功')
})
test('指定托盘池仅剩一只时可复用,不要求同时存在两只', async () => {
const context = {
context: {},
async request(_method, path) {
const code = new URL(path, 'http://offline').searchParams.get('palletCode')
assert.ok(['TP-001', 'YW-TP-002'].includes(code))
return { records: code === 'YW-TP-002' ? [{ id: '2', palletCode: code, warehouseId: 'offline-single-pallet' }] : [] }
},
apiSuccess(_module, _name, response) { return response },
assert(_module, _name, _response, passed) { assert.equal(passed, true) },
}
assert.deepEqual(await reusablePallets(context, 'offline-single-pallet'), [{ id: '2', code: 'YW-TP-002' }])
})
const acceptance = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
const orders = readFileSync(new URL('./orders.mjs', import.meta.url), 'utf8')
const measurement = readFileSync(new URL('./inbound-measurement.mjs', import.meta.url), 'utf8')
@@ -62,7 +76,7 @@ test('称重只随机复用固定托盘且不创建新托盘', () => {
assert.match(measurement, /context\.request\('GET',[\s\S]*\/api\/platform\/v1\/pallets\?page=1&size=20/)
assert.match(measurement, /randomlyReusePalletIds\(candidates, palletCount/)
assert.match(measurement, /随机复用原有称重托盘/)
assert.match(measurement, /固定称重托盘主数据完整/)
assert.match(measurement, /指定称重托盘池至少存在一只可复用托盘/)
assert.doesNotMatch(measurement, /context\.request\('POST', '\/api\/platform\/v1\/pallets/)
assert.doesNotMatch(acceptance, /createPhase3FreshMeasurementPallet/)
})
@@ -54,7 +54,8 @@ test('phase3 script only discovers the current outbound task and blocks missing
assert.match(source, /providerEventId/)
assert.match(source, /\^\\d\{16,/)
assert.doesNotMatch(source, /request\(context,\s*'POST',\s*BASE/)
assert.doesNotMatch(source, /manual-nodes|manual-results|BILL_OF_LADING/)
// 覆盖缺口说明可以提及人工接口;禁止的是构造这些接口路径。
assert.doesNotMatch(source, /\/manual-(?:nodes|results)(?=['"`/?])|BILL_OF_LADING/)
assert.doesNotMatch(source, /records\[0\]/)
})
@@ -14,6 +14,9 @@ const PHASE4_READ_CODES = [
]
const PHASE4_WRITE_SUFFIXES = [':create', ':edit', ':status', ':export', ':adjust', ':delete', ':review']
const PHASE5_OPERATOR_CODES = [
'platform:overseas-warehouse:dispatch:create',
'platform:overseas-warehouse:dispatch:loading-complete',
'platform:overseas-warehouse:dispatch:depart',
'platform:overseas-warehouse:dispatch:list',
'platform:overseas-warehouse:dispatch:edit',
'platform:overseas-warehouse:dispatch:cancel',
@@ -90,7 +93,7 @@ export async function run(context) {
context.assert(MODULE, '操作员具备国内入仓权限、四期只读权限与五期履约权限', rolesResponse,
operatorCodes.has('platform:firstleg:inbound:loading:view')
&& operatorCodes.has('platform:firstleg:inbound:loading:edit')
&& operatorCodes.size === 31
&& operatorCodes.size === 34
&& PHASE4_READ_CODES.every((code) => operatorCodes.has(code))
&& PHASE5_OPERATOR_CODES.every((code) => operatorCodes.has(code))
&& !operatorCodes.has('platform:firstleg:inbound:loading:complete')
@@ -26,7 +26,7 @@ test('warehouse operator and tally use the exact phase-four and phase-five permi
assert.match(iamSql, new RegExp(`'WAREHOUSE_OPERATOR','${code}'`))
assert.match(iamSql, new RegExp(`'WAREHOUSE_TALLY','${code}'`))
}
assert.match(acceptance, /operatorCodes\.size === 31/)
assert.match(acceptance, /operatorCodes\.size === 34/)
assert.match(acceptance, /tallyCodes\.size === 27/)
assert.match(acceptance, /PHASE4_WRITE_SUFFIXES/)
assert.match(iamSql, /assert_phase4_warehouse_role_minimum/)
@@ -152,8 +152,7 @@ function fixture(options = {}) {
const replay = state.looseboardStatuses.get(body.idempotencyKey)
if (replay) return replay.hash === hash ? response(replay.result) : response(null, 'A0409')
if (Number(body.version) !== state.looseboard.version) return response(null, 'A0409')
const allowed = (state.looseboard.status === 'PENDING_ACCEPTANCE' && ['DISPATCHED', 'CANCELLED'].includes(body.status))
|| (state.looseboard.status === 'DISPATCHED' && body.status === 'CANCELLED')
const allowed = state.looseboard.status === 'PENDING_ACCEPTANCE' && body.status === 'CANCELLED'
if (!allowed) return response(null, 'A0409')
const from = state.looseboard.status
state.looseboard = { ...state.looseboard, status: body.status, version: state.looseboard.version + 1 }
@@ -162,6 +161,7 @@ function fixture(options = {}) {
state.looseboardStatuses.set(body.idempotencyKey, { hash, result })
return response(result)
}
if (requestPath.includes('/pending-looseboard-demands?') && method === 'GET') return response(page([state.looseboard]))
if (requestPath.includes('/local-looseboard-orders/LB1?') && method === 'GET') return response(looseboardDetail())
if (requestPath.includes('/local-looseboard-orders/LB1?') && method === 'PUT') {
if (Number(body.version) !== state.looseboard.version) return response(null, 'A0409')
@@ -222,7 +222,8 @@ test('procurement creation review boundaries and transport cancellation paths pa
await run(context)
assert.equal(context.steps.every((step) => step.passed), true,
context.steps.filter((step) => !step.passed).map((step) => `${step.name}: ${step.evidence}`).join('\n'))
assert.equal(context.gaps.length, 0)
assert.equal(context.gaps.length, 1)
assert.equal(context.gaps[0][1], 'looseboard-pool-fulfillment')
assert.equal(context.blockers.length, 0)
assert.equal(context.calls.filter(call => call.path === '/api/auth/login').length, 1)
assert.equal(context.calls.filter(call => call.path === '/api/auth/logout').length, 1)
@@ -373,7 +373,8 @@ async function truckFlow(context, customerId) {
after?.destination === `${payload.destination}-EDIT` && Number(after?.version) === Number(before?.version) + 1
&& after?.status === 'PENDING_REVIEW',
`version=${after?.version ?? 'missing'}, destination=${after?.destination || 'missing'}; ${trace(edited.response)}`, OPTIONS)
const stale = await context.request('PATCH', path, { version: Number(before?.version), destination: 'STALE-DO-NOT-APPLY' })
const stale = await context.request('PATCH', path, { ...payload, idempotencyKey: undefined,
version: Number(before?.version), destination: 'STALE-DO-NOT-APPLY' })
expectedConflict(context, '卡车旧 version 编辑返回 A0409', stale, true)
const afterStale = await overseas(context, 'GET', '卡车旧 version 冲突后零漂移回查', path)
const afterStaleSummary = summary(afterStale.data)
@@ -456,38 +457,33 @@ async function looseboardFlow(context, customerId) {
`destination=${afterStaleSummary?.destination || 'missing'}, version=${afterStaleSummary?.version ?? 'missing'}; ${trace(afterStale.response)}`, OPTIONS)
const statusPath = query(`${base}/${encodeURIComponent(orderNo)}/status`, { customerId })
const forwarded = await overseas(context, 'PATCH', '散板状态正向推进到已派车', statusPath,
{ version: Number(afterStaleSummary?.version), status: 'DISPATCHED',
idempotencyKey: `${context.marker}-looseboard-status-dispatched`.slice(0, 255),
remark: `${context.marker}-FORWARD` })
const forwardedSummary = summary(forwarded.data)
context.assert(MODULE, '散板状态正向推进成功', forwarded.response,
forwardedSummary?.status === 'DISPATCHED'
&& Number(forwardedSummary?.version) === Number(afterStaleSummary?.version) + 1,
`status=${forwardedSummary?.status || 'missing'}, version=${forwardedSummary?.version ?? 'missing'}; ${trace(forwarded.response)}`, OPTIONS)
const forwardBody = { version: Number(afterStaleSummary?.version), status: 'DISPATCHED',
idempotencyKey: `${context.marker}-looseboard-status-dispatched`.slice(0, 255),
remark: `${context.marker}-FORWARD` }
const forwardReplay = await overseas(context, 'PATCH', '散板状态同键同载荷精确重放', statusPath,
forwardBody)
context.assert(MODULE, '散板状态重放复用已派车终态且版本不漂移', forwardReplay.response,
summary(forwardReplay.data)?.status === 'DISPATCHED'
&& Number(summary(forwardReplay.data)?.version) === Number(forwardedSummary?.version),
trace(forwardReplay.response), OPTIONS)
const forwardMismatch = await context.request('PATCH', statusPath, { ...forwardBody, remark: 'DIFFERENT-DO-NOT-APPLY' })
expectedConflict(context, '散板状态同键异载荷返回冲突', forwardMismatch)
const reversed = await context.request('PATCH', statusPath, { version: Number(forwardedSummary?.version), status: 'PENDING_ACCEPTANCE',
idempotencyKey: `${context.marker}-looseboard-status-terminal-reverse`.slice(0, 255),
remark: 'REVERSE-DO-NOT-APPLY' })
expectedConflict(context, '散板状态逆向回退返回冲突', reversed)
const history = await overseas(context, 'GET', '散板状态冲突后流水回查', path)
idempotencyKey: `${context.marker}-looseboard-status-dispatched`.slice(0, 255), remark: `${context.marker}-FORWARD` }
// 散板状态由需求池匹配和真实履约驱动,普通状态接口只能取消。
const beforeRejected = JSON.stringify(afterStale.data)
for (const [name, body] of [
['散板禁止绕过需求池直接派车', forwardBody],
['散板被拒命令同键重放仍拒绝', forwardBody],
['散板被拒命令异载荷仍拒绝', { ...forwardBody, remark: 'DIFFERENT-DO-NOT-APPLY' }],
['散板状态逆向回退返回冲突', { ...forwardBody, status: 'PENDING_ACCEPTANCE',
idempotencyKey: `${context.marker}-looseboard-status-terminal-reverse`.slice(0, 255) }],
]) {
const rejected = await context.request('PATCH', statusPath, body)
expectedConflict(context, name, rejected)
}
const history = await overseas(context, 'GET', '散板被拒命令后完整详情与流水回查', path)
const historySummary = summary(history.data)
const histories = Array.isArray(history.data?.histories) ? history.data.histories : []
const operations = new Set(histories.map((item) => item?.operationType))
context.assert(MODULE, '散板流水覆盖创建编辑与正向状态', history.response,
historySummary?.status === 'DISPATCHED' && ['CREATE', 'UPDATE', 'STATUS'].every((type) => operations.has(type))
&& histories.some((item) => String(item?.remark || item?.changeDetail || '').includes(context.marker)),
`status=${historySummary?.status || 'missing'}, historyCount=${histories.length}, operations=${[...operations].join(',') || 'missing'}; ${trace(history.response)}`, OPTIONS)
context.assert(MODULE, '散板非法推进未改变完整详情及流水', history.response,
JSON.stringify(history.data) === beforeRejected && historySummary?.status === 'PENDING_ACCEPTANCE',
trace(history.response), OPTIONS)
const pending = await overseas(context, 'GET', '散板原单进入待匹配需求池',
query(`${BASE}/fleet/capacity-offers/pending-looseboard-demands`, { page: 1, size: 100, keyword: orderNo }))
context.assert(MODULE, '待匹配需求保持本轮原单客户与版本', pending.response,
records(pending.data).some(row => row.orderNo === orderNo && id(row.customerId) === customerId
&& Number(row.version) === Number(historySummary?.version)),
trace(pending.response), OPTIONS)
context.addCoverageGap(MODULE, 'looseboard-pool-fulfillment', '散板需求池接车至签收',
'本模块验证原单进入需求池、禁止手工跳状态及取消;接车、实收、装车、签收需本轮独立车次生产链,未以手工改状态代替。', 'fixture')
Object.assign(registry.looseboard, { version: Number(historySummary?.version), finalStatus: historySummary?.status })
} finally {
await cancelMarkerOrder(context, registry.looseboard, base, ['PENDING_ACCEPTANCE', 'DISPATCHED'], '散板')
@@ -28,7 +28,8 @@ export async function run(context) {
const summary = calendar?.summary
const filterOptions = calendar?.filterOptions
context.assert(module, '月历记录、汇总与筛选选项结构完整', calendarResponse,
records.every((row) => id(row.id) && id(row.sailingNo) && 'etaAt' in row)
records.every((row) => id(row.id) && id(row.sailingNo)
&& (row.etaAt == null || Number.isFinite(Date.parse(row.etaAt))))
&& summary && Number.isFinite(count(summary.sailingCount))
&& filterOptions && Array.isArray(filterOptions.warehouses) && Array.isArray(filterOptions.channels),
`month=${month},records=${records.length},sailingCount=${summary?.sailingCount ?? 'missing'}`)