From 592da4d7e250953b37c807cb33d68bcb14152ddb Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 17 Aug 2026 16:17:20 +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 --- .../mapper/LoadingPickTaskMapper.java | 6 ++- .../openapi/phase3-pda-convergence-v1.yaml | 4 +- .../PdaConvergenceOpenApiContractTest.java | 1 + .../LoadingPickPdaTaskSqlContractTest.java | 5 +++ .../service/LoadingPickServiceTest.java | 6 ++- scripts/admin-test-env-modules/README.md | 2 + .../phase3-pda-convergence.mjs | 39 +++++++++++++++++-- ...-pda-pending-consistency-contract.test.mjs | 38 ++++++++++++++++++ .../full-regression-entry-contract.test.mjs | 4 ++ 9 files changed, 97 insertions(+), 8 deletions(-) create mode 100644 scripts/admin-test-env-modules/phase3-pda-pending-consistency-contract.test.mjs diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java index 13b4dc309..00070a1da 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java @@ -59,7 +59,11 @@ public interface LoadingPickTaskMapper extends BaseMapper booking.source_type AS sourceType, booking.record_status AS recordStatus, booking.merged_into_container_booking_id AS mergedIntoContainerBookingId, booking.warehouse_name AS warehouseName, task.task_status AS taskStatus, - CASE WHEN assignment.pick_task_id IS NULL THEN 'UNASSIGNED' ELSE 'IN_PROGRESS' END AS workflowStatus, + CASE WHEN task.task_status = 'COMPLETED' THEN 'COMPLETED' + WHEN assignment.pick_task_id IS NULL THEN 'UNASSIGNED' + WHEN SUM(CASE WHEN pick_item.item_status IN ('PENDING', 'PICKING') THEN 1 ELSE 0 END) = 0 + THEN 'AWAITING_REVIEW' + ELSE 'IN_PROGRESS' END AS workflowStatus, task.version AS version, COALESCE(assignment.version, 0) AS assignmentVersion, COUNT(pick_item.id) AS itemCount, SUM(CASE WHEN pick_item.item_status IN ('PENDING', 'PICKING') THEN 1 ELSE 0 END) AS pendingItemCount diff --git a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml index 18bf7c84f..eef1c0492 100644 --- a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml @@ -21,7 +21,7 @@ paths: - {name: keyword, in: query, required: false, schema: {type: string, maxLength: 64}} responses: '200': - description: Unassigned tasks and tasks assigned to the current operator; no customer selector required + description: Unassigned tasks and tasks assigned to the current operator, including AWAITING_REVIEW tasks with zero unresolved items so PDA can enter evidence and final completion; no customer selector required content: application/json: schema: @@ -182,7 +182,7 @@ components: warehouseId: {type: integer, format: int64} warehouseName: {type: string, nullable: true} taskStatus: {type: string, enum: [LOADING]} - workflowStatus: {type: string, enum: [UNASSIGNED, IN_PROGRESS]} + workflowStatus: {type: string, enum: [UNASSIGNED, IN_PROGRESS, AWAITING_REVIEW], description: Server projection aligned with task detail; assigned LOADING tasks with pendingItemCount=0 remain visible as AWAITING_REVIEW} version: {type: integer, minimum: 0} assignmentVersion: {type: integer, minimum: 0} itemCount: {type: integer, minimum: 1} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java index 6eed92941..73c577614 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java @@ -39,6 +39,7 @@ class PdaConvergenceOpenApiContractTest { .contains("sourceType", "recordStatus", "mergedIntoContainerBookingId") .contains("Task detail is served only from the ACTIVE container booking") .contains("Enforced by the server-side pending-task query") + .contains("assigned LOADING tasks with pendingItemCount=0 remain visible as AWAITING_REVIEW") .contains("CompleteLoadingRequest"); String taskLookup = yaml.substring( diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java index 22b555ffe..91576c639 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java @@ -32,6 +32,11 @@ class LoadingPickPdaTaskSqlContractTest { .contains("booking.source_type AS sourceType") .contains("booking.record_status AS recordStatus") .contains("booking.merged_into_container_booking_id AS mergedIntoContainerBookingId") + .contains("WHEN task.task_status = 'COMPLETED' THEN 'COMPLETED'") + .contains("WHEN assignment.pick_task_id IS NULL THEN 'UNASSIGNED'") + .contains("pick_item.item_status IN ('PENDING', 'PICKING')") + .contains("THEN 'AWAITING_REVIEW'") + .contains("ELSE 'IN_PROGRESS' END AS workflowStatus") .contains("assignment.pick_task_id IS NULL OR assignment.assignee_id = #{operatorId}") .contains("booking.warehouse_id AS warehouseId") .doesNotContain("tenantId"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index 717d657fd..1069116a4 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -264,11 +264,11 @@ class LoadingPickServiceTest { row.setMergedIntoContainerBookingId(null); row.setWarehouseId(101L); row.setTaskStatus("LOADING"); - row.setWorkflowStatus("UNASSIGNED"); + row.setWorkflowStatus("AWAITING_REVIEW"); row.setVersion(0); row.setAssignmentVersion(0); row.setItemCount(1); - row.setPendingItemCount(1); + row.setPendingItemCount(0); when(repository.pendingTasks(9L, null)).thenReturn(List.of(row)); var result = service.pendingTasks(9L, null).get(0); @@ -276,6 +276,8 @@ class LoadingPickServiceTest { assertThat(result.sourceType()).isEqualTo("SIMULATION"); assertThat(result.recordStatus()).isEqualTo("ACTIVE"); assertThat(result.mergedIntoContainerBookingId()).isNull(); + assertThat(result.workflowStatus()).isEqualTo("AWAITING_REVIEW"); + assertThat(result.pendingItemCount()).isZero(); } @Test diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index d42cc3186..a1381880e 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -68,6 +68,8 @@ export async function run(context) { 计划在 `EDITING/LOADING` 可通过正式 assignments/PATCH/DELETE 端点增量;计划、任务和明细版本必须递增,PDA 四元身份不变,旧 `inventorySnapshotToken` 必须返回 `A0409 库存已变化,请刷新后重试`。已拣项不可删或减量,未拣项可删;增量会把全拣的 `AWAITING_REVIEW` 撤回为 `IN_PROGRESS`,刷新 task detail 与 inventory 后续拣才可再次等待最终确认。最终唯一完成入口仍为 `POST /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/complete`:保留同柜 `RELEASED` 订舱、bridge 与五类 ACTIVE 证据门禁;拖车状态不是前置门禁,无有效拖车时继续,恰有一条有效拖车时同一事务推进为 `COMPLETED`,多条或并发冲突返回 `A0409` 且本地状态回滚。PDA 结算可能先递增来源入库单版本或创建实装分片,完成动作必须在结算后重新锁定实际完成行并使用当前版本;HTTP finalizer 会回查 full/partial 履约分片,失败时保存 `requestId/traceId` 并断言 task/plan/inbound/operation 零漂移。后续模块必须复用 `phase3Loading` 发布的本轮 `taskId + inboundId + loadingPlanId + containerBookingId`,不得回退历史资源。 +`phase3-pda-convergence` 同时回查 `/tasks/pending` 与稳定 task detail:两者的 `workflowStatus`、`itemCount`、`pendingItemCount` 必须来自同一任务明细事实。全拣任务必须在两处均为 `AWAITING_REVIEW`,可执行证据 GET,但派生 `actions=[]`,不得重复发送 batch;操作历史必须恰有一次有效 `BATCH`。若 pending 仍按 assignment 返回 `IN_PROGRESS`,该轮以真实 HTTP 失败阻断,不由脚本或前端改写状态。 + 部署后需要定向验证该完整链时,模块顺序固定为: ```powershell diff --git a/scripts/admin-test-env-modules/phase3-pda-convergence.mjs b/scripts/admin-test-env-modules/phase3-pda-convergence.mjs index c1606c19d..d1ea44db2 100644 --- a/scripts/admin-test-env-modules/phase3-pda-convergence.mjs +++ b/scripts/admin-test-env-modules/phase3-pda-convergence.mjs @@ -3,6 +3,11 @@ const OPTIONS = { scenario: 'pda-picking-convergence', deploymentBlocker: true } function id(value) { return value == null ? '' : String(value) } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } function failure(response) { return [400, 401, 403, 404, 409, 503].includes(response?.http) || (response?.http === 200 && codeOf(response) !== '200') } +export function buildPendingActions(task) { + if (task?.workflowStatus === 'AWAITING_REVIEW' || task?.workflowStatus === 'COMPLETED') return [] + return (task?.items || []).filter((item) => ['PENDING', 'PICKING'].includes(item?.itemStatus)) + .map((item) => ({ itemId: id(item?.id), action: 'PICK' })) +} async function request(context, method, path, body, headers = {}) { try { const response = await fetch(`${context.options.gateway}${path}`, { method, @@ -40,8 +45,36 @@ export async function run(context) { ['AWAITING_REVIEW', 'COMPLETED'].includes(projection?.status) && Number(projection?.expectedCount) > 0 && Number(projection?.expectedCount) === Number(projection?.pickedCount), `status=${projection?.status || 'missing'}, expected=${projection?.expectedCount ?? 'missing'}, picked=${projection?.pickedCount ?? 'missing'}`, OPTIONS) + const taskPath = `/api/platform/v1/first-leg/pda-loading/tasks/${taskId}` + const taskDetailResponse = await request(context, 'GET', + `${taskPath}?warehouseId=${encodeURIComponent(warehouseId)}`) + const taskDetail = context.apiSuccess(MODULE, '按稳定 taskId 读取 PDA 任务详情', taskDetailResponse, '', OPTIONS) + const pendingResponse = await request(context, 'GET', + `/api/platform/v1/first-leg/pda-loading/tasks/pending?keyword=${encodeURIComponent(containerNo)}`) + const pendingTasks = context.apiSuccess(MODULE, '读取当前操作员 PDA pending 列表', pendingResponse, '', OPTIONS) + const pendingTask = Array.isArray(pendingTasks) + ? pendingTasks.find((item) => id(item?.taskId) === taskId) + : null + const unresolvedItems = (taskDetail?.items || []).filter((item) => + ['PENDING', 'PICKING'].includes(item?.itemStatus)) + context.assert(MODULE, 'pending 与 detail 使用同一 workflowStatus 和未完成数量事实', pendingResponse, + Boolean(pendingTask) && pendingTask?.workflowStatus === taskDetail?.workflowStatus + && Number(pendingTask?.pendingItemCount) === unresolvedItems.length + && Number(pendingTask?.itemCount) === Number(taskDetail?.items?.length), + `taskId=${taskId}, pendingWorkflow=${pendingTask?.workflowStatus || 'missing'}, detailWorkflow=${taskDetail?.workflowStatus || 'missing'}, pendingItemCount=${pendingTask?.pendingItemCount ?? 'missing'}, detailUnresolved=${unresolvedItems.length}`, OPTIONS) + const pendingActions = buildPendingActions(taskDetail) + context.assert(MODULE, 'AWAITING_REVIEW 已全拣任务 actions 为空且不再提交 batch', taskDetailResponse, + taskDetail?.workflowStatus === 'AWAITING_REVIEW' && unresolvedItems.length === 0 + && pendingActions.length === 0, + `workflow=${taskDetail?.workflowStatus || 'missing'}, unresolved=${unresolvedItems.length}, actions=${pendingActions.length}`, OPTIONS) + const evidenceResponse = await request(context, 'GET', + `/api/platform/v1/inbounds/${inboundId}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`) + const evidence = context.apiSuccess(MODULE, 'AWAITING_REVIEW 可进入装柜取证只读入口', evidenceResponse, '', OPTIONS) + context.assert(MODULE, 'AWAITING_REVIEW 取证 GET 开放且不依赖重复 batch', evidenceResponse, + taskDetail?.workflowStatus === 'AWAITING_REVIEW' && Array.isArray(evidence?.evidences), + `workflow=${taskDetail?.workflowStatus || 'missing'}, evidences=${evidence?.evidences?.length ?? 'missing'}, actions=${pendingActions.length}`, OPTIONS) const operationsResponse = await request(context, 'GET', - `/api/platform/v1/first-leg/pda-loading/tasks/${taskId}/operations`) + `${taskPath}/operations`) const operations = context.apiSuccess(MODULE, '查询柜次 PDA 不可变操作历史', operationsResponse, '', OPTIONS) context.assert(MODULE, '最终完成前保留领取操作且不伪造复核或完成历史', operationsResponse, Array.isArray(operations) && operations.some((item) => item.actionType === 'CLAIM' @@ -51,8 +84,8 @@ export async function run(context) { `actions=${Array.isArray(operations) ? operations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS) context.assert(MODULE, 'PDA 原子批次完成审计包含 BATCH 操作', operationsResponse, - Array.isArray(operations) && operations.some((item) => item.actionType === 'BATCH' - && Boolean(item.operatorId) && Boolean(item.createdAt)), + Array.isArray(operations) && operations.filter((item) => item.actionType === 'BATCH' + && Boolean(item.operatorId) && Boolean(item.createdAt)).length === 1, `actions=${Array.isArray(operations) ? operations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS) const forged = await request(context, 'POST', `/api/platform/v1/inbounds/${inboundId}/loading/picking/start?tenantId=${encodeURIComponent(tenantId)}`, diff --git a/scripts/admin-test-env-modules/phase3-pda-pending-consistency-contract.test.mjs b/scripts/admin-test-env-modules/phase3-pda-pending-consistency-contract.test.mjs new file mode 100644 index 000000000..bc7df2e3a --- /dev/null +++ b/scripts/admin-test-env-modules/phase3-pda-pending-consistency-contract.test.mjs @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +import { buildPendingActions } from './phase3-pda-convergence.mjs' + +const here = dirname(fileURLToPath(import.meta.url)) +const read = (path) => readFileSync(resolve(here, path), 'utf8') +const convergence = read('./phase3-pda-convergence.mjs') +const mapper = read('../../firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java') +const openApi = read('../../firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml') + +test('AWAITING_REVIEW derives zero client actions and never needs a repeated batch', () => { + assert.deepEqual(buildPendingActions({ + workflowStatus: 'AWAITING_REVIEW', + items: [{ id: 1, itemStatus: 'PICKED', pickedPackageCount: 1 }], + }), []) + assert.deepEqual(buildPendingActions({ + workflowStatus: 'IN_PROGRESS', + items: [{ id: 1, itemStatus: 'PICKED' }, { id: 2, itemStatus: 'PENDING' }], + }), [{ itemId: '2', action: 'PICK' }]) + assert.match(convergence, /pending 与 detail 使用同一 workflowStatus 和未完成数量事实/) + assert.match(convergence, /AWAITING_REVIEW 已全拣任务 actions 为空且不再提交 batch/) + assert.match(convergence, /AWAITING_REVIEW 可进入装柜取证只读入口/) + assert.match(convergence, /filter\(\(item\) => item\.actionType === 'BATCH'/) + assert.doesNotMatch(convergence, /pendingActions[\s\S]{0,240}request\(context, 'POST'/) +}) + +test('pending backend contract derives workflow from task items instead of assignment alone', () => { + assert.match(openApi, + /PendingTask:[\s\S]*workflowStatus: \{type: string, enum: \[UNASSIGNED, IN_PROGRESS, AWAITING_REVIEW\][^\r\n]*}/) + assert.match(mapper, + /WHEN SUM\(CASE WHEN pick_item\.item_status IN \('PENDING', 'PICKING'\)[\s\S]*= 0[\s\S]*THEN 'AWAITING_REVIEW'/) + assert.doesNotMatch(mapper, + /CASE WHEN assignment\.pick_task_id IS NULL THEN 'UNASSIGNED' ELSE 'IN_PROGRESS' END AS workflowStatus/) +}) diff --git a/scripts/full-regression-entry-contract.test.mjs b/scripts/full-regression-entry-contract.test.mjs index fe8d9660f..c6ac566ee 100644 --- a/scripts/full-regression-entry-contract.test.mjs +++ b/scripts/full-regression-entry-contract.test.mjs @@ -118,6 +118,7 @@ test('default Admin HTTP entry carries evidence chain through settlement-aware P const loading = readFileSync(resolve(scriptsDir, 'admin-test-env-modules', 'phase3-loading.mjs'), 'utf8') const evidenceGate = readFileSync(resolve(scriptsDir, 'admin-test-env-modules', 'phase3-evidence-gate.mjs'), 'utf8') + const convergence = readFileSync(resolve(scriptsDir, 'admin-test-env-modules', 'phase3-pda-convergence.mjs'), 'utf8') const customs = readFileSync(resolve(scriptsDir, 'admin-test-env-modules', 'phase3-customs.mjs'), 'utf8') const finalize = readFileSync(resolve(scriptsDir, 'admin-test-env-modules', 'phase3-loading-finalize.mjs'), 'utf8') @@ -128,6 +129,9 @@ test('default Admin HTTP entry carries evidence chain through settlement-aware P assert.match(loading, /const evidenceTypes = \['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL'\]/) assert.match(evidenceGate, /fixture\??\.marker !== context\.marker/) assert.match(evidenceGate, /taskId[\s\S]*inboundId[\s\S]*loadingPlanId[\s\S]*containerBookingId/) + assert.match(convergence, /pending 与 detail 使用同一 workflowStatus 和未完成数量事实/) + assert.match(convergence, /AWAITING_REVIEW 已全拣任务 actions 为空且不再提交 batch/) + assert.match(convergence, /AWAITING_REVIEW 可进入装柜取证只读入口/) assert.match(customs, /createdResources\?\.phase3Loading[\s\S]*container\?\.finalStatus/) assert.match(finalize, /fixture\?\.marker !== context\.marker/) assert.doesNotMatch(finalize, /BLOCKED_CONTRACT/)