diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java index cb0d36542..64770e5ef 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java @@ -3,7 +3,7 @@ package com.baiyue.common.event; import java.math.BigDecimal; import java.util.List; -/** 装柜完成交接快照,包含三类逐货物备注及一键代发托盘快照。 */ +/** 装柜完成交接快照,包含三类逐货物备注、推荐托数和可复用的头程托盘编号快照。 */ public record FirstLegLoadingPlanCompletedV2( String loadingPlanId, String planNo, String containerBookingId, String containerNo, String cabinetNo, String warehouseId, String warehouseName, @@ -71,7 +71,7 @@ public record FirstLegLoadingPlanCompletedV2( } } - /** 一键代发入仓单的托盘冻结快照。 */ + /** 头程托盘冻结快照;普通入仓用于复用打印编号,一键代发还冻结 SKU/箱唛库存。 */ public record PalletSnapshot(String palletNo, List skuPackages) {} /** 托盘下 SKU、箱数、数量及箱号冻结快照;cartonMarks 中每个箱号都对应 skuCode。 */ diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 4570d9bdc..6f92303cc 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -4938,6 +4938,21 @@ BEGIN ALTER TABLE fl_container_booking ADD COLUMN merged_into_container_booking_id BIGINT UNSIGNED NULL COMMENT '真实柜被模拟柜吸收后的目标稳定柜次ID' AFTER record_status; END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='fl_container_booking' AND column_name='customer_id') THEN + ALTER TABLE fl_container_booking ADD COLUMN customer_id BIGINT UNSIGNED NULL + COMMENT '已关联真实订舱指定的客户范围' AFTER ocean_booking_requirement_id; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='fl_container_booking' AND column_name='customer_name') THEN + ALTER TABLE fl_container_booking ADD COLUMN customer_name VARCHAR(128) NULL + COMMENT '已关联真实订舱客户名称快照' AFTER customer_id; + END IF; + UPDATE fl_container_booking container_booking + JOIN fl_ocean_booking ocean_booking ON ocean_booking.id=container_booking.ocean_booking_id + SET container_booking.customer_id=COALESCE(container_booking.customer_id,ocean_booking.customer_id), + container_booking.customer_name=COALESCE(container_booking.customer_name,ocean_booking.customer_name) + WHERE container_booking.customer_id IS NULL OR container_booking.customer_name IS NULL; IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND index_name='uk_container_booking_container_no') THEN ALTER TABLE fl_container_booking ADD UNIQUE KEY uk_container_booking_container_no (container_no); @@ -15390,11 +15405,17 @@ BEGIN IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_booking' AND column_name='ocean_booking_requirement_id') THEN ALTER TABLE baiyue_firstleg.fl_container_booking ADD COLUMN ocean_booking_requirement_id BIGINT UNSIGNED NULL COMMENT '来源订舱箱型需求ID' AFTER ocean_booking_id; END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_booking' AND column_name='customer_id') THEN + ALTER TABLE baiyue_firstleg.fl_container_booking ADD COLUMN customer_id BIGINT UNSIGNED NULL COMMENT '已关联真实订舱指定的客户范围' AFTER ocean_booking_requirement_id; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_booking' AND column_name='customer_name') THEN + ALTER TABLE baiyue_firstleg.fl_container_booking ADD COLUMN customer_name VARCHAR(128) NULL COMMENT '已关联真实订舱客户名称快照' AFTER customer_id; + END IF; UPDATE baiyue_firstleg.fl_container_booking c JOIN baiyue_firstleg.fl_ocean_booking_container_allocation a ON a.container_booking_id=c.id JOIN baiyue_firstleg.fl_ocean_booking b ON b.id=a.ocean_booking_id - SET c.ocean_booking_id=b.id,c.ocean_booking_requirement_id=a.requirement_id,c.channel_code=COALESCE(c.channel_code,b.channel_code),c.channel_name=COALESCE(c.channel_name,b.channel_name) + SET c.ocean_booking_id=b.id,c.ocean_booking_requirement_id=a.requirement_id,c.channel_code=COALESCE(c.channel_code,b.channel_code),c.channel_name=COALESCE(c.channel_name,b.channel_name),c.customer_id=COALESCE(c.customer_id,b.customer_id),c.customer_name=COALESCE(c.customer_name,b.customer_name) WHERE c.ocean_booking_id IS NULL OR c.ocean_booking_requirement_id IS NULL - OR c.channel_code IS NULL OR c.channel_name IS NULL; + OR c.channel_code IS NULL OR c.channel_name IS NULL OR c.customer_id IS NULL OR c.customer_name IS NULL; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_loading_pick_task' AND column_name='container_booking_id') THEN ALTER TABLE baiyue_firstleg.fl_loading_pick_task ADD COLUMN container_booking_id BIGINT UNSIGNED NULL COMMENT '稳定柜次ID' AFTER loading_plan_id; END IF; 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 b613660dd..8cb8b2ca0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java @@ -38,7 +38,7 @@ public interface LoadingCompletedEventMapper { """) List rows(@Param("planId") long planId); - /** 读取一键代发托盘及其箱唛快照;普通订单不会使用该结果。 */ + /** 读取头程托盘及箱唛快照;普通订单向尾程交接打印编号,一键代发同时冻结托盘库存。 */ @Select(""" SELECT i.account_id, i.inbound_id AS source_inbound_id, a.pallet_code, a.allocation_no, a.picked_package_count AS allocated_package_count, 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 2249da91b..2d42ae32c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -89,8 +89,7 @@ public class LoadingCompletedOutboxService { cartonMarks.getOrDefault(row.getLoadingPlanItemId(), List.of()))) .toList(); String orderType = dropship(first.getBusinessType()) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG"; - List pallets = dropship(first.getBusinessType()) - ? palletSnapshots(sorted, palletRows) : List.of(); + List pallets = palletSnapshots(sorted, palletRows); if ("ONE_CLICK_DROPSHIP".equals(orderType) && pallets.isEmpty()) { throw new BizException(ResultCode.CONFLICT, "一键代发入仓单缺少托盘冻结快照"); } @@ -99,7 +98,7 @@ public class LoadingCompletedOutboxService { string(first.getRootOrderId()), first.getInboundCode(), string(first.getDestinationWarehouseId()), first.getDestinationWarehouseCode(), first.getDestinationWarehouseName(), sumInt(sorted, Row::getPackageCount), sumNullableInt(sorted, Row::getProductQuantity), "ONE_CLICK_DROPSHIP".equals(orderType) - ? pallets.size() : 0, sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg), + ? pallets.size() : sumNullableInt(sorted, Row::getPalletCount), sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg), sum(sorted, Row::getVolumeCbm), items, string(first.getAccountId()), orderType, pallets); } diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index 5086fa25d..bd13ba015 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -132,6 +132,21 @@ BEGIN ALTER TABLE fl_container_booking ADD COLUMN merged_into_container_booking_id BIGINT UNSIGNED NULL COMMENT '真实柜被模拟柜吸收后的目标稳定柜次ID' AFTER record_status; END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='fl_container_booking' AND column_name='customer_id') THEN + ALTER TABLE fl_container_booking ADD COLUMN customer_id BIGINT UNSIGNED NULL + COMMENT '已关联真实订舱指定的客户范围' AFTER ocean_booking_requirement_id; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='fl_container_booking' AND column_name='customer_name') THEN + ALTER TABLE fl_container_booking ADD COLUMN customer_name VARCHAR(128) NULL + COMMENT '已关联真实订舱客户名称快照' AFTER customer_id; + END IF; + UPDATE fl_container_booking container_booking + JOIN fl_ocean_booking ocean_booking ON ocean_booking.id=container_booking.ocean_booking_id + SET container_booking.customer_id=COALESCE(container_booking.customer_id,ocean_booking.customer_id), + container_booking.customer_name=COALESCE(container_booking.customer_name,ocean_booking.customer_name) + WHERE container_booking.customer_id IS NULL OR container_booking.customer_name IS NULL; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND column_name='unloading_completed_at') THEN ALTER TABLE fl_container_booking ADD COLUMN unloading_completed_at DATETIME(3) NULL diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java index d784cf5f6..f51066705 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java @@ -76,6 +76,19 @@ class SmartLoadingSqlContractTest { .contains("version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'"); } + @Test + @DisplayName("存量货柜迁移补齐BaseMapper所需客户快照列") + void shouldMigrateContainerCustomerSnapshotColumns() throws Exception { + for (String sql : Set.of(migration(), unifiedMigration())) { + assertThat(sql) + .contains("ADD COLUMN customer_id BIGINT UNSIGNED NULL") + .contains("ADD COLUMN customer_name VARCHAR(128) NULL") + .contains("JOIN fl_ocean_booking ocean_booking ON ocean_booking.id=container_booking.ocean_booking_id") + .contains("container_booking.customer_id=COALESCE(container_booking.customer_id,ocean_booking.customer_id)") + .contains("container_booking.customer_name=COALESCE(container_booking.customer_name,ocean_booking.customer_name)"); + } + } + @Test @DisplayName("仓库备注使用计划先行与明细单表CAS并允许已拣明细仅改备注") void shouldUpdateWarehouseRemarkWithoutMultiTableTriggerConflict() throws Exception { 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 0a3156576..87cf1e1a9 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -55,6 +55,7 @@ class LoadingCompletedOutboxServiceTest { assertThat(first.payload().at("/orders/0/items/0/sourceInboundDetailId").asText()).isEqualTo("101"); assertThat(first.payload().at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU101"); assertThat(first.payload().at("/orders/0/items/0/cartonMarks/0").asText()).isEqualTo("BOX-POOL-1"); + assertThat(first.payload().at("/orders/0/palletCount").asInt()).isEqualTo(2); assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:2"); assertThat(first.eventVersion()).isEqualTo(2); } @@ -118,6 +119,20 @@ class LoadingCompletedOutboxServiceTest { assertThat(event.payload().at("/orders/0/palletCount").asInt()).isEqualTo(2); } + @Test void publishesOrdinaryRecommendedPalletNumbersForTailLegReuse() throws Exception { + Row value = row(11, 100, 101); + when(snapshots.rows(90L)).thenReturn(List.of(value)); + when(snapshots.palletRows(90L)).thenReturn(List.of( + pallet(11, 100, 1, "FL-PLT-001", 1, 101, "SKU101", 1, 10, "BOX-POOL-1"))); + + service.publish(90L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOutboxEntity.class); + verify(outbox).insert(captor.capture()); + EventEnvelope event = mapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class); + assertThat(event.payload().at("/orders/0/pallets/0/palletNo").asText()).isEqualTo("FL-PLT-001"); + } + private Row row(long account,long inbound,long detail) { Row r=new Row(); r.setLoadingPlanId(90L); r.setPlanNo("P90"); r.setContainerBookingId(91L); r.setAggregateVersion(8); r.setContainerNo("CONT"); r.setCabinetNo("CAB"); r.setWarehouseId(92L); diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 4fef09376..1d14a5980 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -68,7 +68,7 @@ - 只接受冻结契约支持的事件类型和版本;校验事件 ID、租户、区域、Payload 哈希及必要字段。 - 同一实际集装箱 `containerNo` 生成一个共享业务尾程单号;`payload.orders` 每项按 `sourceInboundId` 独立落一条雪花 ID `ll_order`,并保存对应明细和初始状态历史。整柜操作不使用 `cabinetNo`。 - `ONE_CLICK_DROPSHIP` 每个来源入仓单必须携带一个或多个不可变来源托盘快照;每托按 SKU 携带箱数、数量和箱唛。校验订单汇总一致、托盘号/SKU/箱唛唯一后,按托建立档案及 SKU 库存,并建立箱唛和流水。仓库实物托盘由 Warehouse API 分配,不得覆盖来源托盘快照。 -- 其他订单类型只建立入仓单维度的尾程订单与冻结明细,来源 `palletCount` 强制为 0;后续 PDA 托盘属于海外仓作业资源。 +- 其他订单类型建立入仓单维度的尾程订单与冻结明细,并保留头程推荐托数;尾程入仓计划至少按推荐托数生成计划托盘,PDA 再把箱级事实绑定到海外仓实物托盘。 - 整个事件消费在一个事务内完成;任意订单失败时不得留下部分订单。 - 相同事件重复投递返回既有结果,不重新生成单号或插入明细。来源入仓单唯一约束是最终防线。 - 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanListResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanListResponse.java index 5a4e1c6a5..df4947455 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanListResponse.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanListResponse.java @@ -17,6 +17,7 @@ public record InboundPlanListResponse( String fbaWarehouseCode, String customerName, Integer packageCount, + Integer pickedPackageCount, BigDecimal volumeCbm, Integer palletCount, String status, @@ -27,4 +28,17 @@ public record InboundPlanListResponse( String dispatchStatus, LocalDateTime createdAt ) { + public InboundPlanListResponse(Long lineId, Long planId, String planNo, String customerId, + Long orderId, String inboundCode, String orderType, + String warehouseId, String warehouseCode, String warehouseName, + String fbaWarehouseCode, String customerName, Integer packageCount, + BigDecimal volumeCbm, Integer palletCount, String status, + String appointmentNo, String appointmentStatus, + LocalDateTime appointmentAt, LocalDateTime expectedLoadingAt, + String dispatchStatus, LocalDateTime createdAt) { + this(lineId, planId, planNo, customerId, orderId, inboundCode, orderType, warehouseId, + warehouseCode, warehouseName, fbaWarehouseCode, customerName, packageCount, 0, + volumeCbm, palletCount, status, appointmentNo, appointmentStatus, appointmentAt, + expectedLoadingAt, dispatchStatus, createdAt); + } } 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 e4e1c5e70..ffafec563 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 @@ -14,10 +14,20 @@ public record InboundPlanResponse( int lineCount, int palletCount, BigDecimal totalVolumeCbm, + int expectedPackageCount, + int pickedPackageCount, List lines, List pallets, List allocations ) { + public InboundPlanResponse(Long id, String planNo, String customerId, Long sourceOrderId, + String containerNo, String status, int lineCount, int palletCount, + BigDecimal totalVolumeCbm, List lines, List pallets, + List allocations) { + this(id, planNo, customerId, sourceOrderId, containerNo, status, lineCount, palletCount, + totalVolumeCbm, lines.stream().mapToInt(Line::requiredPackageCount).sum(), + allocations.stream().mapToInt(Allocation::pickedPackageCount).sum(), lines, pallets, allocations); + } public record Line(Long id, Long orderId, String inboundCode, String fbaWarehouseCode, String destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, BigDecimal volumeCbm, int requiredPackageCount, int plannedPalletCount) { 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 56d3dda53..44952b38c 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 @@ -18,6 +18,7 @@ public interface InboundPlanOrderMapper { @Select("SELECT o.id AS orderId,o.customer_id AS customerId,o.container_no AS containerNo," + "o.inbound_code AS inboundCode,o.current_status AS currentStatus,o.inbound_order_no AS inboundOrderNo," + "COALESCE(o.volume_cbm,0) AS volumeCbm,COALESCE(o.package_count,0) AS packageCount," + + "COALESCE(o.pallet_count,0) AS recommendedPalletCount," + "NULLIF(o.fba_warehouse_code,'') AS fbaWarehouseCode," + "COALESCE(NULLIF(o.transfer_warehouse_id,''),MIN(NULLIF(d.destination_warehouse_id,''))) AS destinationWarehouseId," + "COALESCE(NULLIF(o.transfer_warehouse_code,''),MIN(NULLIF(d.destination_warehouse_code,''))) AS destinationWarehouseCode," + @@ -30,7 +31,7 @@ public interface InboundPlanOrderMapper { "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.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," + + "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.pallet_count,o.fba_warehouse_code," + "o.transfer_warehouse_id,o.transfer_warehouse_code,o.transfer_warehouse_name " + "ORDER BY o.id FOR UPDATE") List lockOrder(@Param("customerId") String customerId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java index ce4f67d05..97a4476f7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java @@ -39,7 +39,9 @@ public interface InboundPlanQueryMapper { "l.order_id orderId,l.inbound_code inboundCode,o.order_type orderType," + "l.destination_warehouse_id warehouseId,l.destination_warehouse_code warehouseCode," + "l.destination_warehouse_name warehouseName,l.fba_warehouse_code fbaWarehouseCode," + - "o.customer_name customerName,l.required_package_count packageCount,l.volume_cbm volumeCbm," + + "o.customer_name customerName,l.required_package_count packageCount," + + "COALESCE((SELECT SUM(pa.picked_package_count) FROM ll_inbound_plan_pallet_allocation pa WHERE pa.line_id=l.id),0) pickedPackageCount," + + "l.volume_cbm volumeCbm," + "l.planned_pallet_count palletCount,p.status status,a.appointment_no appointmentNo," + "a.appointment_status appointmentStatus,a.appointment_at appointmentAt," + "a.expected_loading_at expectedLoadingAt," + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/SourceOrderSnapshot.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/SourceOrderSnapshot.java index b4d7d5886..fed4c1eab 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/SourceOrderSnapshot.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/SourceOrderSnapshot.java @@ -11,6 +11,7 @@ public record SourceOrderSnapshot( String inboundOrderNo, BigDecimal volumeCbm, int packageCount, + int recommendedPalletCount, String fbaWarehouseCode, String destinationWarehouseId, String destinationWarehouseCode, @@ -18,13 +19,23 @@ public record SourceOrderSnapshot( int destinationCount, int destinationMismatchCount ) { + public SourceOrderSnapshot(Long orderId, String customerId, String containerNo, String inboundCode, + String currentStatus, String inboundOrderNo, BigDecimal volumeCbm, + int packageCount, String fbaWarehouseCode, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + int destinationCount, int destinationMismatchCount) { + this(orderId, customerId, containerNo, inboundCode, currentStatus, inboundOrderNo, volumeCbm, + packageCount, 0, fbaWarehouseCode, destinationWarehouseId, destinationWarehouseCode, + destinationWarehouseName, destinationCount, destinationMismatchCount); + } + public SourceOrderSnapshot(Long orderId, String customerId, String containerNo, String inboundCode, String currentStatus, String inboundOrderNo, BigDecimal volumeCbm, int packageCount, String destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, int destinationCount, int destinationMismatchCount) { this(orderId, customerId, containerNo, inboundCode, currentStatus, inboundOrderNo, volumeCbm, - packageCount, null, destinationWarehouseId, destinationWarehouseCode, + packageCount, 0, null, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName, destinationCount, destinationMismatchCount); } } 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 29056ca92..81dd62966 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 @@ -108,6 +108,15 @@ public class InboundPdaController { return ApiResponse.success(service.complete(scope(user, customerId), planNo)); } + @PostMapping("/partial-complete") + public ApiResponse partialComplete(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo, + @RequestParam(required = false) String customerId, + @Valid @RequestBody PdaRequests.ForceComplete request) { + requireOperate(user); + return ApiResponse.success(service.partialComplete(scope(user, customerId), planNo, request)); + } + @PostMapping("/unload-photos") public ApiResponse uploadUnloadPhotos(@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 018e04692..03d8a246c 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 @@ -20,8 +20,16 @@ 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 ScanCarton(@NotBlank String cartonMark, Long planPalletId, Integer packageCount, + @NotBlank String idempotencyKey, String operatorId, String operatorName) { + /** Backward-compatible single-carton request with an explicitly selected pallet. */ + public ScanCarton(String cartonMark, long planPalletId, String idempotencyKey, + String operatorId, String operatorName) { + this(cartonMark, planPalletId, 1, idempotencyKey, operatorId, operatorName); + } + } + + public record ForceComplete(@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 fa261c613..fd8a92754 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 @@ -10,7 +10,16 @@ public final class PdaResponses { public record Task(long taskId, String taskNo, String planNo, String status, LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt, List unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt, - LocalDateTime estimatedEmptyReturnAt) {} + LocalDateTime estimatedEmptyReturnAt, int expectedPackageCount, + int pickedPackageCount) { + public Task(long taskId, String taskNo, String planNo, String status, + LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt, + List unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt, + LocalDateTime estimatedEmptyReturnAt) { + this(taskId, taskNo, planNo, status, pushedAt, startedAt, completedAt, + unloadPhotoFileIds, unloadPhotoUploadedAt, estimatedEmptyReturnAt, 0, 0); + } + } public record UnloadPhotoFile(long fileId, String fileName, String contentType, long sizeBytes) {} @@ -29,5 +38,10 @@ public final class PdaResponses { boolean sourcePalletScrapped) {} public record Carton(String result, String cartonMark, long planPalletId, long allocationId, - int palletActualPackageCount) {} + int palletActualPackageCount, String palletNo, int scannedPackageCount) { + public Carton(String result, String cartonMark, long planPalletId, long allocationId, + int palletActualPackageCount) { + this(result, cartonMark, planPalletId, allocationId, palletActualPackageCount, null, 1); + } + } } 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 4c8b09a2a..3719e5f51 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 @@ -73,6 +73,9 @@ public class InboundPdaWorkflowService { } InboundPdaTask task = repository.lockTask(customerId, plan.getId()); LocalDateTime now = LocalDateTime.now(); + if (task != null && terminal(task.getStatus())) { + throw conflict("PDA inbound task is already completed"); + } if (task == null) { task = new InboundPdaTask(); task.setId(IdWorker.getId()); @@ -91,7 +94,7 @@ public class InboundPdaWorkflowService { plan.setStatus("PDA_PENDING"); plan.setUpdatedAt(now); repository.update(plan); - PdaResponses.Task result = task(task, planNo); + PdaResponses.Task result = taskResponse(task, plan, planNo); saveOperation(customerId, task.getId(), null, "PUSH", request.idempotencyKey(), request, result); return result; } @@ -132,7 +135,7 @@ public class InboundPdaWorkflowService { InboundPlan plan = requirePlan(customerId, planNo); InboundPdaTask task = repository.task(customerId, plan.getId()); if (task == null) throw notFound("PDA task not found"); - return task(task, planNo); + return taskResponse(task, plan, planNo); } @Transactional @@ -220,39 +223,62 @@ public class InboundPdaWorkflowService { 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()); + long requestedPalletId = request.planPalletId() == null ? 0L : request.planPalletId(); + InboundPlanPallet pallet = requestedPalletId > 0 + ? requirePallet(plan.getId(), requestedPalletId) + : autoPallet(plan, carton); 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())) { + int requestedCount = request.packageCount() == null ? 1 : request.packageCount(); + if (requestedCount < 1) throw conflict("Package count must be positive"); + int alreadyScanned = (int) repository.scannedCartons(plan.getId(), line.getId()); + if (alreadyScanned + requestedCount > value(line.getRequiredPackageCount())) { throw conflict("This inbound plan line has reached its required carton count"); } + List cartonsToScan = new ArrayList<>(); + cartonsToScan.add(carton); + if (requestedCount > 1) { + repository.cartons(plan.getId()).stream() + .filter(candidate -> !Objects.equals(candidate.getId(), carton.getId())) + .filter(candidate -> Objects.equals(candidate.getLineId(), line.getId())) + .filter(candidate -> "UNASSIGNED".equals(candidate.getStatus()) || "EXPECTED".equals(candidate.getStatus())) + .filter(candidate -> sameWarehouse(candidate, pallet)) + .limit(requestedCount - 1L).forEach(cartonsToScan::add); + if (cartonsToScan.size() != requestedCount) throw conflict("Not enough unscanned cartons for the requested quantity"); + } 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); + } else if (value(allocation.getPlannedPackageCount()) > 0 + && value(allocation.getPickedPackageCount()) + requestedCount > value(allocation.getPlannedPackageCount())) { + throw conflict("Requested carton quantity exceeds the recommended pallet package count"); } 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)); + LocalDateTime now = LocalDateTime.now(); + for (InboundPlanCarton item : cartonsToScan) { + item.setOrderId(line.getOrderId()); item.setPlanId(plan.getId()); item.setLineId(line.getId()); + item.setPlanPalletId(pallet.getId()); item.setWarehousePalletId(pallet.getWarehousePalletId()); + item.setWarehouseLocationId(pallet.getWarehouseLocationId()); item.setPdaTaskId(task.getId()); + item.setStatus("ON_PALLET"); item.setBoundAt(now); item.setUpdatedAt(now); + item.setVersion(value(item.getVersion()) + 1); repository.update(item); + } + allocation.setCountedPackageCount(value(allocation.getCountedPackageCount()) + requestedCount); + allocation.setPickedPackageCount(value(allocation.getPickedPackageCount()) + requestedCount); + allocation.setActualVolumeCbm(volume(allocation.getActualVolumeCbm()).add(perPackage.multiply(BigDecimal.valueOf(requestedCount))).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)); + pallet.setActualPackageCount(value(pallet.getActualPackageCount()) + requestedCount); + pallet.setActualVolumeCbm(volume(pallet.getActualVolumeCbm()).add(perPackage.multiply(BigDecimal.valueOf(requestedCount))).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()); + allocation.getId(), pallet.getActualPackageCount(), pallet.getPalletNo(), requestedCount); saveOperation(customerId, task.getId(), allocation.getId(), "SCAN_CARTON", request.idempotencyKey(), request, result); return result; } @@ -378,6 +404,7 @@ public class InboundPdaWorkflowService { InboundPdaTask task = repository.lockTask(customerId, plan.getId()); if (task == null) throw conflict("Push the inbound plan to PDA before operating"); if ("COMPLETED".equals(task.getStatus())) return task(task, planNo); + if ("PARTIAL_INBOUND".equals(task.getStatus())) throw conflict("PDA inbound task was partially completed"); List unloadPhotoFileIds = unloadPhotoFileIds(task); if (unloadPhotoFileIds.isEmpty() || task.getUnloadPhotoUploadedAt() == null || task.getEstimatedEmptyReturnAt() == null) { @@ -414,7 +441,36 @@ public class InboundPdaWorkflowService { plan.setUpdatedAt(now); repository.update(plan); unloadCompletedOutbox.publish(plan, task, unloadPhotoFileIds); - return task(task, planNo); + return taskResponse(task, plan, planNo); + } + + /** Explicit operator decision to receive only the cartons actually picked. */ + @Transactional + public PdaResponses.Task partialComplete(String customerId, String planNo, PdaRequests.ForceComplete request) { + InboundPlan plan = requirePlan(customerId, planNo); + InboundPdaOperation replay = replay(customerId, "PARTIAL_COMPLETE", request.idempotencyKey(), request); + if (replay != null) return read(replay, PdaResponses.Task.class); + InboundPdaTask task = requireActiveTask(customerId, plan); + List lines = repository.lines(plan.getId()); + List allocations = repository.allocations(plan.getId()); + int expected = lines.stream().mapToInt(line -> value(line.getRequiredPackageCount())).sum(); + int picked = allocations.stream().mapToInt(allocation -> value(allocation.getPickedPackageCount())).sum(); + if (picked <= 0) throw conflict("At least one carton must be picked before partial inbound completion"); + if (picked >= expected) throw conflict("All cartons are picked; use normal inbound completion"); + LocalDateTime now = LocalDateTime.now(); + stockReceipts.stockIn(customerId, plan, task, lines, allocations, repository.pallets(plan.getId()), now, true); + for (InboundPlanCarton carton : repository.cartons(plan.getId())) { + if ("ON_PALLET".equals(carton.getStatus())) { + carton.setStatus("AVAILABLE"); carton.setReceivedAt(now); carton.setUpdatedAt(now); + carton.setVersion(value(carton.getVersion()) + 1); repository.update(carton); + } + } + task.setStatus("PARTIAL_INBOUND"); task.setCompletedAt(now); task.setUpdatedAt(now); + task.setVersion(value(task.getVersion()) + 1); repository.update(task); + plan.setStatus("PARTIAL_INBOUND"); plan.setUpdatedAt(now); repository.update(plan); + PdaResponses.Task result = taskResponse(task, plan, planNo); + saveOperation(customerId, task.getId(), null, "PARTIAL_COMPLETE", request.idempotencyKey(), request, result); + return result; } @Transactional @@ -433,7 +489,7 @@ public class InboundPdaWorkflowService { task.setUpdatedAt(uploadedAt); task.setVersion(value(task.getVersion()) + 1); repository.update(task); - PdaResponses.Task result = task(task, planNo); + PdaResponses.Task result = taskResponse(task, plan, planNo); saveOperation(customerId, task.getId(), null, "UPLOAD_UNLOAD_PHOTOS", request.idempotencyKey(), request, result); return result; } @@ -597,7 +653,7 @@ public class InboundPdaWorkflowService { private InboundPdaTask requireActiveTask(String customerId, InboundPlan plan) { InboundPdaTask task = repository.lockTask(customerId, plan.getId()); if (task == null) throw conflict("Push the inbound plan to PDA before operating"); - if ("COMPLETED".equals(task.getStatus())) throw conflict("PDA task is already completed"); + if (terminal(task.getStatus())) throw conflict("PDA task is already completed"); return task; } @@ -613,6 +669,24 @@ public class InboundPdaWorkflowService { return value; } + private InboundPlanPallet autoPallet(InboundPlan plan, InboundPlanCarton carton) { + List candidates = repository.pallets(plan.getId()).stream() + .filter(pallet -> sameWarehouse(carton, pallet)) + .filter(pallet -> !isNotAssigned(pallet)) + .sorted(Comparator.comparingInt(pallet -> value(pallet.getSequenceNo()))) + .toList(); + if (candidates.isEmpty()) throw conflict("No assigned pallet exists for the carton destination warehouse"); + Map plannedByPallet = new HashMap<>(); + for (InboundPlanPalletAllocation allocation : repository.allocations(plan.getId())) { + plannedByPallet.merge(allocation.getPalletId(), value(allocation.getPlannedPackageCount()), Integer::sum); + } + InboundPlanPallet selected = candidates.stream() + .filter(pallet -> value(pallet.getActualPackageCount()) < plannedByPallet.getOrDefault(pallet.getId(), Integer.MAX_VALUE)) + .findFirst().orElse(null); + if (selected == null) throw conflict("All assigned pallets for the carton destination warehouse are full"); + return repository.lockPallet(plan.getId(), selected.getId()); + } + private InboundPlanLine requireLine(Long planId, Long id) { InboundPlanLine value = repository.line(planId, id); if (value == null) throw notFound("Inbound plan line not found"); @@ -629,9 +703,19 @@ public class InboundPdaWorkflowService { } private PdaResponses.Task task(InboundPdaTask task, String planNo) { + return task(task, planNo, 0, 0); + } + + private PdaResponses.Task taskResponse(InboundPdaTask task, InboundPlan plan, String planNo) { + int expected = repository.lines(plan.getId()).stream().mapToInt(line -> value(line.getRequiredPackageCount())).sum(); + int picked = repository.allocations(plan.getId()).stream().mapToInt(allocation -> value(allocation.getPickedPackageCount())).sum(); + return task(task, planNo, expected, picked); + } + + private PdaResponses.Task task(InboundPdaTask task, String planNo, int expected, int picked) { return new PdaResponses.Task(task.getId(), task.getTaskNo(), planNo, task.getStatus(), task.getPushedAt(), task.getStartedAt(), task.getCompletedAt(), unloadPhotoFileIds(task), - task.getUnloadPhotoUploadedAt(), task.getEstimatedEmptyReturnAt()); + task.getUnloadPhotoUploadedAt(), task.getEstimatedEmptyReturnAt(), expected, picked); } private List unloadPhotoFileIds(InboundPdaTask task) { @@ -725,6 +809,7 @@ public class InboundPdaWorkflowService { } private static int value(Integer value) { return value == null ? 0 : value; } + private static boolean terminal(String status) { return "COMPLETED".equals(status) || "PARTIAL_INBOUND".equals(status); } private static BigDecimal volume(BigDecimal value) { return value == null ? ZERO_VOLUME : value; } private static BigDecimal nonNegative(BigDecimal value) { return value.signum() < 0 ? ZERO_VOLUME : value.setScale(4, RoundingMode.HALF_UP); } private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java index 69fa42e4b..cef5764b8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java @@ -46,6 +46,13 @@ public class InboundStockReceiptService { public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task, List lines, List allocations, List pallets, LocalDateTime receivedAt) { + stockIn(customerId, plan, task, lines, allocations, pallets, receivedAt, false); + } + + @Transactional + public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task, + List lines, List allocations, + List pallets, LocalDateTime receivedAt, boolean allowPartial) { Map orderById = orders.selectBatchIds(lines.stream() .map(InboundPlanLine::getOrderId).collect(Collectors.toSet())).stream() .filter(order -> customerId.equals(order.getCustomerId())) @@ -64,8 +71,8 @@ public class InboundStockReceiptService { Set receivedAllocationIds = existing.stream().map(InboundStockReceipt::getAllocationId) .filter(Objects::nonNull).collect(Collectors.toSet()); if (receivedAllocationIds.equals(expectedAllocationIds)) { - activateDropship(customerId, task.getId(), orderById, existing); - completeOrders(customerId, plan, orderById, receivedAt); + if (!allowPartial) activateDropship(customerId, task.getId(), orderById, existing); + if (!allowPartial) completeOrders(customerId, plan, orderById, receivedAt); return; } throw conflict("PDA stock-in receipts are incomplete; repair the task receipts before retrying"); @@ -79,11 +86,13 @@ public class InboundStockReceiptService { .getOrDefault(line.getId(), List.of()); long pickedPackages = lineAllocations.stream() .mapToLong(allocation -> value(allocation.getPickedPackageCount())).sum(); - if (pickedPackages != value(line.getRequiredPackageCount())) { + if (pickedPackages == 0 && allowPartial) continue; + if ((!allowPartial && pickedPackages != value(line.getRequiredPackageCount())) + || (allowPartial && pickedPackages > value(line.getRequiredPackageCount()))) { throw conflict("PDA picked packages must equal the inbound order package total"); } List skuRemainders = "ONE_CLICK_DROPSHIP".equals(order.getOrderType()) - ? List.of() : skuRemainders(order.getId(), pickedPackages); + ? List.of() : skuRemainders(order.getId(), value(line.getRequiredPackageCount())); for (InboundPlanPalletAllocation allocation : lineAllocations) { InboundPlanPallet pallet = requirePallet(palletById, allocation); if ("ONE_CLICK_DROPSHIP".equals(order.getOrderType())) { @@ -96,12 +105,14 @@ public class InboundStockReceiptService { } } } - if (skuRemainders.stream().anyMatch(value -> value.remaining > 0)) { + if (!allowPartial && skuRemainders.stream().anyMatch(value -> value.remaining > 0)) { throw conflict("Inbound SKU package snapshots were not fully received"); } } - activateDropship(customerId, task.getId(), orderById, created); - completeOrders(customerId, plan, orderById, receivedAt); + if (!allowPartial) { + activateDropship(customerId, task.getId(), orderById, created); + completeOrders(customerId, plan, orderById, receivedAt); + } } private void completeOrders(String customerId, InboundPlan plan, Map orderById, 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 8ec5e1d9d..ba081a5c7 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 @@ -57,6 +57,11 @@ public class InboundPlanService { @Transactional public InboundPlanResponse convert(String customerId, Long selectedOrderId) { + return convert(customerId, selectedOrderId, List.of()); + } + + @Transactional + public InboundPlanResponse convert(String customerId, Long selectedOrderId, List sourcePalletNos) { List orders = orderMapper.lockOrder(customerId, selectedOrderId); if (orders == null || orders.isEmpty()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound order not found"); @@ -88,13 +93,14 @@ public class InboundPlanService { Map lineByOrder = createLines(plan, orders); PalletAllocator.Result allocation = PalletAllocator.allocate(orders.stream() .map(order -> new PalletAllocator.LineInput(order.orderId(), warehouseKey(order), volume(order), - order.packageCount())) + order.packageCount(), order.recommendedPalletCount())) .toList()); plan.setPalletCount(allocation.pallets().size()); planMapper.insert(plan); lineByOrder.values().forEach(lineMapper::insert); - Map persistedPallets = createPallets(plan, allocation, orderById); + Map persistedPallets = createPallets(plan, allocation, orderById, + sourcePalletNos == null ? List.of() : sourcePalletNos); createAllocations(plan, allocation, lineByOrder, persistedPallets); Map palletCounts = allocation.allocations().stream().collect(Collectors.groupingBy( @@ -175,7 +181,8 @@ public class InboundPlanService { } private Map createPallets( - InboundPlan plan, PalletAllocator.Result allocation, Map orderById) { + InboundPlan plan, PalletAllocator.Result allocation, Map orderById, + List sourcePalletNos) { Map warehouseSamples = orderById.values().stream() .collect(Collectors.toMap(this::warehouseKey, value -> value, (left, right) -> left)); Map result = new HashMap<>(); @@ -185,7 +192,10 @@ public class InboundPlanService { pallet.setId(IdWorker.getId()); pallet.setCustomerId(plan.getCustomerId()); pallet.setPlanId(plan.getId()); - pallet.setPalletNo(plan.getPlanNo() + "-P" + String.format("%03d", result.size() + 1)); + int palletIndex = result.size(); + String sourcePalletNo = palletIndex < sourcePalletNos.size() ? sourcePalletNos.get(palletIndex) : null; + pallet.setPalletNo(sourcePalletNo == null || sourcePalletNo.isBlank() + ? plan.getPlanNo() + "-P" + String.format("%03d", palletIndex + 1) : sourcePalletNo.trim()); pallet.setDestinationWarehouseId(sample.destinationWarehouseId()); pallet.setDestinationWarehouseCode(sample.destinationWarehouseCode()); pallet.setDestinationWarehouseName(sample.destinationWarehouseName()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/PalletAllocator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/PalletAllocator.java index 74f6f0af9..0726c1d08 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/PalletAllocator.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/PalletAllocator.java @@ -34,6 +34,23 @@ public final class PalletAllocator { PalletDraft current = null; int sequence = 0; for (LineInput line : group) { + if (line.recommendedPalletCount() > 0) { + int minimumByVolume = line.volumeCbm().divide(PALLET_CAPACITY_CBM, 0, RoundingMode.CEILING).intValue(); + int count = Math.max(line.recommendedPalletCount(), minimumByVolume); + BigDecimal remaining = line.volumeCbm(); + for (int index = 0; index < count; index++) { + PalletDraft recommended = new PalletDraft(line.warehouseKey(), ++sequence, BigDecimal.ZERO); + pallets.add(recommended); + int remainingPallets = count - index; + BigDecimal allocated = remainingPallets == 1 ? remaining + : remaining.divide(BigDecimal.valueOf(remainingPallets), 4, RoundingMode.HALF_UP); + recommended.add(allocated); + allocations.add(new AllocationDraft(line.orderId(), recommended, allocated, 0)); + remaining = remaining.subtract(allocated); + } + current = null; + continue; + } BigDecimal remaining = line.volumeCbm(); while (remaining.signum() > 0) { if (current == null || current.remainingCapacity().signum() == 0) { @@ -85,7 +102,11 @@ public final class PalletAllocator { return List.copyOf(result); } - public record LineInput(Long orderId, String warehouseKey, BigDecimal volumeCbm, int packageCount) { + public record LineInput(Long orderId, String warehouseKey, BigDecimal volumeCbm, int packageCount, + int recommendedPalletCount) { + public LineInput(Long orderId, String warehouseKey, BigDecimal volumeCbm, int packageCount) { + this(orderId, warehouseKey, volumeCbm, packageCount, 0); + } } public static final class PalletDraft { 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 e7407e43b..0dec5a0b3 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 @@ -156,7 +156,9 @@ public class LoadingCompletedConsumer { order.setOverseasWarehouseId(payload.warehouseId()); order.setOverseasWarehouseName(payload.warehouseName()); order.setFbaWarehouseCode(source.destinationWarehouseCode()); order.setContainerNo(payload.containerNo()); order.setContainerStatus("待转柜"); order.setAbnormal(false); order.setDetained(false); - order.setPackageCount(source.packageCount()); order.setPalletCount(dropshipPallets.size()); + order.setPackageCount(source.packageCount()); + order.setPalletCount("ONE_CLICK_DROPSHIP".equals(orderType) + ? dropshipPallets.size() : Objects.requireNonNullElse(source.palletCount(), 0)); order.setProductQuantity(source.productQuantity()); order.setVolumeCbm(source.volumeCbm()); order.setVolumeWeightKg(source.items().stream().map(FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg) .filter(Objects::nonNull).reduce(BigDecimal.ZERO,BigDecimal::add)); @@ -192,7 +194,11 @@ public class LoadingCompletedConsumer { 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)) { - InboundPlanResponse plan = inboundPlans.convert(customerId, order.getId()); + List sourcePalletNos = source.pallets() == null ? List.of() : source.pallets().stream() + .map(FirstLegLoadingPlanCompletedV1.PalletSnapshot::palletNo).filter(Objects::nonNull).toList(); + InboundPlanResponse plan = sourcePalletNos.isEmpty() + ? inboundPlans.convert(customerId, order.getId()) + : inboundPlans.convert(customerId, order.getId(), sourcePalletNos); registerInTransit(event, payload, source, order, plan.planNo()); } if ("ONE_CLICK_DROPSHIP".equals(orderType)) { 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 e1353af91..7f38e5550 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 @@ -161,6 +161,30 @@ class InboundPdaWorkflowServiceTest { assertEquals(3, targetPallet.getActualPackageCount()); } + @Test + void cartonScanCanAutoSelectDestinationPalletAndBindAQuantity() { + InboundPlanCarton first = carton("BOX-BATCH-1", "UNASSIGNED", targetLine.getId(), "WH-A"); + InboundPlanCarton second = carton("BOX-BATCH-2", "UNASSIGNED", targetLine.getId(), "WH-A"); + second.setId(42L); + activeTask(); + when(repository.lockCarton("100", "BOX-BATCH-1")).thenReturn(first); + when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet)); + when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet); + when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine); + when(repository.cartons(plan.getId())).thenReturn(List.of(first, second)); + when(repository.lockAllocation(plan.getId(), targetLine.getId(), targetPallet.getId())).thenReturn(target); + + var result = service.scanCarton("100", "LIP1", + new PdaRequests.ScanCarton("BOX-BATCH-1", null, 2, "scan-batch", "7", "worker")); + + assertEquals(targetPallet.getId(), result.planPalletId()); + assertEquals("PLT-21", result.palletNo()); + assertEquals(2, result.scannedPackageCount()); + assertEquals("ON_PALLET", first.getStatus()); + assertEquals("ON_PALLET", second.getStatus()); + assertEquals(2, target.getPickedPackageCount()); + } + @Test void cartonScanRejectsDuplicateAndWrongDestination() { InboundPlanCarton repeated = carton("BOX-002", "ON_PALLET", targetLine.getId(), "WH-A"); @@ -430,6 +454,26 @@ class InboundPdaWorkflowServiceTest { verify(repository, never()).allocations(plan.getId()); } + @Test + void partialCompleteStocksOnlyActualPickedPackagesAndKeepsPartialStatus() { + target.setPickedPackageCount(2); + activeTask(); + when(repository.lines(plan.getId())).thenReturn(List.of(targetLine)); + when(repository.allocations(plan.getId())).thenReturn(List.of(target)); + when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet)); + when(repository.cartons(plan.getId())).thenReturn(List.of()); + + var result = service.partialComplete("100", "LIP1", + new PdaRequests.ForceComplete("partial-1", "7", "worker")); + + assertEquals("PARTIAL_INBOUND", result.status()); + assertEquals(5, result.expectedPackageCount()); + assertEquals(2, result.pickedPackageCount()); + assertEquals("PARTIAL_INBOUND", plan.getStatus()); + verify(stockReceipts).stockIn(eq("100"), eq(plan), eq(task), eq(List.of(targetLine)), + eq(List.of(target)), eq(List.of(targetPallet)), any(), eq(true)); + } + @Test void rejectsReuseOfIdempotencyKeyWithDifferentActionOrPayload() { InboundPdaOperation existing = new InboundPdaOperation(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/PalletAllocatorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/PalletAllocatorTest.java index 663404360..8cc8eba1d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/PalletAllocatorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/PalletAllocatorTest.java @@ -49,6 +49,16 @@ class PalletAllocatorTest { .map(PalletAllocator.AllocationDraft::plannedPackageCount).toList()); } + @Test + void keepsAtLeastTheFirstLegRecommendedPalletCount() { + PalletAllocator.Result result = PalletAllocator.allocate(List.of( + new PalletAllocator.LineInput(1L, "WH-A", new BigDecimal("1.5000"), 6, 3))); + + assertEquals(3, result.pallets().size()); + assertEquals(6, result.allocations().stream() + .mapToInt(PalletAllocator.AllocationDraft::plannedPackageCount).sum()); + } + private PalletAllocator.Result allocate(String volume) { return PalletAllocator.allocate(List.of( new PalletAllocator.LineInput(1L, "WH-A", new BigDecimal(volume),