fix: block receiving before pallet materialization
This commit is contained in:
@@ -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,国内按来源入仓单绑定更新 |
|
||||
|
||||
+19
-1
@@ -128,6 +128,7 @@ public class ReceivingContainerService {
|
||||
public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo) {
|
||||
requireReadyContainer(containerNo, overseasWarehouseId, false);
|
||||
List<InboundPlan> 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<InboundPlan> 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<ReceivingContainerResponses.SignedPhoto> photos = validateSignPhotos(request);
|
||||
List<Long> photoFileIds = photos.stream().map(photo -> positiveFileId(photo.fileId())).toList();
|
||||
photoFileIds.forEach(fileId -> unloadPhotos.validate(PLATFORM_SCOPE, session.getId(), fileId));
|
||||
List<InboundPlan> 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<InboundPlan> 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<InboundPlan> containerPlans) {
|
||||
for (InboundPlan plan : containerPlans) {
|
||||
int expected = plan.getPalletCount() == null ? 0 : plan.getPalletCount();
|
||||
long materialized = pallets.selectCount(new LambdaQueryWrapper<InboundPlanPallet>()
|
||||
.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;
|
||||
|
||||
@@ -42,7 +42,8 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
|
||||
"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," +
|
||||
|
||||
+19
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user