From b139255d3b5e95a8108e231b95f43c52393718d5 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Thu, 20 Aug 2026 13:34:34 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=A5=E5=BA=93=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/头程项目目录结构与服务说明.md | 2 + .../mapper/LoadingCartonManifestMapper.java | 21 +++ .../mapper/LoadingCompletedEventMapper.java | 3 +- .../LoadingCompletedOutboxService.java | 31 ++++- .../LoadingCompletedOutboxServiceTest.java | 8 +- .../controller/InboundPlanController.java | 11 +- .../inboundplan/dto/InboundPlanResponse.java | 1 + .../dto/SupplementPalletRequest.java | 7 + .../inboundplan/entity/InboundPlan.java | 1 + .../inboundplan/entity/InboundPlanCarton.java | 35 +++++ .../mapper/InboundPlanCartonMapper.java | 34 +++++ .../inboundplan/mapper/InboundPlanMapper.java | 4 +- .../mapper/InboundPlanOrderMapper.java | 6 +- .../pda/controller/InboundPdaController.java | 18 +++ .../inboundplan/pda/dto/PdaRequests.java | 6 + .../inboundplan/pda/dto/PdaResponses.java | 3 + .../pda/repository/PdaWorkflowRepository.java | 25 ++++ .../service/InboundPdaWorkflowService.java | 125 +++++++++++++++++- .../service/InboundPlanService.java | 50 +++++-- .../service/LoadingCompletedConsumer.java | 30 ++++- .../src/main/resources/db/lastleg.sql | 27 +++- .../InboundPdaWorkflowServiceTest.java | 73 +++++++++- .../service/InboundPlanServiceTest.java | 50 +++---- .../service/LoadingCompletedConsumerTest.java | 19 ++- 24 files changed, 528 insertions(+), 62 deletions(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCartonManifestMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/SupplementPalletRequest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanCarton.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanCartonMapper.java diff --git a/docs/头程项目目录结构与服务说明.md b/docs/头程项目目录结构与服务说明.md index 91a3f12ad..f1a996425 100644 --- a/docs/头程项目目录结构与服务说明.md +++ b/docs/头程项目目录结构与服务说明.md @@ -155,6 +155,8 @@ Integration 负责百度 OCR、重量识别和 Amazon Logistics/SP-API 等第三 Event Bridge 负责接收国内外服务器之间的必要业务事件,并通过 Inbox 实现幂等。事件载荷遵循 `common-event-contract`,包含事件 ID、来源和目标区域、业务键、版本、时间及 Payload 哈希;头程与尾程不共享 MySQL、Redis、Nacos 或内部接口。 +头程装柜完成事件以实际集装箱为顶层批次,`orders[]` 中每项是一张实际发运的来源入仓单;一个柜可包含多张入仓单。每个货物明细携带来源明细 ID、实际装运箱数和完整的国内箱唛池。国内不要求在装柜时逐箱扫描冻结,完整箱唛池可以大于本柜实际装运箱数;海外按实际首次扫描的箱唛确认归属和实收,未扫描箱唛保留给后续子订单。海外将同柜各来源入仓单分别生成入库计划,但以同一集装箱号关联,并且只允许同一目的仓的箱子合托。 + ## 6. 配置、部署和辅助目录 ### `config/` diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCartonManifestMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCartonManifestMapper.java new file mode 100644 index 000000000..4ee4e2079 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCartonManifestMapper.java @@ -0,0 +1,21 @@ +package com.baiyue.firstleg.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** 读取国内已打印的稳定箱唛池;普通装柜不在国内逐箱确认归属。 */ +@Mapper +public interface LoadingCartonManifestMapper { + @Select(""" + SELECT box_code FROM fl_inbound_carton_label + WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND inbound_detail_id=#{inboundDetailId} + AND batch_status='ACTIVE' + ORDER BY carton_index,id + """) + List labelPool(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, + @Param("inboundDetailId") long inboundDetailId); + +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java index e6a340418..b613660dd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java @@ -21,7 +21,7 @@ public interface LoadingCompletedEventMapper { COALESCE(root_order.order_id, o.order_id) AS root_order_id, o.inbound_code, o.business_type, o.destination_warehouse_id, o.destination_code AS destination_warehouse_code, o.destination_name AS destination_warehouse_name, - i.inbound_detail_id AS source_inbound_detail_id, i.sku_code, + i.id AS loading_plan_item_id, i.inbound_detail_id AS source_inbound_detail_id, i.sku_code, i.product_name_zh, i.product_name_en, i.package_count, i.product_quantity, i.pallet_count, i.gross_weight_kg, i.net_weight_kg, i.dimensional_weight_kg, i.volume_cbm, i.fba_tracking_no, i.shipment_no, i.shipping_mark @@ -65,6 +65,7 @@ public interface LoadingCompletedEventMapper { private Integer aggregateVersion; private String containerNo; private String cabinetNo; private Long warehouseId; private String warehouseName; private Long accountId; private Long sourceInboundId; private Long rootInboundId; private Long sourceOrderId; + private Long loadingPlanItemId; private String sourceOrderNo; private Long rootOrderId; private String inboundCode; private Long destinationWarehouseId; private String destinationWarehouseCode; private String destinationWarehouseName; private Long sourceInboundDetailId; private String skuCode; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java index 3d365eebe..922cd7cca 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -15,6 +15,7 @@ import com.baiyue.firstleg.mapper.InboundOutboxMapper; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.Row; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.PalletRow; +import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -37,26 +38,29 @@ import java.util.Objects; public class LoadingCompletedOutboxService { private static final long HANDOVER_VERSION = 2L; @Resource private LoadingCompletedEventMapper snapshotMapper; + @Resource private LoadingCartonManifestMapper cartonManifestMapper; @Resource private InboundOutboxMapper outboxMapper; @Resource private ObjectMapper objectMapper; public void publish(long planId) { List rows = snapshotMapper.rows(planId); if (rows.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜计划没有最终实装入仓单,不能生成尾程交接事件"); + Map> cartonMarks = cartonLabelPools(rows); List palletRows = snapshotMapper.palletRows(planId); rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId, java.util.TreeMap::new, java.util.stream.Collectors.toList())) .forEach((accountId, accountRows) -> insert(envelope(accountId, accountRows, - palletRows.stream().filter(item -> accountId.equals(item.getAccountId())).toList()))); + palletRows.stream().filter(item -> accountId.equals(item.getAccountId())).toList(), cartonMarks))); } - private EventEnvelope envelope(long accountId, List rows, List palletRows) { + private EventEnvelope envelope(long accountId, List rows, List palletRows, + Map> cartonMarks) { Row first = rows.get(0); Map> byInbound = rows.stream().collect(java.util.stream.Collectors.groupingBy( Row::getSourceInboundId, java.util.TreeMap::new, java.util.stream.Collectors.toList())); List orders = byInbound.values().stream() .map(value -> order(value, palletRows.stream() - .filter(item -> Objects.equals(item.getSourceInboundId(), value.get(0).getSourceInboundId())).toList())).toList(); + .filter(item -> Objects.equals(item.getSourceInboundId(), value.get(0).getSourceInboundId())).toList(), cartonMarks)).toList(); var payload = new FirstLegLoadingPlanCompletedV2(String.valueOf(first.getLoadingPlanId()), first.getPlanNo(), string(first.getContainerBookingId()), first.getContainerNo(), first.getCabinetNo(), string(first.getWarehouseId()), first.getWarehouseName(), orders); @@ -70,7 +74,8 @@ public class LoadingCompletedOutboxService { traceId, eventKey, null, payloadNode, hash).requireValid(); } - private FirstLegLoadingPlanCompletedV2.Order order(List values, List palletRows) { + private FirstLegLoadingPlanCompletedV2.Order order(List values, List palletRows, + Map> cartonMarks) { List sorted = values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).toList(); Row first = sorted.get(0); List items = sorted.stream().map(row -> @@ -79,7 +84,8 @@ public class LoadingCompletedOutboxService { row.getPalletCount(), row.getGrossWeightKg(), row.getNetWeightKg(), row.getDimensionalWeightKg(), row.getVolumeCbm(), string(row.getDestinationWarehouseId()), row.getDestinationWarehouseCode(), row.getDestinationWarehouseName(), row.getFbaTrackingNo(), row.getShipmentNo(), row.getShippingMark(), - row.getCustomerRemark(), row.getInternalRemark(), row.getWarehouseRemark())) + row.getCustomerRemark(), row.getInternalRemark(), row.getWarehouseRemark(), null, + cartonMarks.getOrDefault(row.getLoadingPlanItemId(), List.of()))) .toList(); String orderType = dropship(first.getBusinessType()) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG"; List pallets = dropship(first.getBusinessType()) @@ -96,6 +102,21 @@ public class LoadingCompletedOutboxService { sum(sorted, Row::getVolumeCbm), items, string(first.getAccountId()), orderType, pallets); } + /** + * 国内只确认本柜实装箱数,不扫每张箱唛。事件携带全量可用标签池,海外首扫才确权具体箱唛。 + */ + private Map> cartonLabelPools(List rows) { + Map> result = new java.util.LinkedHashMap<>(); + for (Row row : rows) { + if (dropship(row.getBusinessType())) continue; + List labels = Objects.requireNonNullElse(cartonManifestMapper.labelPool(row.getAccountId(), + row.getSourceInboundId(), row.getSourceInboundDetailId()), List.of()); + if (labels.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜明细没有已打印的可用箱唛池,不能生成海外交接事件"); + result.put(row.getLoadingPlanItemId(), labels); + } + return result; + } + private List palletSnapshots(List values, List source) { if (source.isEmpty()) return List.of(); Map> byPallet = source.stream().collect(java.util.stream.Collectors.groupingBy( diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index 959518478..d7c085e81 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -7,6 +7,7 @@ import com.baiyue.firstleg.mapper.InboundOutboxMapper; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.Row; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.PalletRow; +import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -19,19 +20,23 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class LoadingCompletedOutboxServiceTest { private final LoadingCompletedEventMapper snapshots = mock(LoadingCompletedEventMapper.class); private final InboundOutboxMapper outbox = mock(InboundOutboxMapper.class); + private final LoadingCartonManifestMapper manifests = mock(LoadingCartonManifestMapper.class); private final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules(); private final LoadingCompletedOutboxService service = new LoadingCompletedOutboxService(); @BeforeEach void setup() { ReflectionTestUtils.setField(service,"snapshotMapper",snapshots); + ReflectionTestUtils.setField(service,"cartonManifestMapper",manifests); ReflectionTestUtils.setField(service,"outboxMapper",outbox); ReflectionTestUtils.setField(service,"objectMapper",mapper); + when(manifests.labelPool(anyLong(), anyLong(), anyLong())).thenReturn(List.of("BOX-POOL-1")); } @Test void groupsByAccountAndKeepsAllOrdersAndItemsStable() throws Exception { @@ -87,12 +92,13 @@ class LoadingCompletedOutboxServiceTest { r.setAggregateVersion(8); r.setContainerNo("CONT"); r.setCabinetNo("CAB"); r.setWarehouseId(92L); r.setWarehouseName("WH"); r.setAccountId(account); r.setSourceInboundId(inbound); r.setRootInboundId(99L); r.setSourceOrderId(inbound+1000); r.setSourceOrderNo("FL"+inbound); r.setRootOrderId(1099L); - r.setInboundCode("RK"+inbound); r.setDestinationWarehouseId(93L); r.setDestinationWarehouseCode("ONT8"); + r.setInboundCode("RK"+inbound); r.setLoadingPlanItemId(detail); r.setDestinationWarehouseId(93L); r.setDestinationWarehouseCode("ONT8"); r.setDestinationWarehouseName("ONT8"); r.setSourceInboundDetailId(detail); r.setSkuCode("SKU"+detail); r.setPackageCount(1); r.setProductQuantity(10); r.setPalletCount(1); r.setGrossWeightKg(BigDecimal.ONE); r.setNetWeightKg(BigDecimal.ONE); r.setVolumeCbm(BigDecimal.ONE); return r; } + private PalletRow pallet(long account,long inbound,int allocation,String palletNo,int allocated, long detail,String sku,int packages,int quantity,String boxCode) { PalletRow row = new PalletRow(); row.setAccountId(account); row.setSourceInboundId(inbound); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java index a360ddde1..3e9cb5d66 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -6,6 +6,7 @@ import com.baiyue.lastleg.inboundplan.dto.ConvertInboundPlanRequest; import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest; import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse; import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; +import com.baiyue.lastleg.inboundplan.dto.SupplementPalletRequest; import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; import com.baiyue.lastleg.inboundplan.service.InboundPlanService; import io.swagger.v3.oas.annotations.Operation; @@ -25,7 +26,7 @@ public class InboundPlanController { @Resource private InboundFbaAppointmentService appointmentService; @PostMapping("/convert") - @Operation(summary = "Convert the entire container to one inbound plan") + @Operation(summary = "Convert one inbound order to one inbound plan") public ApiResponse convert(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ConvertInboundPlanRequest request) { return ApiResponse.success(service.convert(scope(user, request.customerId()), request.orderId())); @@ -39,6 +40,14 @@ public class InboundPlanController { return ApiResponse.success(service.detailByPlanNo(scope(user, customerId), planNo)); } + @PostMapping("/{planNo}/pallets/supplement") + @Operation(summary = "Add one printable supplemental pallet for a plan destination warehouse") + public ApiResponse supplementPallet(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo, + @Valid @RequestBody SupplementPalletRequest request) { + return ApiResponse.success(service.supplementPallet(scope(user, request.customerId()), planNo, request)); + } + @PostMapping("/fba-appointments/batch") @Operation(summary = "Create one FBA appointment for selected inbound lines") public ApiResponse batchFbaAppointment( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java index f50f69579..e4e1c5e70 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java @@ -8,6 +8,7 @@ public record InboundPlanResponse( Long id, String planNo, String customerId, + Long sourceOrderId, String containerNo, String status, int lineCount, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/SupplementPalletRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/SupplementPalletRequest.java new file mode 100644 index 000000000..47aa5b910 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/SupplementPalletRequest.java @@ -0,0 +1,7 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import jakarta.validation.constraints.NotBlank; + +/** 托盘纸不足时,按计划内目的仓补打一张空托盘标签。 */ +public record SupplementPalletRequest(@NotBlank String destinationWarehouseId, String destinationWarehouseCode, + String destinationWarehouseName, String customerId) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java index a796f8679..ec6ccd5ad 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java @@ -15,6 +15,7 @@ public class InboundPlan { private Long id; private String planNo; private String customerId; + private Long sourceOrderId; private String containerNo; private String status; private Integer lineCount; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanCarton.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanCarton.java new file mode 100644 index 000000000..7229b4498 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanCarton.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.inboundplan.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** 本柜在国内冻结、由海外首次扫码确权的箱级作业事实。 */ +@Data +@TableName("ll_inbound_plan_carton") +public class InboundPlanCarton { + @TableId(type = IdType.INPUT) + private Long id; + private String customerId; + private Long orderId; + private String sourceInboundDetailId; + private String skuCode; + private String cartonMark; + private String destinationWarehouseId; + private String destinationWarehouseCode; + private String destinationWarehouseName; + private Long planId; + private Long lineId; + private Long planPalletId; + private Long warehousePalletId; + private Long warehouseLocationId; + private Long pdaTaskId; + private String status; + private Integer version; + private LocalDateTime boundAt; + private LocalDateTime receivedAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanCartonMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanCartonMapper.java new file mode 100644 index 000000000..f95d5cbc7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanCartonMapper.java @@ -0,0 +1,34 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface InboundPlanCartonMapper extends BaseMapper { + @Select(""" + SELECT l.* FROM ll_inbound_plan_line l + JOIN ll_order_detail d ON d.order_id=l.order_id + WHERE l.plan_id=#{planId} + AND d.source_inbound_detail_id=#{sourceInboundDetailId} + LIMIT 1 + """) + com.baiyue.lastleg.inboundplan.entity.InboundPlanLine findPlanLine(@Param("planId") Long planId, + @Param("sourceInboundDetailId") String sourceInboundDetailId); + + @Select(""" + SELECT COUNT(*) FROM ll_inbound_plan_carton + WHERE plan_id=#{planId} AND line_id=#{lineId} AND status='ON_PALLET' + """) + long scannedCount(@Param("planId") Long planId, @Param("lineId") Long lineId); + + @Select(""" + SELECT EXISTS(SELECT 1 FROM ll_inbound_plan_line l + JOIN ll_order_detail d ON d.order_id=l.order_id + JOIN ll_inbound_plan_carton c ON c.source_inbound_detail_id=d.source_inbound_detail_id + WHERE l.plan_id=#{planId}) + """) + boolean hasLabelPool(@Param("planId") Long planId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java index 893df0507..c8c7f0c6a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java @@ -8,6 +8,6 @@ import org.apache.ibatis.annotations.Select; @Mapper public interface InboundPlanMapper extends BaseMapper { - @Select("SELECT * FROM ll_inbound_plan WHERE customer_id=#{customerId} AND container_no=#{containerNo} LIMIT 1") - InboundPlan findByContainer(@Param("customerId") String customerId, @Param("containerNo") String containerNo); + @Select("SELECT * FROM ll_inbound_plan WHERE customer_id=#{customerId} AND source_order_id=#{sourceOrderId} LIMIT 1") + InboundPlan findBySourceOrder(@Param("customerId") String customerId, @Param("sourceOrderId") Long sourceOrderId); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java index ed66ee48d..e1e02b377 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java @@ -29,12 +29,12 @@ public interface InboundPlanOrderMapper { "<> COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) THEN 1 ELSE 0 END) " + "AS destinationMismatchCount " + "FROM ll_order o LEFT JOIN ll_order_detail d ON d.order_id=o.id " + - "WHERE o.customer_id=#{customerId} AND o.container_no=#{containerNo} " + + "WHERE o.customer_id=#{customerId} AND o.id=#{orderId} " + "GROUP BY o.id,o.customer_id,o.container_no,o.inbound_code,o.current_status,o.inbound_order_no,o.volume_cbm,o.package_count,o.fba_warehouse_code," + "o.transfer_warehouse_id,o.transfer_warehouse_code,o.transfer_warehouse_name " + "ORDER BY o.id FOR UPDATE") - List lockContainerOrders(@Param("customerId") String customerId, - @Param("containerNo") String containerNo); + List lockOrder(@Param("customerId") String customerId, + @Param("orderId") Long orderId); @Update("UPDATE ll_order SET inbound_order_no=#{planNo},current_status='INBOUND_PLANNED'," + "pallet_merge_flag='MERGED',pallet_count=#{palletCount},updated_at=#{updatedAt} " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java index 7ade7f61a..b4a508bca 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java @@ -62,6 +62,24 @@ public class InboundPdaController { return ApiResponse.success(service.pick(scope(user, customerId), planNo, allocationId, request)); } + @PostMapping("/cartons/scan") + public ApiResponse scanCarton(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo, + @RequestParam(required = false) String customerId, + @Valid @RequestBody PdaRequests.ScanCarton request) { + requireOperate(user); + return ApiResponse.success(service.scanCarton(scope(user, customerId), planNo, request)); + } + + @PostMapping("/cartons/remove") + public ApiResponse removeCarton(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo, + @RequestParam(required = false) String customerId, + @Valid @RequestBody PdaRequests.RemoveCarton request) { + requireOperate(user); + return ApiResponse.success(service.removeCarton(scope(user, customerId), planNo, request)); + } + @GetMapping("/allocations/{allocationId}/merge-candidates") public ApiResponse> candidates( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaRequests.java index 3a1d3880b..685ccf911 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaRequests.java @@ -16,4 +16,10 @@ public final class PdaRequests { public record ConfirmMerge(@Positive long sourceAllocationId, @Positive int packageCount, @NotBlank String idempotencyKey, String operatorId, String operatorName) {} + + public record ScanCarton(@NotBlank String cartonMark, @Positive long planPalletId, + @NotBlank String idempotencyKey, String operatorId, String operatorName) {} + + public record RemoveCarton(@NotBlank String cartonMark, @NotBlank String idempotencyKey, + String operatorId, String operatorName) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java index 0a9b26291..7a95472ca 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java @@ -23,4 +23,7 @@ public final class PdaResponses { public record Merge(String result, String mergeGroupNo, long sourceOrderId, long targetOrderId, int movedPackageCount, BigDecimal movedVolumeCbm, boolean sourcePalletScrapped) {} + + public record Carton(String result, String cartonMark, long planPalletId, long allocationId, + int palletActualPackageCount) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java index e39b826e6..6d8dd4cbe 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java @@ -9,6 +9,8 @@ import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge; import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation; import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; @@ -29,6 +31,7 @@ public class PdaWorkflowRepository { @Resource private InboundPdaTaskMapper tasks; @Resource private InboundPdaOperationMapper operations; @Resource private InboundPalletMergeMapper merges; + @Resource private InboundPlanCartonMapper cartons; public InboundPlan lockPlan(String customerId, String planNo) { return plans.selectOne(new LambdaQueryWrapper() @@ -45,6 +48,27 @@ public class PdaWorkflowRepository { .eq(InboundPlanPalletAllocation::getId, allocationId).last("FOR UPDATE")); } + public InboundPlanPalletAllocation lockAllocation(Long planId, Long lineId, Long palletId) { + return allocations.selectOne(new LambdaQueryWrapper() + .eq(InboundPlanPalletAllocation::getPlanId, planId).eq(InboundPlanPalletAllocation::getLineId, lineId) + .eq(InboundPlanPalletAllocation::getPalletId, palletId).last("FOR UPDATE")); + } + + public InboundPlanCarton lockCarton(String customerId, String cartonMark) { + return cartons.selectOne(new LambdaQueryWrapper() + .eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, cartonMark) + .last("FOR UPDATE")); + } + + public List cartons(Long planId) { + return cartons.selectList(new LambdaQueryWrapper().eq(InboundPlanCarton::getPlanId, planId)); + } + public InboundPlanLine lineForCarton(Long planId, InboundPlanCarton carton) { + return cartons.findPlanLine(planId, carton.getSourceInboundDetailId()); + } + public long scannedCartons(Long planId, Long lineId) { return cartons.scannedCount(planId, lineId); } + public boolean hasCartonLabelPool(Long planId) { return cartons.hasLabelPool(planId); } + public InboundPlanPallet lockPallet(Long planId, Long palletId) { return pallets.selectOne(new LambdaQueryWrapper() .eq(InboundPlanPallet::getPlanId, planId).eq(InboundPlanPallet::getId, palletId).last("FOR UPDATE")); @@ -76,4 +100,5 @@ public class PdaWorkflowRepository { public void update(InboundPdaTask value) { tasks.updateById(value); } public void update(InboundPlanPallet value) { pallets.updateById(value); } public void update(InboundPlanPalletAllocation value) { allocations.updateById(value); } + public void update(InboundPlanCarton value) { cartons.updateById(value); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java index 680e43d57..b09a218c7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java @@ -5,6 +5,7 @@ import com.baiyue.lastleg.inboundplan.entity.InboundPlan; import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests; import com.baiyue.lastleg.inboundplan.pda.dto.PdaResponses; import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge; @@ -99,9 +100,13 @@ public class InboundPdaWorkflowService { && Objects.equals(pallet.getWarehouseLocationId(), request.locationId())) return; throw conflict("Planned pallet is already assigned to another warehouse resource"); } - String businessId = planNo + ":" + planPalletId; + String destinationKey = destinationKey(pallet); + String businessId = plan.getContainerNo() + ":" + destinationKey; + // A container is unloaded by destination pallet, not by inbound-plan pallet. + // The stable reservation key lets each plan from the same container reuse it. + String reservationKey = "INBOUND_CONTAINER_DESTINATION_PALLET:" + businessId; long lockId = warehouse.reserve(customerId, request.warehouseId(), request.palletId(), request.locationId(), - "INBOUND_PLAN_PALLET", businessId, request.idempotencyKey()); + "INBOUND_CONTAINER_DESTINATION_PALLET", businessId, reservationKey); pallet.setWarehousePalletId(request.palletId()); pallet.setWarehouseLocationId(request.locationId()); pallet.setWarehouseResourceLockId(lockId); @@ -128,6 +133,7 @@ public class InboundPdaWorkflowService { public PdaResponses.Operation count(String customerId, String planNo, long allocationId, PdaRequests.PackageOperation request) { InboundPlan plan = requirePlan(customerId, planNo); + requireLegacyCounting(plan); InboundPdaOperation replay = replay(customerId, "COUNT", request.idempotencyKey(), request); if (replay != null) return read(replay, PdaResponses.Operation.class); InboundPdaTask task = requireActiveTask(customerId, plan); @@ -155,6 +161,7 @@ public class InboundPdaWorkflowService { public PdaResponses.Operation pick(String customerId, String planNo, long allocationId, PdaRequests.PackageOperation request) { InboundPlan plan = requirePlan(customerId, planNo); + requireLegacyCounting(plan); InboundPdaOperation replay = replay(customerId, "PICK", request.idempotencyKey(), request); if (replay != null) return read(replay, PdaResponses.Operation.class); InboundPdaTask task = requireActiveTask(customerId, plan); @@ -193,6 +200,90 @@ public class InboundPdaWorkflowService { return result; } + /** 海外首次扫描箱唛:仅接受本柜国内冻结且已归属本计划的箱。 */ + @Transactional + public PdaResponses.Carton scanCarton(String customerId, String planNo, PdaRequests.ScanCarton request) { + InboundPlan plan = requirePlan(customerId, planNo); + InboundPdaOperation replay = replay(customerId, "SCAN_CARTON", request.idempotencyKey(), request); + if (replay != null) return read(replay, PdaResponses.Carton.class); + InboundPdaTask task = requireActiveTask(customerId, plan); + InboundPlanCarton carton = repository.lockCarton(customerId, request.cartonMark()); + if (carton == null || (carton.getPlanId() != null && !Objects.equals(carton.getPlanId(), plan.getId()))) { + throw notFound("Carton mark is not available to this inbound plan"); + } + if (!("UNASSIGNED".equals(carton.getStatus()) || "EXPECTED".equals(carton.getStatus()))) { + throw conflict("Carton mark has already been scanned; remove it before scanning again"); + } + InboundPlanPallet pallet = requirePallet(plan.getId(), request.planPalletId()); + if (isNotAssigned(pallet)) throw conflict("Planned pallet must be assigned and locked before carton scanning"); + if (!sameWarehouse(carton, pallet)) throw conflict("Carton and pallet destination warehouses must match"); + InboundPlanLine line = carton.getLineId() == null + ? repository.lineForCarton(plan.getId(), carton) + : requireLine(plan.getId(), carton.getLineId()); + if (line == null) throw notFound("Carton mark does not belong to cargo in this inbound plan"); + if (repository.scannedCartons(plan.getId(), line.getId()) >= value(line.getRequiredPackageCount())) { + throw conflict("This inbound plan line has reached its required carton count"); + } + InboundPlanPalletAllocation allocation = repository.lockAllocation(plan.getId(), line.getId(), pallet.getId()); + if (allocation == null) { + allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId()); allocation.setPlanId(plan.getId()); + allocation.setLineId(line.getId()); allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(ZERO_VOLUME); + allocation.setPlannedPackageCount(0); allocation.setCountedPackageCount(0); allocation.setPickedPackageCount(0); + allocation.setActualVolumeCbm(ZERO_VOLUME); allocation.setStatus("PICKING"); allocation.setVersion(0); repository.insert(allocation); + } + BigDecimal perPackage = perPackageVolume(line); + carton.setOrderId(line.getOrderId()); carton.setPlanId(plan.getId()); carton.setLineId(line.getId()); + carton.setPlanPalletId(pallet.getId()); carton.setWarehousePalletId(pallet.getWarehousePalletId()); + carton.setWarehouseLocationId(pallet.getWarehouseLocationId()); carton.setPdaTaskId(task.getId()); + carton.setStatus("ON_PALLET"); carton.setBoundAt(LocalDateTime.now()); carton.setUpdatedAt(LocalDateTime.now()); + carton.setVersion(value(carton.getVersion()) + 1); repository.update(carton); + allocation.setCountedPackageCount(value(allocation.getCountedPackageCount()) + 1); + allocation.setPickedPackageCount(value(allocation.getPickedPackageCount()) + 1); + allocation.setActualVolumeCbm(volume(allocation.getActualVolumeCbm()).add(perPackage).setScale(4, RoundingMode.HALF_UP)); + allocation.setStatus(value(allocation.getPickedPackageCount()) >= value(allocation.getPlannedPackageCount()) ? "PICKED" : "PICKING"); + bump(allocation); repository.update(allocation); + pallet.setActualPackageCount(value(pallet.getActualPackageCount()) + 1); + pallet.setActualVolumeCbm(volume(pallet.getActualVolumeCbm()).add(perPackage).setScale(4, RoundingMode.HALF_UP)); + repository.update(pallet); startPicking(task, plan, request.operatorId(), request.operatorName()); + PdaResponses.Carton result = new PdaResponses.Carton("SCANNED", carton.getCartonMark(), pallet.getId(), + allocation.getId(), pallet.getActualPackageCount()); + saveOperation(customerId, task.getId(), allocation.getId(), "SCAN_CARTON", request.idempotencyKey(), request, result); + return result; + } + + @Transactional + public PdaResponses.Carton removeCarton(String customerId, String planNo, PdaRequests.RemoveCarton request) { + InboundPlan plan = requirePlan(customerId, planNo); + InboundPdaOperation replay = replay(customerId, "REMOVE_CARTON", request.idempotencyKey(), request); + if (replay != null) return read(replay, PdaResponses.Carton.class); + InboundPdaTask task = requireActiveTask(customerId, plan); + InboundPlanCarton carton = repository.lockCarton(customerId, request.cartonMark()); + if (carton == null || !Objects.equals(carton.getPlanId(), plan.getId())) throw notFound("Carton mark is not expected by this inbound plan"); + if (!"ON_PALLET".equals(carton.getStatus()) || carton.getPlanPalletId() == null) throw conflict("Only a carton currently on a pallet may be removed"); + InboundPlanPallet pallet = requirePallet(plan.getId(), carton.getPlanPalletId()); + InboundPlanLine line = requireLine(plan.getId(), carton.getLineId()); + InboundPlanPalletAllocation allocation = repository.lockAllocation(plan.getId(), line.getId(), pallet.getId()); + if (allocation == null || value(allocation.getPickedPackageCount()) <= 0 || value(pallet.getActualPackageCount()) <= 0) { + throw conflict("Carton pallet allocation is inconsistent"); + } + BigDecimal perPackage = perPackageVolume(line); + allocation.setCountedPackageCount(Math.max(0, value(allocation.getCountedPackageCount()) - 1)); + allocation.setPickedPackageCount(value(allocation.getPickedPackageCount()) - 1); + allocation.setActualVolumeCbm(nonNegative(volume(allocation.getActualVolumeCbm()).subtract(perPackage))); + allocation.setStatus(value(allocation.getPickedPackageCount()) == 0 ? "PENDING_COUNT" : "PICKING"); + bump(allocation); repository.update(allocation); + pallet.setActualPackageCount(value(pallet.getActualPackageCount()) - 1); + pallet.setActualVolumeCbm(nonNegative(volume(pallet.getActualVolumeCbm()).subtract(perPackage))); repository.update(pallet); + carton.setOrderId(null); carton.setPlanId(null); carton.setLineId(null); carton.setPlanPalletId(null); + carton.setWarehousePalletId(null); carton.setWarehouseLocationId(null); carton.setPdaTaskId(null); + carton.setStatus("UNASSIGNED"); carton.setBoundAt(null); carton.setVersion(value(carton.getVersion()) + 1); + carton.setUpdatedAt(LocalDateTime.now()); repository.update(carton); + PdaResponses.Carton result = new PdaResponses.Carton("REMOVED", carton.getCartonMark(), pallet.getId(), + allocation.getId(), pallet.getActualPackageCount()); + saveOperation(customerId, task.getId(), allocation.getId(), "REMOVE_CARTON", request.idempotencyKey(), request, result); + return result; + } + @Transactional public List mergeCandidates(String customerId, String planNo, long allocationId) { @@ -205,6 +296,7 @@ public class InboundPdaWorkflowService { public PdaResponses.Merge merge(String customerId, String planNo, long targetAllocationId, PdaRequests.ConfirmMerge request) { InboundPlan plan = requirePlan(customerId, planNo); + requireLegacyCounting(plan); InboundPdaOperation replay = replay(customerId, "MERGE", request.idempotencyKey(), request); if (replay != null) return read(replay, PdaResponses.Merge.class); InboundPdaTask task = requireActiveTask(customerId, plan); @@ -292,8 +384,16 @@ public class InboundPdaWorkflowService { throw conflict("All line picked package totals must exactly equal required package totals"); } } + List cartons = repository.cartons(plan.getId()); + if (!cartons.isEmpty() && cartons.stream().anyMatch(carton -> !"ON_PALLET".equals(carton.getStatus()))) { + throw conflict("All expected carton marks must be scanned onto a pallet before stock-in"); + } LocalDateTime now = LocalDateTime.now(); stockReceipts.stockIn(customerId, plan, task, lines, allocations, repository.pallets(plan.getId()), now); + for (InboundPlanCarton carton : cartons) { + carton.setStatus("AVAILABLE"); carton.setReceivedAt(now); carton.setUpdatedAt(now); + carton.setVersion(value(carton.getVersion()) + 1); repository.update(carton); + } task.setStatus("COMPLETED"); task.setCompletedAt(now); task.setUpdatedAt(now); @@ -387,6 +487,21 @@ public class InboundPdaWorkflowService { return leftKey != null && leftKey.equals(rightKey); } + private boolean sameWarehouse(InboundPlanCarton carton, InboundPlanPallet pallet) { + String cartonKey = carton.getDestinationWarehouseId() != null + ? carton.getDestinationWarehouseId() : carton.getDestinationWarehouseCode(); + String palletKey = pallet.getDestinationWarehouseId() != null + ? pallet.getDestinationWarehouseId() : pallet.getDestinationWarehouseCode(); + return cartonKey != null && cartonKey.equals(palletKey); + } + + private String destinationKey(InboundPlanPallet pallet) { + String value = pallet.getDestinationWarehouseId() != null + ? pallet.getDestinationWarehouseId() : pallet.getDestinationWarehouseCode(); + if (value == null || value.isBlank()) throw conflict("Planned pallet destination warehouse is empty"); + return value; + } + private void validateDestinationWarehouse(InboundPlanPallet pallet, long warehouseId) { String destinationId = pallet.getDestinationWarehouseId(); if (destinationId == null || destinationId.isBlank()) return; @@ -413,6 +528,12 @@ public class InboundPdaWorkflowService { repository.update(plan); } + private void requireLegacyCounting(InboundPlan plan) { + if (repository.hasCartonLabelPool(plan.getId())) { + throw conflict("This inbound plan has a carton manifest and must be operated by carton scanning"); + } + } + private boolean isNotAssigned(InboundPlanPallet pallet) { return pallet.getWarehousePalletId() == null || pallet.getWarehouseLocationId() == null || pallet.getWarehouseResourceLockId() == null diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index 58570f0d0..588cd634d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -3,6 +3,7 @@ package com.baiyue.lastleg.inboundplan.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; +import com.baiyue.lastleg.inboundplan.dto.SupplementPalletRequest; import com.baiyue.lastleg.inboundplan.entity.InboundPlan; import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; @@ -37,17 +38,13 @@ public class InboundPlanService { @Transactional public InboundPlanResponse convert(String customerId, Long selectedOrderId) { - String containerNo = trimToNull(orderMapper.findContainerNo(customerId, selectedOrderId)); - if (containerNo == null) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, - "Order does not exist in this tenant or container number is empty"); + List orders = orderMapper.lockOrder(customerId, selectedOrderId); + if (orders == null || orders.isEmpty()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound order not found"); } - - List orders = orderMapper.lockContainerOrders(customerId, containerNo); - if (orders.isEmpty()) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Container orders not found"); - } - InboundPlan existing = planMapper.findByContainer(customerId, containerNo); + String containerNo = trimToNull(orders.get(0).containerNo()); + if (containerNo == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Inbound order container number is empty"); + InboundPlan existing = planMapper.findBySourceOrder(customerId, selectedOrderId); if (existing != null) { return detail(customerId, existing); } @@ -58,6 +55,7 @@ public class InboundPlanService { plan.setId(IdWorker.getId()); plan.setPlanNo("LIP" + plan.getId()); plan.setCustomerId(customerId); + plan.setSourceOrderId(selectedOrderId); plan.setContainerNo(containerNo); plan.setStatus("PENDING_ASSIGNMENT"); plan.setLineCount(orders.size()); @@ -92,11 +90,12 @@ public class InboundPlanService { throw new IllegalStateException("Failed to update order " + order.orderId()); } orderMapper.insertHistory(order.orderId(), order.currentStatus(), - "Container converted to inbound plan " + plan.getPlanNo(), now); + "Inbound order converted to inbound plan " + plan.getPlanNo(), now); } return detail(customerId, plan); } + @Transactional(readOnly = true) public InboundPlanResponse detailByPlanNo(String customerId, String planNo) { InboundPlan plan = planMapper.selectOne(new LambdaQueryWrapper() @@ -107,6 +106,32 @@ public class InboundPlanService { return detail(customerId, plan); } + @Transactional + public InboundPlanResponse.Pallet supplementPallet(String customerId, String planNo, SupplementPalletRequest request) { + InboundPlan plan = planMapper.selectOne(new LambdaQueryWrapper() + .eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getPlanNo, planNo).last("FOR UPDATE")); + if (plan == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found"); + if ("COMPLETED".equals(plan.getStatus())) throw new ResponseStatusException(HttpStatus.CONFLICT, "Completed plan cannot add pallets"); + List lines = lineMapper.selectList(new LambdaQueryWrapper() + .eq(InboundPlanLine::getPlanId, plan.getId())); + boolean destinationExists = lines.stream().anyMatch(line -> request.destinationWarehouseId().equals(line.getDestinationWarehouseId())); + if (!destinationExists) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Destination warehouse is not part of this inbound plan"); + int sequence = palletMapper.selectList(new LambdaQueryWrapper() + .eq(InboundPlanPallet::getPlanId, plan.getId())).stream().map(InboundPlanPallet::getSequenceNo) + .filter(Objects::nonNull).max(Integer::compareTo).orElse(0) + 1; + InboundPlanPallet pallet = new InboundPlanPallet(); + pallet.setId(IdWorker.getId()); pallet.setPlanId(plan.getId()); pallet.setPalletNo(plan.getPlanNo() + "-P" + String.format("%03d", sequence)); + pallet.setDestinationWarehouseId(request.destinationWarehouseId()); pallet.setDestinationWarehouseCode(request.destinationWarehouseCode()); + pallet.setDestinationWarehouseName(request.destinationWarehouseName()); pallet.setCapacityCbm(PalletAllocator.PALLET_CAPACITY_CBM); + pallet.setAllocatedVolumeCbm(BigDecimal.ZERO); pallet.setSequenceNo(sequence); pallet.setAssignmentStatus("UNASSIGNED"); + pallet.setActualVolumeCbm(BigDecimal.ZERO); pallet.setActualPackageCount(0); palletMapper.insert(pallet); + plan.setPalletCount(plan.getPalletCount() + 1); plan.setUpdatedAt(LocalDateTime.now()); planMapper.updateById(plan); + return new InboundPlanResponse.Pallet(pallet.getId(), pallet.getPalletNo(), pallet.getDestinationWarehouseId(), + pallet.getDestinationWarehouseCode(), pallet.getDestinationWarehouseName(), pallet.getCapacityCbm(), + pallet.getAllocatedVolumeCbm(), pallet.getSequenceNo(), null, null, null, pallet.getAssignmentStatus(), + null, pallet.getActualVolumeCbm(), pallet.getActualPackageCount()); + } + private Map createLines(InboundPlan plan, List orders) { Map result = new HashMap<>(); for (SourceOrderSnapshot order : orders) { @@ -211,7 +236,8 @@ public class InboundPlanService { new LambdaQueryWrapper() .eq(InboundPlanPalletAllocation::getPlanId, plan.getId()) .orderByAsc(InboundPlanPalletAllocation::getId)); - return new InboundPlanResponse(plan.getId(), plan.getPlanNo(), plan.getCustomerId(), plan.getContainerNo(), + return new InboundPlanResponse(plan.getId(), plan.getPlanNo(), plan.getCustomerId(), plan.getSourceOrderId(), + plan.getContainerNo(), plan.getStatus(), plan.getLineCount(), plan.getPalletCount(), plan.getTotalVolumeCbm(), lines.stream().map(line -> new InboundPlanResponse.Line(line.getId(), line.getOrderId(), line.getInboundCode(), line.getFbaWarehouseCode(), line.getDestinationWarehouseId(), line.getDestinationWarehouseCode(), line.getDestinationWarehouseName(), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index 49fa9caef..3d076a2b3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -5,6 +5,9 @@ import com.baiyue.lastleg.entity.*; import com.baiyue.lastleg.mapper.*; import com.baiyue.lastleg.dropship.dto.DropshipRequests; import com.baiyue.lastleg.dropship.service.DropshipOrderService; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; +import com.baiyue.lastleg.inboundplan.service.InboundPlanService; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.DeserializationFeature; import jakarta.annotation.Resource; @@ -25,6 +28,8 @@ public class LoadingCompletedConsumer { @Resource private LastlegOrderMapper orderMapper; @Resource private LastlegOrderDetailMapper detailMapper; @Resource private LastlegOrderStatusHistoryMapper historyMapper; + @Resource private InboundPlanCartonMapper cartonMapper; + @Resource private InboundPlanService inboundPlans; @Resource private ObjectMapper objectMapper; @Resource private LastlegOrderNoGenerator orderNoGenerator; @Resource private DropshipOrderService dropshipOrders; @@ -32,7 +37,8 @@ public class LoadingCompletedConsumer { @Transactional public int consume(EventEnvelope envelope) { envelope.requireValid(); - if(!EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(envelope.eventType())||envelope.eventVersion()!=1) + if (!EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(envelope.eventType()) + || (envelope.eventVersion() != 1 && envelope.eventVersion() != 2)) throw new IllegalArgumentException("不支持的尾程交接事件"); if (envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS) throw new IllegalArgumentException("尾程交接事件区域无效"); @@ -71,6 +77,8 @@ public class LoadingCompletedConsumer { try { ObjectMapper compatibleMapper = objectMapper.copy() .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + // V2 only adds immutable item remarks; the current tail-leg snapshot model + // intentionally ignores those fields until its schema exposes them. return compatibleMapper.treeToValue(envelope.payload(), FirstLegLoadingPlanCompletedV1.class); } catch(Exception exception) { throw new IllegalArgumentException("尾程事件负载无效",exception); } @@ -133,10 +141,12 @@ public class LoadingCompletedConsumer { detail.setDestinationWarehouseId(item.destinationWarehouseId()); detail.setDestinationWarehouseCode(item.destinationWarehouseCode()); detail.setDestinationWarehouseName(item.destinationWarehouseName()); detail.setFbaTrackingNo(item.fbaTrackingNo()); detail.setShipmentNo(item.shipmentNo()); detail.setShippingMark(item.shippingMark()); detailMapper.insert(detail); + createCartonLabelPool(customerId, item); } LastlegOrderStatusHistory history=new LastlegOrderStatusHistory(); history.setOrderId(order.getId()); history.setToStatus("PENDING_ASSIGNMENT"); history.setEventId(event.eventId()); history.setRemark("头程装柜完成自动生成"); history.setCreatedAt(LocalDateTime.now()); historyMapper.insert(history); + if ("FIRST_LEG".equals(orderType)) inboundPlans.convert(customerId, order.getId()); if ("ONE_CLICK_DROPSHIP".equals(orderType)) { long warehouseId; try { warehouseId = Long.parseLong(payload.warehouseId()); } @@ -146,6 +156,24 @@ public class LoadingCompletedConsumer { } } + private void createCartonLabelPool(String customerId, FirstLegLoadingPlanCompletedV1.Item item) { + List marks = item.cartonMarks() == null ? List.of() : item.cartonMarks(); + if (marks.isEmpty()) return; // 历史 V1 事件保持原有按箱数入库路径。 + if (marks.stream().anyMatch(mark -> mark == null || mark.isBlank()) || marks.stream().distinct().count() != marks.size()) { + throw new IllegalArgumentException("装柜事件的箱唛池不可包含空值或重复箱唛"); + } + for (String mark : marks) { + InboundPlanCarton carton = new InboundPlanCarton(); + carton.setId(IdWorker.getId()); carton.setCustomerId(customerId); + carton.setSourceInboundDetailId(item.sourceInboundDetailId()); carton.setSkuCode(item.skuCode()); + carton.setCartonMark(mark); carton.setDestinationWarehouseId(item.destinationWarehouseId()); + carton.setDestinationWarehouseCode(item.destinationWarehouseCode()); + carton.setDestinationWarehouseName(item.destinationWarehouseName()); carton.setStatus("UNASSIGNED"); + carton.setVersion(0); carton.setUpdatedAt(LocalDateTime.now()); + try { cartonMapper.insert(carton); } catch (DuplicateKeyException ignored) { } + } + } + private List pallets(FirstLegLoadingPlanCompletedV1.Order source, JsonNode rawSource) { if (source.pallets() != null && !source.pallets().isEmpty()) { return source.pallets().stream().map(pallet -> new DropshipRequests.Pallet(pallet.palletNo(), diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 6de76faf9..67935ab04 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -335,6 +335,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan ( id BIGINT NOT NULL COMMENT '入库计划ID(雪花ID)', plan_no VARCHAR(32) NOT NULL COMMENT '入库单号', customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + source_order_id BIGINT NOT NULL COMMENT '来源尾程订单ID,一张入仓单对应一张入库计划', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', status VARCHAR(32) NOT NULL DEFAULT 'PLANNED' COMMENT '入库计划状态', line_count INT NOT NULL DEFAULT 0 COMMENT '入仓码明细数量', @@ -344,8 +345,9 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan ( updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY (id), UNIQUE KEY uk_ll_inbound_plan_no (plan_no), - UNIQUE KEY uk_ll_inbound_plan_container (customer_id, container_no) -) ENGINE=InnoDB COMMENT='尾程入库计划表:每个租户的每个集装箱生成一张入库单'; + UNIQUE KEY uk_ll_inbound_plan_source_order (customer_id, source_order_id), + KEY idx_ll_inbound_plan_container (customer_id, container_no) +) ENGINE=InnoDB COMMENT='尾程入库计划表:每张来源入仓单生成一张入库计划,同柜计划共享集装箱号'; CREATE TABLE IF NOT EXISTS ll_inbound_plan_line ( id BIGINT NOT NULL COMMENT '入库计划明细ID(雪花ID)', @@ -362,6 +364,22 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_line ( KEY idx_ll_inbound_line_plan (plan_id) ) ENGINE=InnoDB COMMENT='尾程入库计划明细表:按入仓码和尾程订单维度记录'; +CREATE TABLE IF NOT EXISTS ll_inbound_plan_carton ( + id BIGINT NOT NULL COMMENT '箱唛作业主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + order_id BIGINT NULL COMMENT '海外首扫确权后的尾程订单ID', source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入库明细ID', + sku_code VARCHAR(128) NULL COMMENT 'SKU编码快照', carton_mark VARCHAR(128) NOT NULL COMMENT '国内冻结箱唛', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', plan_id BIGINT NULL COMMENT '海外入库计划ID', + line_id BIGINT NULL COMMENT '入库计划明细ID', plan_pallet_id BIGINT NULL COMMENT '海外计划托盘ID', warehouse_pallet_id BIGINT NULL COMMENT '海外实物托盘ID', + warehouse_location_id BIGINT NULL COMMENT '海外库位ID', pda_task_id BIGINT NULL COMMENT '首次扫码PDA任务ID', + status VARCHAR(32) NOT NULL COMMENT '箱唛状态:UNASSIGNED、ON_PALLET、AVAILABLE', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + bound_at DATETIME(3) NULL COMMENT '绑定托盘时间', received_at DATETIME(3) NULL COMMENT '完成入库时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_inbound_carton_mark(customer_id,carton_mark), + KEY idx_ll_inbound_carton_plan(plan_id,status), KEY idx_ll_inbound_carton_pallet(plan_pallet_id,status), + KEY idx_ll_inbound_carton_order(order_id,status) +) ENGINE=InnoDB COMMENT='柜次冻结箱唛及海外入库箱级作业账'; + CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet ( id BIGINT NOT NULL COMMENT '计划托盘ID(雪花ID)', plan_id BIGINT NOT NULL COMMENT '入库计划ID', @@ -390,12 +408,13 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet_allocation ( KEY idx_ll_inbound_allocation_pallet (pallet_id) ) ENGINE=InnoDB COMMENT='尾程入库计划托盘分配表:记录入仓码明细与计划托盘的分配关系'; --- 修复既有入库计划表中的英文注释。 -ALTER TABLE ll_inbound_plan COMMENT='尾程入库计划表:每个租户的每个集装箱生成一张入库单'; +-- 修复既有入库计划表的注释;历史“每柜一张计划”记录不在这里猜测拆分。 +ALTER TABLE ll_inbound_plan COMMENT='尾程入库计划表:每张来源入仓单生成一张入库计划,同柜计划共享集装箱号'; ALTER TABLE ll_inbound_plan MODIFY COLUMN id BIGINT NOT NULL COMMENT '入库计划ID(雪花ID)', MODIFY COLUMN plan_no VARCHAR(32) NOT NULL COMMENT '入库单号', MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + MODIFY COLUMN source_order_id BIGINT NOT NULL COMMENT '来源尾程订单ID,一张入仓单对应一张入库计划', MODIFY COLUMN container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'PLANNED' COMMENT '入库计划状态', MODIFY COLUMN line_count INT NOT NULL DEFAULT 0 COMMENT '入仓码明细数量', diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java index 1e2c79ed6..950a00202 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java @@ -4,6 +4,7 @@ import com.baiyue.lastleg.inboundplan.entity.InboundPlan; import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests; import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge; import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation; @@ -94,7 +95,8 @@ class InboundPdaWorkflowServiceTest { targetPallet.setWarehouseResourceLockId(null); targetPallet.setAssignmentStatus("PENDING_ASSIGNMENT"); when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet); - when(warehouse.reserve("100", 88L, 188L, 288L, "INBOUND_PLAN_PALLET", "LIP1:21", "assign-1")) + when(warehouse.reserve("100", 88L, 188L, 288L, "INBOUND_CONTAINER_DESTINATION_PALLET", "CONT-1:88", + "INBOUND_CONTAINER_DESTINATION_PALLET:CONT-1:88")) .thenReturn(999L); when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet)); @@ -125,6 +127,66 @@ class InboundPdaWorkflowServiceTest { targetPallet.getWarehouseLocationId(), "assign-wrong-warehouse"))); } + @Test + void cartonScanBindsOnlyExpectedCartonAndRemoveMakesItScannableAgain() { + InboundPlanCarton carton = carton("BOX-001", "UNASSIGNED", targetLine.getId(), "WH-A"); + activeTask(); + when(repository.lockCarton("100", "BOX-001")).thenReturn(carton); + when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet); + when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine); + when(repository.lockAllocation(plan.getId(), targetLine.getId(), targetPallet.getId())).thenReturn(target); + + var scanned = service.scanCarton("100", "LIP1", + new PdaRequests.ScanCarton("BOX-001", targetPallet.getId(), "scan-1", "7", "worker")); + + assertEquals("SCANNED", scanned.result()); + assertEquals("ON_PALLET", carton.getStatus()); + assertEquals(1, carton.getVersion()); + assertEquals(1, target.getPickedPackageCount()); + verify(repository).update(carton); + + var removed = service.removeCarton("100", "LIP1", + new PdaRequests.RemoveCarton("BOX-001", "remove-1", "7", "worker")); + assertEquals("REMOVED", removed.result()); + assertEquals("UNASSIGNED", carton.getStatus()); + assertEquals(0, target.getPickedPackageCount()); + assertEquals(3, targetPallet.getActualPackageCount()); + } + + @Test + void cartonScanRejectsDuplicateAndWrongDestination() { + InboundPlanCarton repeated = carton("BOX-002", "ON_PALLET", targetLine.getId(), "WH-A"); + activeTask(); + when(repository.lockCarton("100", "BOX-002")).thenReturn(repeated); + assertThrows(ResponseStatusException.class, () -> service.scanCarton("100", "LIP1", + new PdaRequests.ScanCarton("BOX-002", targetPallet.getId(), "scan-repeat", "7", "worker"))); + + InboundPlanCarton mismatched = carton("BOX-003", "UNASSIGNED", targetLine.getId(), "WH-B"); + when(repository.lockCarton("100", "BOX-003")).thenReturn(mismatched); + when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet); + assertThrows(ResponseStatusException.class, () -> service.scanCarton("100", "LIP1", + new PdaRequests.ScanCarton("BOX-003", targetPallet.getId(), "scan-destination", "7", "worker"))); + } + + @Test + void cartonScanUsesSourceInboundDetailIdWhenOrdersShareSkuAndDestination() { + InboundPlanCarton carton = carton("BOX-004", "UNASSIGNED", null, "WH-A"); + carton.setSkuCode("SKU-SHARED"); + carton.setSourceInboundDetailId("SOURCE-DETAIL-SECOND"); + activeTask(); + when(repository.lockCarton("100", "BOX-004")).thenReturn(carton); + when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet); + when(repository.lineForCarton(plan.getId(), carton)).thenReturn(sourceLine); + when(repository.lockAllocation(plan.getId(), sourceLine.getId(), targetPallet.getId())).thenReturn(null); + + service.scanCarton("100", "LIP1", + new PdaRequests.ScanCarton("BOX-004", targetPallet.getId(), "scan-second-order", "7", "worker")); + + assertEquals(sourceLine.getId(), carton.getLineId()); + assertEquals(sourceLine.getOrderId(), carton.getOrderId()); + verify(repository).lineForCarton(plan.getId(), carton); + } + @Test void countMustPrecedePickAndPickedBoxesDriveActualVolume() { activeTask(); @@ -313,6 +375,7 @@ class InboundPdaWorkflowServiceTest { value.setId(1L); value.setCustomerId("100"); value.setPlanNo("LIP1"); + value.setContainerNo("CONT-1"); value.setStatus("ASSIGNED"); return value; } @@ -371,4 +434,12 @@ class InboundPdaWorkflowServiceTest { value.setVersion(0); return value; } + + private InboundPlanCarton carton(String mark, String status, Long lineId, String warehouseId) { + InboundPlanCarton value = new InboundPlanCarton(); + value.setId(41L); value.setCustomerId("100"); value.setPlanId(plan.getId()); value.setLineId(lineId); + value.setCartonMark(mark); value.setDestinationWarehouseId(warehouseId); value.setStatus(status); value.setVersion(0); + if ("ON_PALLET".equals(status)) value.setPlanPalletId(targetPallet.getId()); + return value; + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java index 179612fe1..a534f549d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java @@ -56,13 +56,10 @@ class InboundPlanServiceTest { } @Test - void convertsWholeContainerInOneTransaction() throws Exception { + void convertsOnlyTheSelectedInboundOrderToItsOwnPlan() throws Exception { assertNotNull(InboundPlanService.class.getMethod("convert", String.class, Long.class) .getAnnotation(Transactional.class)); - when(orderMapper.findContainerNo("100", 2L)).thenReturn("CONT-1"); - when(orderMapper.lockContainerOrders("100", "CONT-1")).thenReturn(List.of( - order(1L, "RK-1", "1.5000", "WH-A"), - order(2L, "RK-2", "1.0000", "WH-A"))); + when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(order(2L, "RK-2", "1.0000", "WH-A"))); when(orderMapper.markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any())) .thenReturn(1); @@ -70,26 +67,29 @@ class InboundPlanServiceTest { ArgumentCaptor plan = ArgumentCaptor.forClass(InboundPlan.class); verify(planMapper).insert(plan.capture()); - assertEquals(2, plan.getValue().getLineCount()); + assertEquals(2L, plan.getValue().getSourceOrderId()); + assertEquals(1, plan.getValue().getLineCount()); assertEquals("PENDING_ASSIGNMENT", plan.getValue().getStatus()); - assertEquals(2, plan.getValue().getPalletCount()); - assertEquals(new BigDecimal("2.5000"), plan.getValue().getTotalVolumeCbm()); - verify(palletMapper, times(2)).insert(any(InboundPlanPallet.class)); + assertEquals(1, plan.getValue().getPalletCount()); + assertEquals(new BigDecimal("1.0000"), plan.getValue().getTotalVolumeCbm()); + verify(palletMapper).insert(any(InboundPlanPallet.class)); ArgumentCaptor lines = ArgumentCaptor.forClass(InboundPlanLine.class); - verify(lineMapper, times(2)).insert(lines.capture()); - assertEquals(25, lines.getAllValues().stream().mapToInt(InboundPlanLine::getRequiredPackageCount).sum()); + verify(lineMapper).insert(lines.capture()); + assertEquals(10, lines.getValue().getRequiredPackageCount()); + assertEquals(2L, lines.getValue().getOrderId()); ArgumentCaptor allocations = ArgumentCaptor.forClass(InboundPlanPalletAllocation.class); - verify(allocationMapper, times(3)).insert(allocations.capture()); - assertEquals(25, allocations.getAllValues().stream() + verify(allocationMapper).insert(allocations.capture()); + assertEquals(10, allocations.getAllValues().stream() .mapToInt(InboundPlanPalletAllocation::getPlannedPackageCount).sum()); assertEquals(0, allocations.getAllValues().stream() .mapToInt(InboundPlanPalletAllocation::getPickedPackageCount).sum()); - assertEquals(3, allocations.getAllValues().stream() + assertEquals(1, allocations.getAllValues().stream() .filter(item -> "PENDING_COUNT".equals(item.getStatus())).count()); - verify(orderMapper, times(2)).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any()); - verify(orderMapper, times(2)).insertHistory(any(), any(), any(), any()); - verify(orderMapper).lockContainerOrders("100", "CONT-1"); + verify(orderMapper).markPlanned(any(), org.mockito.ArgumentMatchers.eq(2L), any(), org.mockito.ArgumentMatchers.anyInt(), any()); + verify(orderMapper).insertHistory(org.mockito.ArgumentMatchers.eq(2L), any(), any(), any()); + verify(orderMapper).lockOrder("100", 2L); + assertEquals(2L, result.sourceOrderId()); assertEquals("CONT-1", result.containerNo()); } @@ -104,21 +104,22 @@ class InboundPlanServiceTest { existing.setLineCount(1); existing.setPalletCount(1); existing.setTotalVolumeCbm(BigDecimal.ONE); - when(orderMapper.findContainerNo("100", 1L)).thenReturn("CONT-1"); - when(orderMapper.lockContainerOrders("100", "CONT-1")).thenReturn(List.of(order(1L, "RK-1", "1", "WH-A"))); - when(planMapper.findByContainer("100", "CONT-1")).thenReturn(existing); + existing.setSourceOrderId(1L); + when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(order(1L, "RK-1", "1", "WH-A"))); + when(planMapper.findBySourceOrder("100", 1L)).thenReturn(existing); - assertEquals("LIP9", service.convert("100", 1L).planNo()); + var result = service.convert("100", 1L); + assertEquals("LIP9", result.planNo()); + assertEquals(1L, result.sourceOrderId()); verify(planMapper, never()).insert(any(InboundPlan.class)); verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any()); } @Test void rejectsMissingDestinationWarehouse() { - when(orderMapper.findContainerNo("100", 1L)).thenReturn("CONT-1"); SourceOrderSnapshot invalid = new SourceOrderSnapshot(1L, "100", "CONT-1", "RK-1", "PENDING_ASSIGNMENT", null, BigDecimal.ONE, 1, null, null, null, 1, 0); - when(orderMapper.lockContainerOrders("100", "CONT-1")).thenReturn(List.of(invalid)); + when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(invalid)); assertThrows(ResponseStatusException.class, () -> service.convert("100", 1L)); verify(planMapper, never()).insert(any(InboundPlan.class)); @@ -126,10 +127,9 @@ class InboundPlanServiceTest { @Test void rejectsTransferWarehouseThatDiffersFromCargoSnapshots() { - when(orderMapper.findContainerNo("100", 1L)).thenReturn("CONT-1"); SourceOrderSnapshot invalid = new SourceOrderSnapshot(1L, "100", "CONT-1", "RK-1", "DESTINATION_ASSIGNED", null, BigDecimal.ONE, 1, "WH-B", "WH-B", "Warehouse B", 1, 1); - when(orderMapper.lockContainerOrders("100", "CONT-1")).thenReturn(List.of(invalid)); + when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(invalid)); assertThrows(ResponseStatusException.class, () -> service.convert("100", 1L)); verify(planMapper, never()).insert(any(InboundPlan.class)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index 30aa475d0..4f3142a49 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -1,13 +1,24 @@ package com.baiyue.lastleg.service; -import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com.baiyue.lastleg.mapper.*; import com.baiyue.lastleg.dropship.dto.DropshipRequests; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.fasterxml.jackson.databind.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.Instant; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com.baiyue.lastleg.mapper.*; import com.baiyue.lastleg.dropship.dto.DropshipRequests; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.lastleg.inboundplan.service.InboundPlanService; import com.fasterxml.jackson.databind.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.Instant; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class) class LoadingCompletedConsumerTest { - @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock DropshipOrderService dropshipOrders; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service; - @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); } - @Test void createsOneOrderPerInboundAndAllSnapshots(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); } + @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock DropshipOrderService dropshipOrders; @Mock InboundPlanService inboundPlans; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service; + @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inboundPlans",inboundPlans); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); } + @Test void createsOneOrderAndOneInboundPlanPerInbound(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(inboundPlans,times(2)).convert(eq("100"),anyLong()); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); } @Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); } @Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); } @Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); } @Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","ONE_CLICK_DROPSHIP",List.of(pallet)); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); } + @Test void consumesV2LoadingEventAndKeepsDropshipSnapshot(){ + EventEnvelope source=event(); + JsonNode node=source.payload().deepCopy(); + ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0).path("items").get(0)).put("customerRemark","fragile"); + String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); + EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(), + source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(), + 2L,source.traceId(),source.correlationId(),source.causationId(),node,hash); + assertEquals(2,service.consume(v2)); + verify(orders,times(2)).insert(any(LastlegOrder.class)); + } private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU1","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1)),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2)))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); } }