diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 402460f9d..c7fd0b2a2 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -28,7 +28,7 @@ | 入仓计划分页、柜级预览、转换与详情 | 平台 `inboundplan/controller/InboundPlanController.java`,客户只读 `inboundplan/controller/MerchantInboundPlanController.java`;共用 `inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`ContainerInboundPlanPreviewResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java`;客户入口固定 `/api/merchant/v1/last-leg/inbound-plans`,范围只取 Token。`/container-preview` 按全柜返回 FBA/一件代发/私人地址分组、托数、就绪、阻断项及 `snapshotVersion/snapshotHash`;`ContainerInboundPlanConversionCoordinator.java` 用唯一柜级 Saga 锁柜重算,先经 `WarehouseResourceGateway` 批量预留真实托盘,再本地原子生成剩余独立计划并确认资源,本地失败只补偿未确认资源,远端确认失败的重放只重试确认,同柜不同幂等键也不得产生第二批资源 | | 托盘分配算法 | `inboundplan/service/PalletAllocator.java` | | FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` | -| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`,合托只作现场可选提示;首扫确认已预留的真实 pallet/location/lock 绑定,不得重新分配计划资源 | +| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`,合托只作现场可选提示;全部计划完整绑定真实 pallet/location/lock 前不得查看、准备或签收该柜,首扫只确认已预留绑定,不得重新分配计划资源 | | PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` | | 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` | | 尾程暂扣状态回传 | `service/InboundOrderDetainedOutboxService.java`;暂扣与解除暂扣在订单更新事务内按 `sourceInboundId` 写入海外到国内 Outbox,国内按来源入仓单绑定更新 | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index 4f6aac638..1645a65e0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -128,6 +128,7 @@ public class ReceivingContainerService { public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo) { requireReadyContainer(containerNo, overseasWarehouseId, false); List containerPlans = requirePlans(containerNo); + requireMaterializedPlans(containerPlans); if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("当前柜已分配至其他海外接柜仓,无法在本仓操作"); ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey)); requireSessionWarehouse(current, overseasWarehouseId); @@ -158,6 +159,7 @@ public class ReceivingContainerService { return result; } List containerPlans = requirePlans(containerNo); + requireMaterializedPlans(containerPlans); if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("当前柜已分配至其他海外接柜仓,无法在本仓操作"); String activeOperatorKey = activeOperatorKey(operatorKey); ReceivingContainerSession active = sessions.lockActiveByOperator(activeOperatorKey); @@ -210,10 +212,11 @@ public class ReceivingContainerService { List photos = validateSignPhotos(request); List photoFileIds = photos.stream().map(photo -> positiveFileId(photo.fileId())).toList(); photoFileIds.forEach(fileId -> unloadPhotos.validate(PLATFORM_SCOPE, session.getId(), fileId)); + List containerPlans = requirePlans(session.getContainerNo()); + requireMaterializedPlans(containerPlans); LocalDateTime now = LocalDateTime.now(); session.setSignPhotos(write(photos)); session.setStatus("SIGNED"); session.setSignedAt(now); session.setOperatorName(operatorName); session.setUpdatedAt(now); update(session); - List containerPlans = requirePlans(session.getContainerNo()); for (InboundPlan plan : containerPlans) { workflow.ensureSignedTask(plan.getCustomerId(), plan, operatorKey, operatorName, photoFileIds, now); } @@ -661,6 +664,21 @@ public class ReceivingContainerService { || "LOCKED".equals(pallet.getAssignmentStatus())); } + private void requireMaterializedPlans(List containerPlans) { + for (InboundPlan plan : containerPlans) { + int expected = plan.getPalletCount() == null ? 0 : plan.getPalletCount(); + long materialized = pallets.selectCount(new LambdaQueryWrapper() + .eq(InboundPlanPallet::getCustomerId, plan.getCustomerId()) + .eq(InboundPlanPallet::getPlanId, plan.getId()) + .isNotNull(InboundPlanPallet::getWarehousePalletId) + .isNotNull(InboundPlanPallet::getWarehouseLocationId) + .isNotNull(InboundPlanPallet::getWarehouseResourceLockId)); + if (expected <= 0 || materialized < expected) { + throw conflict("入库计划尚未完整绑定真实托盘,请先在平台按柜生成入库计划"); + } + } + } + private void validatePallet(InboundRef ref, WarehouseResourceGateway.WarehousePalletReference pallet) { if ("SCRAPPED".equals(pallet.status())) throw conflict("Pallet is scrapped"); long actualWarehouse; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java index fea64fe0b..fe481b259 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java @@ -42,7 +42,8 @@ public interface LastlegOrderMapper extends BaseMapper { "AND NULLIF(TRIM(p.status),'') IS NOT NULL THEN NULLIF(TRIM(o.inbound_code),'') END) AS plannedInboundCodeCount," + "SUM(CASE WHEN p.id IS NULL OR NULLIF(TRIM(p.plan_no),'') IS NULL OR NULLIF(TRIM(p.status),'') IS NULL " + "THEN 1 ELSE 0 END) AS pendingPlanCount," + - "SUM(CASE WHEN p.id IS NOT NULL AND COALESCE(p.pallet_count,0)>0 AND " + + "SUM(CASE WHEN p.id IS NOT NULL AND p.status IN ('PENDING_ASSIGNMENT','SIGNED') " + + "AND COALESCE(p.pallet_count,0)>0 AND " + "(SELECT COUNT(*) FROM ll_inbound_plan_pallet pp WHERE pp.customer_id=p.customer_id " + "AND pp.plan_id=p.id AND pp.warehouse_pallet_id IS NOT NULL)<COALESCE(p.pallet_count,0) " + "THEN 1 ELSE 0 END) AS resourcePendingPlanCount," + diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index e170f209f..038a550a5 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -85,6 +85,7 @@ class ReceivingContainerServiceBehaviorTest { ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules()); service.initializeObjectMapper(); org.mockito.Mockito.lenient().when(shippingGates.receivingAvailable(any(), any())).thenReturn(1); + org.mockito.Mockito.lenient().when(pallets.selectCount(any())).thenReturn(1L); } @Test @@ -256,6 +257,23 @@ class ReceivingContainerServiceBehaviorTest { verify(plans).updateById(unassigned); } + @Test + void prepareRejectsManualPlanBeforeRealPalletsAreMaterialized() { + InboundPlan plan = plan("TSTU0195472"); + InboundPlanLine planLine = line("FBA"); + LastlegOrder order = containerOrder(1L, "100", "TSTU0195472", "BOOKING-472", "IN-472"); + when(plans.selectList(any())).thenReturn(List.of(plan)); + when(lines.selectList(any())).thenReturn(List.of(planLine)); + when(orders.selectList(any())).thenReturn(List.of(order)); + when(pallets.selectCount(any())).thenReturn(0L); + + assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "TSTU0195472", + new ReceivingContainerRequests.Action("missing-real-pallets"))) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("尚未完整绑定真实托盘"); + verify(sessions, never()).insert(any(ReceivingContainerSession.class)); + } + @Test void candidatesHideContainersReservedByAnotherOperator() { InboundPlan available = plan("CONT-A"); @@ -778,7 +796,7 @@ class ReceivingContainerServiceBehaviorTest { private static InboundPlan plan(String containerNo) { InboundPlan value = new InboundPlan(); value.setId(2L); value.setPlanNo("PLAN-1"); value.setCustomerId("100"); value.setContainerNo(containerNo); - value.setStatus("SIGNED"); value.setOverseasWarehouseId(100L); + value.setStatus("SIGNED"); value.setOverseasWarehouseId(100L); value.setPalletCount(1); return value; }