From bafabf72174706f4455161a39931a3ee03f3ce2f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 15 Aug 2026 09:57:46 +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 --- .../loading/mapper/LoadingInboundMapper.java | 6 ++ .../repository/LoadingPickRepository.java | 15 +++++ .../firstleg/service/LoadingPickService.java | 65 +++++++++++++++---- .../openapi/phase3-loading-delivery-v1.yaml | 2 + .../openapi/phase3-pda-convergence-v1.yaml | 3 + .../PdaConvergenceOpenApiContractTest.java | 3 + .../LoadingDeliveryOpenApiContractTest.java | 2 + .../service/LoadingPickServiceTest.java | 51 +++++++++++++++ .../phase3-evidence-stage-contract.test.mjs | 2 +- .../suppliers-contract.test.mjs | 7 +- 10 files changed, 140 insertions(+), 16 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingInboundMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingInboundMapper.java index 3359c09f3..c12349796 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingInboundMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingInboundMapper.java @@ -11,6 +11,12 @@ public interface LoadingInboundMapper { + "FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0") Snapshot find(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId); + @Update("UPDATE fl_inbound_order SET process_status='LOADING', updated_by=#{operatorId}, " + + "version=version+1 WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0 " + + "AND process_status='WAIT_SPLIT' AND version=#{expectedVersion}") + int beginLoading(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, + @Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion); + @Select(""" SELECT batch.source_inbound_id AS sourceInboundId, batch.loaded_child_inbound_id AS loadedChildInboundId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java index 9cf569d79..aeea11a17 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingPickRepository.java @@ -17,6 +17,7 @@ import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity; import com.baiyue.firstleg.entity.LoadingPickOperationEntity; import com.baiyue.firstleg.entity.LoadingPickReviewEntity; import com.baiyue.firstleg.loading.mapper.InboundLoadingPickTaskMapper; +import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; @@ -40,6 +41,8 @@ public class LoadingPickRepository { private LoadingPickOperationMapper operations; @Resource private InboundLoadingPickTaskMapper inboundProjections; + @Resource + private LoadingInboundMapper inbounds; public LoadingPickTaskEntity task(long taskId) { return tasks.selectById(taskId); @@ -209,6 +212,18 @@ public class LoadingPickRepository { return items.selectInboundProgress(tenantId, inboundId); } + public List inboundOwners(long taskId) { + return items.selectInboundOwners(taskId); + } + + public LoadingInboundMapper.Snapshot inbound(long tenantId, long inboundId) { + return inbounds.find(tenantId, inboundId); + } + + public int beginInboundLoading(long tenantId, long inboundId, long operatorId, int expectedVersion) { + return inbounds.beginLoading(tenantId, inboundId, operatorId, expectedVersion); + } + public int settlePartial(long planItemId, long pickItemId, int pickedCount, int plannedCount, long operatorId, String operatorName) { if (items.settlePartialPlanItem(planItemId, pickedCount, plannedCount, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index 4c620349c..113d16681 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -30,6 +30,9 @@ import com.baiyue.firstleg.entity.LoadingPickReviewEntity; import com.baiyue.firstleg.entity.LoadingPickTaskEntity; import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity; +import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; +import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper; +import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; import com.baiyue.firstleg.mapper.LoadingPickItemMapper.CartonLabelRow; import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository; @@ -70,6 +73,8 @@ public class LoadingPickService { @Resource private LoadingListService loadingListService; @Resource + private InboundStatusHistoryMapper inboundHistories; + @Resource private PdaPickSettlementService settlementService; @Resource private WarehousePalletLocationClient warehouse; @@ -451,21 +456,53 @@ public class LoadingPickService { /** 进入PDA拣货任务即将预装柜计划推进为拣货中;状态推进和历史写入保持幂等。 */ private void startPickingIfNeeded(LoadingPickTaskEntity task, long operatorId, String operatorName) { - if (smartLoadingRepository.startPicking(task.getLoadingPlanId(), operatorId, operatorName) != 1) { - return; + if (smartLoadingRepository.startPicking(task.getLoadingPlanId(), operatorId, operatorName) == 1) { + LoadingPlanHistoryEntity history = new LoadingPlanHistoryEntity(); + history.setLoadingPlanId(task.getLoadingPlanId()); + history.setOperationId("PDA-START-" + task.getId()); + history.setActionType("START_LOADING"); + history.setFromStatus("EDITING"); + history.setToStatus("LOADING"); + history.setBeforeSnapshot("{\"source\":\"PDA\"}"); + history.setAfterSnapshot("{\"source\":\"PDA\",\"status\":\"LOADING\"}"); + history.setOperatorId(operatorId); + history.setOperatorName(operatorName); + history.setOperationReason("进入PDA拣货任务"); + smartLoadingRepository.insert(history); + } + for (var owner : repository.inboundOwners(task.getId())) { + if (owner.tenantId() == null || owner.inboundId() == null) { + throw conflict("PDA明细缺少入库单归属"); + } + LoadingInboundMapper.Snapshot inbound = repository.inbound(owner.tenantId(), owner.inboundId()); + if (inbound == null || inbound.processStatus() == null) { + throw conflict("PDA关联入库单不存在"); + } + if ("LOADING".equals(inbound.processStatus())) { + continue; + } + if (!"WAIT_SPLIT".equals(inbound.processStatus()) || inbound.version() == null) { + throw conflict("入库单当前状态不能进入装柜阶段"); + } + if (repository.beginInboundLoading(owner.tenantId(), owner.inboundId(), operatorId, + inbound.version()) != 1) { + LoadingInboundMapper.Snapshot refreshed = repository.inbound(owner.tenantId(), owner.inboundId()); + if (refreshed == null || !"LOADING".equals(refreshed.processStatus())) { + throw conflict("入库单状态已变化,请刷新后重试"); + } + continue; + } + InboundStatusHistoryEntity history = new InboundStatusHistoryEntity(); + history.setTenantId(owner.tenantId()); + history.setInboundId(owner.inboundId()); + history.setFromStatus("WAIT_SPLIT"); + history.setToStatus("LOADING"); + history.setAction("PDA_START_LOADING"); + history.setReason("进入PDA拣货任务"); + history.setOperatorId(operatorId); + history.setOperatorName(operatorName); + inboundHistories.insert(history); } - LoadingPlanHistoryEntity history = new LoadingPlanHistoryEntity(); - history.setLoadingPlanId(task.getLoadingPlanId()); - history.setOperationId("PDA-START-" + task.getId()); - history.setActionType("START_LOADING"); - history.setFromStatus("EDITING"); - history.setToStatus("LOADING"); - history.setBeforeSnapshot("{\"source\":\"PDA\"}"); - history.setAfterSnapshot("{\"source\":\"PDA\",\"status\":\"LOADING\"}"); - history.setOperatorId(operatorId); - history.setOperatorName(operatorName); - history.setOperationReason("进入PDA拣货任务"); - smartLoadingRepository.insert(history); } private LoadingPickTaskEntity requiredTask(long taskId) { diff --git a/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml b/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml index b98340959..3910a22b8 100644 --- a/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml @@ -6,11 +6,13 @@ paths: /api/platform/v1/inbounds/{inboundId}/loading/evidence/pda: get: summary: List loading evidence for the active PDA worker + description: Requires the associated inbound to have entered the loading stage. PDA workflowStatus AWAITING_REVIEW does not bypass this gate; canonical claim or picking advances WAIT_SPLIT to LOADING. parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/TenantId'}] responses: '200': {description: Business envelope; requires platform:firstleg:pda:work} post: summary: Append one PDA-captured loading evidence photo + description: Requires the associated inbound process status to be LOADING. PDA workflowStatus AWAITING_REVIEW is a review projection and does not replace the inbound loading-stage gate. parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: required: true 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 8074f91ad..3a7ddcd3f 100644 --- a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml @@ -38,6 +38,7 @@ paths: /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/claim: post: summary: Atomically claim an unassigned task for the authenticated operator + description: Claiming the canonical PDA task idempotently starts its plan and advances every associated inbound from WAIT_SPLIT to LOADING with optimistic locking; workflowStatus may later become AWAITING_REVIEW while the task remains LOADING until manual completion. parameters: [{$ref: '#/components/parameters/TaskId'}] requestBody: required: true @@ -66,6 +67,7 @@ paths: /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/items/{itemId}/pick: post: summary: Increment a picked item owned by the authenticated assignee + description: The first successful pick also repairs the same WAIT_SPLIT to LOADING transition when the plan was already started, without using containerNo as an association key. parameters: [{$ref: '#/components/parameters/TaskId'}, {$ref: '#/components/parameters/ItemId'}] responses: {'200': {description: Updated task}} /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/items/{itemId}/partial: @@ -81,6 +83,7 @@ paths: /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/batch: post: summary: Atomically submit the complete online PDA draft once + description: A batch containing PICK or PARTIAL idempotently advances associated inbound process status to LOADING before evidence operations become available. parameters: - {$ref: '#/components/parameters/TaskId'} - {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} 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 33f05f9a6..ff275fe86 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java @@ -30,6 +30,9 @@ class PdaConvergenceOpenApiContractTest { .contains("InventorySnapshot") .contains("changing it never resynchronizes the task or item versions") .contains("expectedTaskVersion") + .contains("advances every associated inbound from WAIT_SPLIT to LOADING with optimistic locking") + .contains("workflowStatus may later become AWAITING_REVIEW") + .contains("without using containerNo as an association key") .contains("CompleteLoadingRequest"); String taskLookup = yaml.substring( diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/LoadingDeliveryOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/LoadingDeliveryOpenApiContractTest.java index 8ccd887a9..721f2ca40 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/LoadingDeliveryOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/LoadingDeliveryOpenApiContractTest.java @@ -25,6 +25,8 @@ class LoadingDeliveryOpenApiContractTest { .contains("同类型可多次追加") .contains("Required when evidenceType is SEAL") .contains("compatibility projection") + .contains("associated inbound process status to be LOADING") + .contains("workflowStatus AWAITING_REVIEW does not bypass this gate") .contains("must exactly match the server-derived container-task aggregate") .contains("cannot complete canonical picking"); assertThat(yaml).doesNotContain("/delivery/complete").doesNotContain("DELIVERED"); 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 35e990082..cdb85a3fe 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -17,6 +17,10 @@ import com.baiyue.firstleg.entity.LoadingPickItemEntity; import com.baiyue.firstleg.entity.LoadingPickOperationEntity; import com.baiyue.firstleg.entity.LoadingPickTaskEntity; import com.baiyue.firstleg.entity.LoadingPlanItemEntity; +import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; +import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper; +import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; +import com.baiyue.firstleg.mapper.LoadingPickItemMapper; import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; @@ -53,6 +57,8 @@ class LoadingPickServiceTest { @MockBean private LoadingListService loadingListService; @MockBean + private InboundStatusHistoryMapper inboundHistories; + @MockBean private PdaPickSettlementService settlementService; @MockBean private WarehousePalletLocationClient warehouse; @@ -178,6 +184,51 @@ class LoadingPickServiceTest { verify(smartLoadingRepository).insert(any(com.baiyue.firstleg.entity.LoadingPlanHistoryEntity.class)); } + @Test + void startsInboundLoadingEvenWhenPlanWasAlreadyStarted() { + LoadingPickTaskEntity task = task(); + LoadingPickItemEntity item = item(); + preparePick(task, item, "pick-start-inbound"); + when(smartLoadingRepository.startPicking(10L, 9L, "operator")).thenReturn(0); + when(repository.inboundOwners(20L)).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(101L, 50L))); + when(repository.inbound(101L, 50L)).thenReturn( + new LoadingInboundMapper.Snapshot(50L, 101L, "WAIT_SPLIT", 4)); + when(repository.beginInboundLoading(101L, 50L, 9L, 4)).thenReturn(1); + + service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-start-inbound"), + 9L, "operator"); + + verify(repository).beginInboundLoading(101L, 50L, 9L, 4); + verify(inboundHistories).insert(argThat((InboundStatusHistoryEntity history) -> + Long.valueOf(101L).equals(history.getTenantId()) + && Long.valueOf(50L).equals(history.getInboundId()) + && "WAIT_SPLIT".equals(history.getFromStatus()) + && "LOADING".equals(history.getToStatus()) + && "PDA_START_LOADING".equals(history.getAction()))); + verify(smartLoadingRepository, never()).insert(any( + com.baiyue.firstleg.entity.LoadingPlanHistoryEntity.class)); + } + + @Test + void acceptsConcurrentInboundTransitionWithoutDuplicateHistory() { + LoadingPickTaskEntity task = task(); + LoadingPickItemEntity item = item(); + preparePick(task, item, "pick-concurrent-inbound"); + when(repository.inboundOwners(20L)).thenReturn(List.of( + new LoadingPickItemMapper.InboundOwner(101L, 50L))); + when(repository.inbound(101L, 50L)).thenReturn( + new LoadingInboundMapper.Snapshot(50L, 101L, "WAIT_SPLIT", 4), + new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 5)); + when(repository.beginInboundLoading(101L, 50L, 9L, 4)).thenReturn(0); + + service.pick(20L, 30L, new PickRequest(101L, 2, List.of(), 0, "pick-concurrent-inbound"), + 9L, "operator"); + + verify(repository).beginInboundLoading(101L, 50L, 9L, 4); + verify(inboundHistories, never()).insert(any(InboundStatusHistoryEntity.class)); + } + @Test void claimStartsLoadingAndSameOperatorReentryDoesNotDuplicateStartHistory() { LoadingPickTaskEntity task = task(); diff --git a/scripts/admin-test-env-modules/phase3-evidence-stage-contract.test.mjs b/scripts/admin-test-env-modules/phase3-evidence-stage-contract.test.mjs index 61a2f88b3..7af5e3886 100644 --- a/scripts/admin-test-env-modules/phase3-evidence-stage-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-evidence-stage-contract.test.mjs @@ -18,5 +18,5 @@ test('evidence gate requires stable PDA identity and server LOADING before any e assert.match(source, /AWAITING_REVIEW 不作为证据开放条件/) const gate = source.slice(source.indexOf('const taskResponse'), source.indexOf('const path =')) assert.doesNotMatch(gate, /loading\/evidence/) - assert.match(source, /const path = `\/api\/platform\/inbounds\/\$\{inboundId\}\/loading\/evidence/) + assert.match(source, /const path = `\/api\/platform\/v1\/inbounds\/\$\{inboundId\}\/loading\/evidence/) }) diff --git a/scripts/admin-test-env-modules/suppliers-contract.test.mjs b/scripts/admin-test-env-modules/suppliers-contract.test.mjs index 1b53fa3c0..f86e82d51 100644 --- a/scripts/admin-test-env-modules/suppliers-contract.test.mjs +++ b/scripts/admin-test-env-modules/suppliers-contract.test.mjs @@ -3,9 +3,14 @@ import { readFileSync } from 'node:fs' import test from 'node:test' const source=readFileSync(new URL('./suppliers.mjs',import.meta.url),'utf8') const orchestrator=readFileSync(new URL('../admin-test-env-acceptance.mjs',import.meta.url),'utf8') +const openapi=readFileSync(new URL('../../warehouse/src/main/resources/openapi/supplier-v1.yaml',import.meta.url),'utf8') test('supplier acceptance covers full CRUD-like lifecycle and boundary failures',()=>{ for(const marker of ['列表分页与订舱筛选','查询四类供应商统计','新增供应商','查询供应商详情','编辑供应商','停用供应商','重复名称返回冲突','非订舱分类携带scope被拒绝','tenantId非0被拒绝','客户账号调用平台供应商接口被拒绝','requestId','traceId']) assert.match(source,new RegExp(marker)) }) -test('supplier module is registered in the default full acceptance orchestration',()=>assert.match(orchestrator,/DEFAULT_MODULES = \[[^\]]*'suppliers'/s)) +test('supplier module is registered in the default full acceptance orchestration',()=>assert.match(orchestrator,/CONFIGURED_MODULES = \[[^\]]*'suppliers'/s)) test('supplier fixture is retained only as inactive because API has no delete contract',()=>{assert.match(source,/finalStatus:inactive\?'INACTIVE'/);assert.doesNotMatch(source,/context\.request\('DELETE'/)}) +test('supplier OpenAPI exposes the internal active category and booking scope validation contract',()=>{ + for(const marker of ['/internal/warehouse/v1/suppliers/{supplierId}','X-Internal-Service-Token','requireActive','InternalSnapshot','SupplierPageResponse','platform:supplier:list']) assert.match(openapi,new RegExp(marker.replace(/[{}]/g,'\\$&'))) + assert.doesNotMatch(openapi,/platform:supplier:view/) +})