From 3b38fdd33d8dae412b574b15924d82f96fce416a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 13:50:33 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../baiyue/firstleg/dto/LoadingListDtos.java | 1 + .../repository/LoadingListRepository.java | 2 +- .../resources/openapi/loading-lists-v1.yaml | 3 +- .../LoadingListControllerContractTest.java | 2 +- .../inbound-weight-ocr-contract.test.mjs | 8 +- .../inbound-weight-ocr.mjs | 27 ++++-- ...ght-pallet-page-response-contract.test.mjs | 86 +++++++++++++++++++ 7 files changed, 117 insertions(+), 12 deletions(-) create mode 100644 scripts/admin-test-env-modules/weight-pallet-page-response-contract.test.mjs diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java index d7b45a413..e3b9327f9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java @@ -200,6 +200,7 @@ public final class LoadingListDtos { public record History( Long historyId, Long itemId, + String operationId, String actionType, String fromStatus, String toStatus, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java index e363c9a2d..c4c32a067 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java @@ -188,7 +188,7 @@ public class LoadingListRepository { private List histories(long planId) { return mapper.selectHistories(planId).stream().map(row -> new History(row.getHistoryId(), row.getItemId(), - row.getActionType(), row.getFromStatus(), row.getToStatus(), row.getBeforeSnapshot(), + row.getOperationId(), row.getActionType(), row.getFromStatus(), row.getToStatus(), row.getBeforeSnapshot(), row.getAfterSnapshot(), row.getOperatorId(), row.getOperatorName(), row.getOperationReason(), row.getOccurredAt())).toList(); } diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml index 18b436f15..4e556e7d3 100644 --- a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -427,10 +427,11 @@ components: itemVersion: {type: integer, format: int32, minimum: 0, description: 明细乐观锁版本;更新仓库备注时作为 expectedItemVersion} OperationHistory: type: object - required: [historyId, actionType, operatorId, occurredAt] + required: [historyId, operationId, actionType, operatorId, occurredAt] properties: historyId: {$ref: '#/components/schemas/LongId'} itemId: {$ref: '#/components/schemas/NullableLongId'} + operationId: {type: string, minLength: 1, maxLength: 128, description: 稳定业务动作或幂等事件标识;客户端据此识别重放,不按展示文案去重} actionType: {type: string, description: 操作类型} fromStatus: {type: string, nullable: true, description: 操作前状态} toStatus: {type: string, nullable: true, description: 操作后状态} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java index 28be884a7..a2ecf3ca4 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java @@ -180,7 +180,7 @@ class LoadingListControllerContractTest { "itemId", "accountId", "inboundId", "inboundDetailId", "skuCode", "destinationWarehouse", "productQuantity", "frozenAt"); assertThat(properties(schemas, "OperationHistory")).containsKeys( - "historyId", "itemId", "actionType", "fromStatus", "toStatus", + "historyId", "itemId", "operationId", "actionType", "fromStatus", "toStatus", "beforeSnapshot", "afterSnapshot", "operatorId", "occurredAt"); assertThat(properties(schemas, "TransportUpdateRequest")).containsOnlyKeys( "containerNo", "voyageNo", "expectedVersion", "operationReason"); diff --git a/scripts/admin-test-env-modules/inbound-weight-ocr-contract.test.mjs b/scripts/admin-test-env-modules/inbound-weight-ocr-contract.test.mjs index d90328480..40684161b 100644 --- a/scripts/admin-test-env-modules/inbound-weight-ocr-contract.test.mjs +++ b/scripts/admin-test-env-modules/inbound-weight-ocr-contract.test.mjs @@ -4,6 +4,7 @@ import test from 'node:test' import { confirmedNetSummary, + normalizeWeightPalletOptionPage, normalizedGrossKg, palletOptionsPath, selectWeightPallet, @@ -50,10 +51,13 @@ test('HTTP scenario uses pallet option and net weight contracts without legacy f 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, + records: [{ palletId: '1' }], page: '1', size: '50', total: '1000', pages: '20', }), [{ palletId: '1' }]) + assert.deepEqual(normalizeWeightPalletOptionPage({ + records: [], page: '1', size: '20', total: '0', pages: '0', + }), { records: [], page: 1, size: 20, total: 0, pages: 0 }) assert.equal(weightPalletOptionRecords([{ palletId: '1' }]), null) - assert.equal(weightPalletOptionRecords({ records: [], page: 1, size: 1000 }), null) + assert.equal(weightPalletOptionRecords({ records: [], page: 1, size: 1000, total: 0, pages: 0 }), null) assert.ok(source.indexOf('const palletOptionsResponse') < source.lastIndexOf('const uploadPath')) assert.match(source, /form\.append\('file'/) assert.doesNotMatch(source, /form\.append\('palletId'/) diff --git a/scripts/admin-test-env-modules/inbound-weight-ocr.mjs b/scripts/admin-test-env-modules/inbound-weight-ocr.mjs index 8b33d1632..0ced75364 100644 --- a/scripts/admin-test-env-modules/inbound-weight-ocr.mjs +++ b/scripts/admin-test-env-modules/inbound-weight-ocr.mjs @@ -214,13 +214,21 @@ export function palletOptionsPath(tenantId, inboundId, keyword = '') { return `/api/platform/v1/first-leg/inbounds/${encodeURIComponent(inboundId)}/weight-pallet-options?${params.toString()}` } -export function weightPalletOptionRecords(page) { +export function normalizeWeightPalletOptionPage(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 + 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 = {}) { @@ -401,7 +409,12 @@ export async function run(context) { ) const palletOptionsPage = context.apiSuccess(MODULE, '分页读取同仓称重托盘候选', palletOptionsResponse, `inboundId=${target.inboundId}`, NEW_CONTRACT_OPTIONS) - const palletOptions = weightPalletOptionRecords(palletOptionsPage) + 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, diff --git a/scripts/admin-test-env-modules/weight-pallet-page-response-contract.test.mjs b/scripts/admin-test-env-modules/weight-pallet-page-response-contract.test.mjs new file mode 100644 index 000000000..9c5aec34e --- /dev/null +++ b/scripts/admin-test-env-modules/weight-pallet-page-response-contract.test.mjs @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +import { normalizeWeightPalletOptionPage } from './inbound-weight-ocr.mjs' + +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 pageResult = read('../../common-core/src/main/java/com/baiyue/common/core/api/PageResult.java') +const longJackson = read('../../common-web/src/main/java/com/baiyue/common/web/LongIdJacksonConfiguration.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 adminApiTests = read('../../../apps/admin-web/src/modules/inbounds/weight/api/weightOcr.test.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 marker: ${marker}`) + const end = source.indexOf(nextMarker, start + marker.length) + return source.slice(start, end === -1 ? source.length : end) +} + +test('formal success accepts two records and string pagination numbers', () => { + const records = [{ palletId: '1' }, { palletId: '2' }] + assert.deepEqual(normalizeWeightPalletOptionPage({ records, page: '1', size: '20', total: '2', pages: '1' }), + { records, page: 1, size: 20, total: 2, pages: 1 }) +}) + +test('formal empty page remains a successful page instead of an error', () => { + assert.deepEqual(normalizeWeightPalletOptionPage({ records: [], page: '1', size: '20', total: '0', pages: '0' }), + { records: [], page: 1, size: 20, total: 0, pages: 0 }) +}) + +test('malformed array and invalid page metadata are rejected by the HTTP contract', () => { + assert.equal(normalizeWeightPalletOptionPage([]), null) + assert.equal(normalizeWeightPalletOptionPage({ records: [], page: 1, size: 20, total: -1, pages: 0 }), null) + assert.equal(normalizeWeightPalletOptionPage({ records: [], page: 1, size: 101, total: 0, pages: 0 }), null) +}) + +test('Controller DTO and response envelope use PageResult records/page/size/total/pages', () => { + const block = endpointBlock(controller, '@GetMapping("/weight-pallet-options")', '@BusinessAudit') + assert.match(block, /ApiResponse>/) + assert.match(pageResult, /record PageResult\(List records, long page, long size, long total, long pages\)/) +}) + +test('OpenAPI documents the page object and permits runtime string pagination numbers', () => { + const endpoint = endpointBlock(openapi, + ' /api/platform/v1/first-leg/inbounds/{inboundId}/weight-pallet-options:', + ' /api/platform/v1/first-leg/inbounds/{inboundId}/weight-results:') + assert.match(endpoint, /data: \{\$ref: '#\/components\/schemas\/WeightPalletOptionPage'\}/) + const schema = endpointBlock(openapi, ' WeightPalletOptionPage:', ' CreateWeightResultRequest:') + assert.match(schema, /required: \[records, page, size, total, pages\]/) + for (const field of ['page', 'size', 'total', 'pages']) { + assert.match(schema, new RegExp(`${field}:[^\n]*(?:oneOf|string)`), + `${field} must permit the string form emitted by the shared Long serializer`) + } + assert.match(longJackson, /serializerByType\(Long\.TYPE, ToStringSerializer\.instance\)/) +}) + +test('Admin reads records, normalizes numeric strings, and never treats data as an array', () => { + const block = endpointBlock(adminApi, 'export async function getWeightPalletOptions', 'export async function uploadWeightImage') + assert.match(block, /ApiEnvelope/) + assert.match(block, /records:\s*page\.records/) + assert.match(block, /normalizedPageNumber\(page\.(?:page|size|total|pages)/) + assert.doesNotMatch(block, /ApiEnvelope/) + assert.match(adminPanel, /palletOptions\.value = result\.records/) +}) + +test('Admin behavior covers two-record success and an empty successful page', () => { + assert.match(adminApiTests, /formal paged pallet response[\s\S]{0,900}records[\s\S]{0,300}page: '1'/) + assert.match(adminBehavior, /no pallet options[\s\S]{0,500}palletPage\(\[\]\)/) +}) + +test('Admin behavior exposes a body code business error as a candidate load failure', () => { + assert.match(adminBehavior, /getWeightPalletOptions\)\.mockRejectedValueOnce\(new ApiResponseError\([^)]*'A0500'/) + assert.match(adminBehavior, /可选托盘加载失败|托盘服务暂不可用/) +}) + +test('Admin behavior separately covers a non-2xx HTTP or network error', () => { + assert.match(adminBehavior, /(?:HTTP error|HTTP错误|network error|网络错误)[\s\S]{0,700}(?:AxiosError|HTTP_5|status)/i) +}) + +test('Admin aborts and ignores an expired candidate response', () => { + assert.match(adminBehavior, /in-flight pallet request[\s\S]{0,900}signal\?\.aborted[\s\S]{0,300}过期托盘/) +})