From b72dd54b8098c7eadb6a93accfa9cb57d992317d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 11:19:33 +0800 Subject: [PATCH 01/24] fix container customer snapshot schema migration --- .../event/FirstLegLoadingPlanCompletedV2.java | 4 +- deploy/sql/头程数据库统一增量-MySQL8.sql | 25 +++- .../mapper/LoadingCompletedEventMapper.java | 2 +- .../LoadingCompletedOutboxService.java | 5 +- .../db/phase3-smart-loading-schema.sql | 15 +++ .../SmartLoadingSqlContractTest.java | 13 ++ .../LoadingCompletedOutboxServiceTest.java | 15 +++ lastleg-service/AGENTS.md | 2 +- .../dto/InboundPlanListResponse.java | 14 ++ .../inboundplan/dto/InboundPlanResponse.java | 10 ++ .../mapper/InboundPlanOrderMapper.java | 3 +- .../mapper/InboundPlanQueryMapper.java | 4 +- .../model/SourceOrderSnapshot.java | 13 +- .../pda/controller/InboundPdaController.java | 9 ++ .../inboundplan/pda/dto/PdaRequests.java | 12 +- .../inboundplan/pda/dto/PdaResponses.java | 18 ++- .../service/InboundPdaWorkflowService.java | 123 +++++++++++++++--- .../service/InboundStockReceiptService.java | 25 +++- .../service/InboundPlanService.java | 18 ++- .../inboundplan/service/PalletAllocator.java | 23 +++- .../service/LoadingCompletedConsumer.java | 10 +- .../InboundPdaWorkflowServiceTest.java | 44 +++++++ .../service/PalletAllocatorTest.java | 10 ++ 23 files changed, 368 insertions(+), 49 deletions(-) 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), From ce9f08c7560c3f9777cc9881ec85f04a7fef18ca Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Mon, 24 Aug 2026 14:52:50 +0800 Subject: [PATCH 02/24] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/InboundPdaWorkflowService.java | 6 +++++ .../service/InboundStockReceiptService.java | 23 +++++++++++-------- .../InboundPdaWorkflowServiceTest.java | 18 +++++++++++++++ .../InboundStockReceiptServiceTest.java | 18 +++++++++++++++ 4 files changed, 55 insertions(+), 10 deletions(-) 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 3719e5f51..96a4dd50f 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 @@ -451,6 +451,11 @@ public class InboundPdaWorkflowService { InboundPdaOperation replay = replay(customerId, "PARTIAL_COMPLETE", request.idempotencyKey(), request); if (replay != null) return read(replay, PdaResponses.Task.class); InboundPdaTask task = requireActiveTask(customerId, plan); + List unloadPhotoFileIds = unloadPhotoFileIds(task); + if (unloadPhotoFileIds.isEmpty() || task.getUnloadPhotoUploadedAt() == null + || task.getEstimatedEmptyReturnAt() == null) { + throw conflict("Upload unload photos before partially completing the PDA inbound task"); + } List lines = repository.lines(plan.getId()); List allocations = repository.allocations(plan.getId()); int expected = lines.stream().mapToInt(line -> value(line.getRequiredPackageCount())).sum(); @@ -468,6 +473,7 @@ public class InboundPdaWorkflowService { 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); + unloadCompletedOutbox.publish(plan, task, unloadPhotoFileIds); PdaResponses.Task result = taskResponse(task, plan, planNo); saveOperation(customerId, task.getId(), null, "PARTIAL_COMPLETE", request.idempotencyKey(), request, result); return result; 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 cef5764b8..c428261aa 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 @@ -72,7 +72,7 @@ public class InboundStockReceiptService { .filter(Objects::nonNull).collect(Collectors.toSet()); if (receivedAllocationIds.equals(expectedAllocationIds)) { if (!allowPartial) activateDropship(customerId, task.getId(), orderById, existing); - if (!allowPartial) completeOrders(customerId, plan, orderById, receivedAt); + completeOrders(customerId, plan, orderById, receivedAt, allowPartial); return; } throw conflict("PDA stock-in receipts are incomplete; repair the task receipts before retrying"); @@ -109,29 +109,32 @@ public class InboundStockReceiptService { throw conflict("Inbound SKU package snapshots were not fully received"); } } - if (!allowPartial) { - activateDropship(customerId, task.getId(), orderById, created); - completeOrders(customerId, plan, orderById, receivedAt); - } + if (!allowPartial) activateDropship(customerId, task.getId(), orderById, created); + completeOrders(customerId, plan, orderById, receivedAt, allowPartial); } private void completeOrders(String customerId, InboundPlan plan, Map orderById, - LocalDateTime completedAt) { + LocalDateTime completedAt, boolean partial) { + String subOrderStatus = partial ? "INBOUND_PLAN:PARTIAL_INBOUND" : "INBOUND_PLAN:COMPLETED"; for (LastlegOrder snapshot : orderById.values()) { LastlegOrder order = orders.lockById(customerId, snapshot.getId()); if (order == null) throw conflict("Inbound tail-leg order disappeared while completing stock-in"); - if ("INBOUND_COMPLETED".equals(order.getCurrentStatus())) continue; + if ("INBOUND_COMPLETED".equals(order.getCurrentStatus()) + && subOrderStatus.equals(order.getSubOrderStatus())) continue; String before = order.getCurrentStatus(); order.setCurrentStatus("INBOUND_COMPLETED"); - order.setSubOrderStatus("INBOUND_PLAN:COMPLETED"); + order.setSubOrderStatus(subOrderStatus); order.setUpdatedAt(completedAt); orders.updateById(order); LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setOrderId(order.getId()); history.setFromStatus(before); history.setToStatus("INBOUND_COMPLETED"); - history.setEventId("PDA_INBOUND_COMPLETE:" + plan.getPlanNo()); - history.setRemark("PDA inbound completed and inventory stocked in"); + history.setEventId((partial ? "PDA_PARTIAL_INBOUND_COMPLETE:" : "PDA_INBOUND_COMPLETE:") + + plan.getPlanNo()); + history.setRemark(partial + ? "PDA inbound partially completed and actual inventory stocked in" + : "PDA inbound completed and inventory stocked in"); history.setCreatedAt(completedAt); orderHistories.insert(history); } 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 7f38e5550..d009229fe 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 @@ -472,6 +472,24 @@ class InboundPdaWorkflowServiceTest { 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)); + verify(unloadCompletedOutbox).publish(eq(plan), eq(task), eq(List.of(101L))); + } + + @Test + void partialCompleteRequiresUnloadPhotosBeforeStockIn() { + target.setPickedPackageCount(2); + task.setUnloadPhotoFileIds(null); + task.setUnloadPhotoUploadedAt(null); + task.setEstimatedEmptyReturnAt(null); + activeTask(); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.partialComplete("100", "LIP1", + new PdaRequests.ForceComplete("partial-no-photo", "7", "worker"))); + + assertEquals(409, exception.getStatusCode().value()); + verify(stockReceipts, never()).stockIn(any(), any(), any(), any(), any(), any(), any(), eq(true)); + verify(unloadCompletedOutbox, never()).publish(any(), any(), any()); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java index 1199e5409..75ba697ee 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java @@ -179,6 +179,24 @@ class InboundStockReceiptServiceTest { assertEquals(10L, activationReceipts.getValue().get(0).getInventoryPositionId()); } + @Test + void partialStockInSynchronizesOrderWithoutActivatingDropship() { + Fixture fixture = fixture(5, 2, 2, 10); + fixture.order.setOrderType("ONE_CLICK_DROPSHIP"); + when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order)); + when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class))) + .thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 2, 0, 0, 0, false, false)); + + service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line), + List.of(fixture.allocation), List.of(fixture.pallet), LocalDateTime.now(), true); + + assertEquals("INBOUND_COMPLETED", fixture.order.getCurrentStatus()); + assertEquals("INBOUND_PLAN:PARTIAL_INBOUND", fixture.order.getSubOrderStatus()); + verify(orders).updateById(fixture.order); + verify(orderHistories).insert(any(LastlegOrderStatusHistory.class)); + verify(dropshipOrders, never()).activateAfterStockIn(any(), any(), any(), any()); + } + private Fixture fixture(int required, int planned, int picked, int quantity) { InboundPlan plan = new InboundPlan(); plan.setId(1L); plan.setPlanNo("IP-1"); InboundPdaTask task = new InboundPdaTask(); task.setId(2L); task.setOperatorId("operator"); From 01675b8220857ba87a92f9f31b43f6caaf11bc50 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Mon, 24 Aug 2026 17:12:30 +0800 Subject: [PATCH 03/24] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../model/DropshipPalletSnapshot.java | 8 ++ .../service/DropshipOrderService.java | 34 ++++-- .../pda/controller/InboundPdaController.java | 10 ++ .../inboundplan/pda/dto/PdaRequests.java | 3 + .../inboundplan/pda/dto/PdaResponses.java | 17 ++- .../pda/repository/PdaWorkflowRepository.java | 5 + .../service/InboundPdaWorkflowService.java | 99 +++++++++++++++-- .../service/InboundStockReceiptService.java | 24 ++-- .../service/InboundPlanService.java | 103 ++++++++++++++++-- .../service/LoadingCompletedConsumer.java | 5 + .../service/DropshipOrderServiceTest.java | 2 +- .../InboundPdaWorkflowServiceTest.java | 52 +++++++++ .../InboundStockReceiptServiceTest.java | 23 ++++ .../service/InboundPlanServiceTest.java | 21 ++++ .../service/LoadingCompletedConsumerTest.java | 4 +- 15 files changed, 367 insertions(+), 43 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dropship/model/DropshipPalletSnapshot.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/model/DropshipPalletSnapshot.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/model/DropshipPalletSnapshot.java new file mode 100644 index 000000000..105ae33f1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/model/DropshipPalletSnapshot.java @@ -0,0 +1,8 @@ +package com.baiyue.lastleg.dropship.model; + +import java.util.List; + +public record DropshipPalletSnapshot(String sourcePalletNo, long packageCount, + List skus) { + public record Sku(String skuCode, String productName, long packageCount, long productQuantity) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java index 6d37fc43f..c8e1e029f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java @@ -7,6 +7,7 @@ import com.baiyue.lastleg.dropship.mapper.*; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot; import jakarta.annotation.Resource; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -109,6 +110,21 @@ public class DropshipOrderService { return profile; } + @Transactional(readOnly = true) + public List palletSnapshots(String customerId, Long orderId) { + List orderProfiles = profiles.listOrder(customerId, orderId); + Map> skusByProfile = skuInventory.listOrder(customerId, orderId).stream() + .collect(Collectors.groupingBy(DropshipSkuInventory::getProfileId)); + return orderProfiles.stream().map(profile -> { + List skus = skusByProfile.getOrDefault(profile.getId(), List.of()).stream() + .map(sku -> new DropshipPalletSnapshot.Sku(sku.getSkuCode(), sku.getProductName(), + value(sku.getPackageCount()), value(sku.getProductQuantity()))) + .toList(); + return new DropshipPalletSnapshot(profile.getSourcePalletNo(), + skus.stream().mapToLong(DropshipPalletSnapshot.Sku::packageCount).sum(), skus); + }).toList(); + } + @Transactional public void activateAfterStockIn(String customerId, Long orderId, Long taskId, List receipts) { @@ -118,20 +134,18 @@ public class DropshipOrderService { if (profilesForOrder.stream().anyMatch(profile -> "ACTIVE".equals(profile.getStatus()))) { throw conflict("Dropship pallet activation is incomplete; repair profiles before retrying"); } - Map> receiptsByPallet = receipts.stream() - .filter(receipt -> receipt.getWarehousePalletId() != null) - .collect(Collectors.groupingBy(InboundStockReceipt::getWarehousePalletId)); + Map> receiptsByPallet = receipts.stream() + .filter(receipt -> receipt.getPalletNo() != null) + .collect(Collectors.groupingBy(InboundStockReceipt::getPalletNo)); if (receiptsByPallet.size() != profilesForOrder.size()) { throw conflict("PDA pallet count does not match dropship source pallet count"); } - List orderedProfiles = profilesForOrder.stream() - .sorted(java.util.Comparator.comparing(DropshipOrderProfile::getSourcePalletNo)).toList(); - List> orderedReceipts = receiptsByPallet.values().stream() - .sorted(java.util.Comparator.comparing(value -> value.get(0).getWarehousePalletId())).toList(); LocalDateTime now = LocalDateTime.now(); - for (int index = 0; index < orderedProfiles.size(); index++) { - DropshipOrderProfile profile = orderedProfiles.get(index); - List actual = orderedReceipts.get(index); + for (DropshipOrderProfile profile : profilesForOrder) { + List actual = receiptsByPallet.get(profile.getSourcePalletNo()); + if (actual == null || actual.isEmpty()) { + throw conflict("PDA receipt is missing for dropship source pallet " + profile.getSourcePalletNo()); + } List profileCartons = cartons.listProfile(customerId, profile.getId()); long expected = profileCartons.size(); long received = actual.stream().mapToLong(value -> value.getReceivedPackages() == null ? 0 : value.getReceivedPackages()).sum(); 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 81dd62966..da48dafaf 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 @@ -65,6 +65,16 @@ public class InboundPdaController { return ApiResponse.success(service.pick(scope(user, customerId), planNo, allocationId, request)); } + @PostMapping("/allocations/{allocationId}/pick-whole-pallet") + public ApiResponse pickWholePallet( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo, + @PathVariable long allocationId, @RequestParam(required = false) String customerId, + @Valid @RequestBody PdaRequests.WholePalletOperation request) { + requireOperate(user); + return ApiResponse.success(service.pickWholePallet( + scope(user, customerId), planNo, allocationId, request)); + } + @PostMapping("/cartons/scan") public ApiResponse scanCarton(@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 03d8a246c..d05ea8f4a 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 @@ -17,6 +17,9 @@ public final class PdaRequests { public record PackageOperation(@Positive int packageCount, @NotBlank String idempotencyKey, String operatorId, String operatorName) {} + public record WholePalletOperation(@NotBlank String idempotencyKey, + String operatorId, String operatorName) {} + public record ConfirmMerge(@Positive long sourceAllocationId, @Positive int packageCount, @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 fd8a92754..20fb0f557 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 @@ -11,7 +11,18 @@ public final class PdaResponses { LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt, List unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt, LocalDateTime estimatedEmptyReturnAt, int expectedPackageCount, - int pickedPackageCount) { + int pickedPackageCount, String orderType, boolean wholePalletInbound, + List dropshipPallets) { + public Task(long taskId, String taskNo, String planNo, String status, + LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt, + List unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt, + LocalDateTime estimatedEmptyReturnAt, int expectedPackageCount, + int pickedPackageCount) { + this(taskId, taskNo, planNo, status, pushedAt, startedAt, completedAt, + unloadPhotoFileIds, unloadPhotoUploadedAt, estimatedEmptyReturnAt, + expectedPackageCount, pickedPackageCount, null, false, List.of()); + } + public Task(long taskId, String taskNo, String planNo, String status, LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt, List unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt, @@ -21,6 +32,10 @@ public final class PdaResponses { } } + public record DropshipPallet(String sourcePalletNo, long planPalletId, long allocationId, + long packageCount, List skus) {} + public record DropshipSku(String skuCode, String productName, long packageCount, long productQuantity) {} + public record UnloadPhotoFile(long fileId, String fileName, String contentType, long sizeBytes) {} public record Operation(String result, long allocationId, int requiredPackageCount, 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 6d8dd4cbe..726cf9054 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 @@ -17,6 +17,8 @@ import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; import com.baiyue.lastleg.inboundplan.pda.mapper.InboundPalletMergeMapper; import com.baiyue.lastleg.inboundplan.pda.mapper.InboundPdaOperationMapper; import com.baiyue.lastleg.inboundplan.pda.mapper.InboundPdaTaskMapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; import org.springframework.stereotype.Repository; import jakarta.annotation.Resource; @@ -32,6 +34,7 @@ public class PdaWorkflowRepository { @Resource private InboundPdaOperationMapper operations; @Resource private InboundPalletMergeMapper merges; @Resource private InboundPlanCartonMapper cartons; + @Resource private LastlegOrderMapper orders; public InboundPlan lockPlan(String customerId, String planNo) { return plans.selectOne(new LambdaQueryWrapper() @@ -92,6 +95,8 @@ public class PdaWorkflowRepository { .eq(InboundPlanPalletAllocation::getPlanId, planId)); } + public LastlegOrder order(Long orderId) { return orderId == null ? null : orders.selectById(orderId); } + public void insert(InboundPdaTask value) { tasks.insert(value); } public void insert(InboundPdaOperation value) { operations.insert(value); } public void insert(InboundPalletMerge value) { merges.insert(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 96a4dd50f..228017b3a 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 @@ -14,6 +14,9 @@ import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway; import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository; +import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot; +import com.baiyue.lastleg.dropship.service.DropshipOrderService; +import com.baiyue.lastleg.entity.LastlegOrder; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.ObjectMapper; @@ -50,6 +53,7 @@ public class InboundPdaWorkflowService { @Resource private InboundStockReceiptService stockReceipts; @Resource private InboundUnloadCompletedOutboxService unloadCompletedOutbox; @Resource private UnloadPhotoFileGateway unloadPhotoFiles; + @Resource private DropshipOrderService dropshipOrders; @Resource private ObjectMapper sourceObjectMapper; private ObjectMapper objectMapper; @@ -209,6 +213,39 @@ public class InboundPdaWorkflowService { return result; } + @Transactional + public PdaResponses.Operation pickWholePallet(String customerId, String planNo, long allocationId, + PdaRequests.WholePalletOperation request) { + InboundPlan plan = requirePlan(customerId, planNo); + requireDropship(plan); + InboundPdaOperation replay = replay(customerId, "PICK_WHOLE_PALLET", request.idempotencyKey(), request); + if (replay != null) return read(replay, PdaResponses.Operation.class); + InboundPdaTask task = requireActiveTask(customerId, plan); + InboundPlanPalletAllocation allocation = requireAllocation(plan.getId(), allocationId); + InboundPlanLine line = requireLine(plan.getId(), allocation.getLineId()); + int planned = value(allocation.getPlannedPackageCount()); + if (planned <= 0) throw conflict("One-click dropship pallet has no planned packages"); + if (value(allocation.getPickedPackageCount()) != 0) { + throw conflict("One-click dropship pallet has already been partially operated"); + } + allocation.setCountedPackageCount(planned); + allocation.setPickedPackageCount(planned); + allocation.setActualVolumeCbm(volume(allocation.getAllocatedVolumeCbm())); + allocation.setStatus("PICKED"); + bump(allocation); + repository.update(allocation); + InboundPlanPallet pallet = requirePallet(plan.getId(), allocation.getPalletId()); + pallet.setActualPackageCount(planned); + pallet.setActualVolumeCbm(volume(allocation.getAllocatedVolumeCbm())); + repository.update(pallet); + startPicking(task, plan, request.operatorId(), request.operatorName()); + PdaResponses.Operation result = operation("WHOLE_PALLET_PICKED", allocation, line, + List.of(), 0, planned, 0); + saveOperation(customerId, task.getId(), allocationId, "PICK_WHOLE_PALLET", + request.idempotencyKey(), request, result); + return result; + } + /** 海外首次扫描箱唛:仅接受本柜国内冻结且已归属本计划的箱。 */ @Transactional public PdaResponses.Carton scanCarton(String customerId, String planNo, PdaRequests.ScanCarton request) { @@ -423,7 +460,8 @@ public class InboundPdaWorkflowService { } } List cartons = repository.cartons(plan.getId()); - if (!cartons.isEmpty() && cartons.stream().anyMatch(carton -> !"ON_PALLET".equals(carton.getStatus()))) { + if (!isDropship(plan) && !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(); @@ -639,6 +677,9 @@ public class InboundPdaWorkflowService { } private void requireLegacyCounting(InboundPlan plan) { + if (isDropship(plan)) { + throw conflict("One-click dropship inbound must be picked by complete source pallet"); + } if (repository.hasCartonLabelPool(plan.getId())) { throw conflict("This inbound plan has a carton manifest and must be operated by carton scanning"); } @@ -708,20 +749,56 @@ public class InboundPdaWorkflowService { volume(allocation.getActualVolumeCbm()), remaining, requested, merge, candidates); } - 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) { + List allocations = repository.allocations(plan.getId()); + int picked = allocations.stream().mapToInt(allocation -> value(allocation.getPickedPackageCount())).sum(); + LastlegOrder order = repository.order(plan.getSourceOrderId()); + String orderType = order == null ? null : order.getOrderType(); + boolean wholePalletInbound = "ONE_CLICK_DROPSHIP".equals(orderType); + Map palletsByNo = wholePalletInbound + ? repository.pallets(plan.getId()).stream().collect(java.util.stream.Collectors.toMap( + InboundPlanPallet::getPalletNo, value -> value)) : Map.of(); + Map allocationsByPallet = wholePalletInbound + ? allocations.stream().collect(java.util.stream.Collectors.toMap( + InboundPlanPalletAllocation::getPalletId, value -> value)) : Map.of(); + List dropshipPallets = wholePalletInbound + ? dropshipOrders.palletSnapshots(task.getCustomerId(), order.getId()).stream() + .map(pallet -> dropshipPallet(pallet, palletsByNo, allocationsByPallet)).toList() : List.of(); return new PdaResponses.Task(task.getId(), task.getTaskNo(), planNo, task.getStatus(), task.getPushedAt(), task.getStartedAt(), task.getCompletedAt(), unloadPhotoFileIds(task), - task.getUnloadPhotoUploadedAt(), task.getEstimatedEmptyReturnAt(), expected, picked); + task.getUnloadPhotoUploadedAt(), task.getEstimatedEmptyReturnAt(), expected, picked, + orderType, wholePalletInbound, dropshipPallets); + } + + private PdaResponses.Task task(InboundPdaTask task, String planNo) { + return new PdaResponses.Task(task.getId(), task.getTaskNo(), planNo, task.getStatus(), + task.getPushedAt(), task.getStartedAt(), task.getCompletedAt(), unloadPhotoFileIds(task), + task.getUnloadPhotoUploadedAt(), task.getEstimatedEmptyReturnAt()); + } + + private PdaResponses.DropshipPallet dropshipPallet(DropshipPalletSnapshot pallet, + Map palletsByNo, + Map allocationsByPallet) { + InboundPlanPallet planPallet = palletsByNo.get(pallet.sourcePalletNo()); + if (planPallet == null) throw conflict("Dropship source pallet is missing from the inbound plan"); + InboundPlanPalletAllocation allocation = allocationsByPallet.get(planPallet.getId()); + if (allocation == null) throw conflict("Dropship source pallet has no inbound allocation"); + return new PdaResponses.DropshipPallet(pallet.sourcePalletNo(), planPallet.getId(), allocation.getId(), + pallet.packageCount(), pallet.skus().stream() + .map(sku -> new PdaResponses.DropshipSku(sku.skuCode(), sku.productName(), + sku.packageCount(), sku.productQuantity())).toList()); + } + + private void requireDropship(InboundPlan plan) { + if (!isDropship(plan)) { + throw conflict("Whole-pallet picking is only available for one-click dropship inbound plans"); + } + } + + private boolean isDropship(InboundPlan plan) { + LastlegOrder order = repository.order(plan.getSourceOrderId()); + return order != null && "ONE_CLICK_DROPSHIP".equals(order.getOrderType()); } private List unloadPhotoFileIds(InboundPdaTask task) { 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 c428261aa..9f826b8f2 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 @@ -18,6 +18,7 @@ import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import com.baiyue.lastleg.dropship.service.DropshipOrderService; +import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot; import jakarta.annotation.Resource; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -93,11 +94,24 @@ public class InboundStockReceiptService { } List skuRemainders = "ONE_CLICK_DROPSHIP".equals(order.getOrderType()) ? List.of() : skuRemainders(order.getId(), value(line.getRequiredPackageCount())); + Map dropshipPallets = "ONE_CLICK_DROPSHIP".equals(order.getOrderType()) + ? dropshipOrders.palletSnapshots(customerId, order.getId()).stream() + .collect(Collectors.toMap(DropshipPalletSnapshot::sourcePalletNo, value -> value)) + : Map.of(); for (InboundPlanPalletAllocation allocation : lineAllocations) { InboundPlanPallet pallet = requirePallet(palletById, allocation); if ("ONE_CLICK_DROPSHIP".equals(order.getOrderType())) { - created.add(persist(customerId, plan, task, line, allocation, pallet, order, - SkuSnapshot.unspecified(value(allocation.getPickedPackageCount())), receivedAt)); + if (value(allocation.getPickedPackageCount()) != value(allocation.getPlannedPackageCount())) { + throw conflict("One-click dropship inbound must receive a complete source pallet"); + } + DropshipPalletSnapshot sourcePallet = dropshipPallets.get(pallet.getPalletNo()); + if (sourcePallet == null || sourcePallet.packageCount() != value(allocation.getPickedPackageCount())) { + throw conflict("One-click dropship source pallet SKU package total does not match PDA receipt"); + } + for (DropshipPalletSnapshot.Sku sku : sourcePallet.skus()) { + created.add(persist(customerId, plan, task, line, allocation, pallet, order, + new SkuSnapshot(sku.skuCode(), sku.productName(), null, sku.packageCount()), receivedAt)); + } } else { long allocationPackages = value(allocation.getPickedPackageCount()); for (SkuSnapshot sku : consume(skuRemainders, allocationPackages)) { @@ -279,9 +293,5 @@ public class InboundStockReceiptService { } } - private record SkuSnapshot(String skuCode, String productNameZh, String productNameEn, long packages) { - private static SkuSnapshot unspecified(long packages) { - return new SkuSnapshot("UNSPECIFIED", null, null, packages); - } - } + private record SkuSnapshot(String skuCode, String productNameZh, String productNameEn, long packages) {} } 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 ba081a5c7..27b745e6b 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 @@ -30,6 +30,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.HashSet; import java.util.stream.Collectors; @Service @@ -62,6 +63,32 @@ public class InboundPlanService { @Transactional public InboundPlanResponse convert(String customerId, Long selectedOrderId, List sourcePalletNos) { + return convert(customerId, selectedOrderId, sourcePalletNos, null); + } + + @Transactional + public InboundPlanResponse convertDropship(String customerId, Long selectedOrderId, + List sourcePallets) { + if (sourcePallets == null || sourcePallets.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "One-click dropship inbound requires source pallets"); + } + HashSet palletNos = new HashSet<>(); + for (WholePalletInput pallet : sourcePallets) { + if (pallet == null || trimToNull(pallet.palletNo()) == null || pallet.packageCount() <= 0) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "One-click dropship source pallet number and package count are required"); + } + if (!palletNos.add(pallet.palletNo().trim())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Duplicate one-click dropship source pallet number: " + pallet.palletNo()); + } + } + return convert(customerId, selectedOrderId, List.of(), List.copyOf(sourcePallets)); + } + + private InboundPlanResponse convert(String customerId, Long selectedOrderId, List sourcePalletNos, + List wholePallets) { List orders = orderMapper.lockOrder(customerId, selectedOrderId); if (orders == null || orders.isEmpty()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound order not found"); @@ -91,21 +118,27 @@ public class InboundPlanService { Map orderById = orders.stream() .collect(Collectors.toMap(SourceOrderSnapshot::orderId, value -> value)); Map lineByOrder = createLines(plan, orders); - PalletAllocator.Result allocation = PalletAllocator.allocate(orders.stream() + PalletAllocator.Result allocation = wholePallets == null ? PalletAllocator.allocate(orders.stream() .map(order -> new PalletAllocator.LineInput(order.orderId(), warehouseKey(order), volume(order), - order.packageCount(), order.recommendedPalletCount())) - .toList()); - plan.setPalletCount(allocation.pallets().size()); + order.packageCount(), order.recommendedPalletCount())).toList()) : null; + if (wholePallets != null) validateWholePallets(orders, wholePallets); + plan.setPalletCount(wholePallets == null ? allocation.pallets().size() : wholePallets.size()); planMapper.insert(plan); lineByOrder.values().forEach(lineMapper::insert); - Map persistedPallets = createPallets(plan, allocation, orderById, - sourcePalletNos == null ? List.of() : sourcePalletNos); - createAllocations(plan, allocation, lineByOrder, persistedPallets); - - Map palletCounts = allocation.allocations().stream().collect(Collectors.groupingBy( - PalletAllocator.AllocationDraft::orderId, - Collectors.collectingAndThen(Collectors.mapping(item -> item.pallet().sequenceNo(), Collectors.toSet()), java.util.Set::size))); + Map palletCounts; + if (wholePallets == null) { + Map persistedPallets = createPallets(plan, allocation, + orderById, sourcePalletNos == null ? List.of() : sourcePalletNos); + createAllocations(plan, allocation, lineByOrder, persistedPallets); + palletCounts = allocation.allocations().stream().collect(Collectors.groupingBy( + PalletAllocator.AllocationDraft::orderId, Collectors.collectingAndThen(Collectors.mapping( + item -> item.pallet().sequenceNo(), Collectors.toSet()), java.util.Set::size))); + } else { + SourceOrderSnapshot order = orders.get(0); + createWholePallets(plan, order, lineByOrder.get(order.orderId()), wholePallets); + palletCounts = Map.of(order.orderId(), wholePallets.size()); + } for (SourceOrderSnapshot order : orders) { int count = palletCounts.getOrDefault(order.orderId(), 0); InboundPlanLine line = lineByOrder.get(order.orderId()); @@ -120,6 +153,54 @@ public class InboundPlanService { return detail(customerId, plan); } + private void validateWholePallets(List orders, List pallets) { + if (orders.size() != 1) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "One-click dropship inbound plan must belong to one inbound order"); + } + int packages = pallets.stream().mapToInt(WholePalletInput::packageCount).sum(); + if (packages != orders.get(0).packageCount()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "One-click dropship pallet package total must equal inbound order package count"); + } + } + + private void createWholePallets(InboundPlan plan, SourceOrderSnapshot order, InboundPlanLine line, + List sourcePallets) { + BigDecimal remainingVolume = volume(order); + int remainingPackages = order.packageCount(); + for (int index = 0; index < sourcePallets.size(); index++) { + WholePalletInput source = sourcePallets.get(index); + BigDecimal palletVolume = index == sourcePallets.size() - 1 ? remainingVolume + : volume(order).multiply(BigDecimal.valueOf(source.packageCount())) + .divide(BigDecimal.valueOf(order.packageCount()), 4, java.math.RoundingMode.HALF_UP); + if (palletVolume.compareTo(remainingVolume) > 0) palletVolume = remainingVolume; + InboundPlanPallet pallet = new InboundPlanPallet(); + pallet.setId(IdWorker.getId()); pallet.setCustomerId(plan.getCustomerId()); pallet.setPlanId(plan.getId()); + pallet.setPalletNo(source.palletNo().trim()); pallet.setDestinationWarehouseId(order.destinationWarehouseId()); + pallet.setDestinationWarehouseCode(order.destinationWarehouseCode()); + pallet.setDestinationWarehouseName(order.destinationWarehouseName()); + pallet.setCapacityCbm(PalletAllocator.PALLET_CAPACITY_CBM); pallet.setAllocatedVolumeCbm(palletVolume); + pallet.setSequenceNo(index + 1); pallet.setAssignmentStatus("UNASSIGNED"); + pallet.setActualVolumeCbm(BigDecimal.ZERO); pallet.setActualPackageCount(0); palletMapper.insert(pallet); + + InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation(); + allocation.setId(IdWorker.getId()); allocation.setCustomerId(plan.getCustomerId()); allocation.setPlanId(plan.getId()); + allocation.setLineId(line.getId()); allocation.setPalletId(pallet.getId()); + allocation.setAllocatedVolumeCbm(palletVolume); allocation.setPlannedPackageCount(source.packageCount()); + allocation.setCountedPackageCount(0); allocation.setPickedPackageCount(0); + allocation.setActualVolumeCbm(BigDecimal.ZERO); allocation.setStatus("PENDING_COUNT"); allocation.setVersion(0); + allocationMapper.insert(allocation); + remainingVolume = remainingVolume.subtract(palletVolume); + remainingPackages -= source.packageCount(); + } + if (remainingPackages != 0 || remainingVolume.signum() != 0) { + throw new IllegalStateException("Failed to conserve one-click dropship pallet totals"); + } + } + + public record WholePalletInput(String palletNo, int packageCount) {} + @Transactional(readOnly = true) public InboundPlanResponse detailByPlanNo(String customerId, String planNo) { 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 0dec5a0b3..febdbe99d 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 @@ -207,6 +207,11 @@ public class LoadingCompletedConsumer { catch (Exception exception) { throw new IllegalArgumentException("One-click dropship warehouseId is invalid", exception); } dropshipOrders.receive(new DropshipRequests.Receive(customerId, order.getId(), warehouseId, event.eventId() + ":DROPSHIP", dropshipPallets)); + InboundPlanResponse plan = inboundPlans.convertDropship(customerId, order.getId(), dropshipPallets.stream() + .map(pallet -> new InboundPlanService.WholePalletInput(pallet.palletNo(), pallet.skuPackages().stream() + .mapToInt(sku -> Math.toIntExact(sku.packageCount())).sum())) + .toList()); + registerInTransit(event, payload, source, order, plan.planNo()); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/service/DropshipOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/service/DropshipOrderServiceTest.java index 85564aa1f..9c37e00a0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/service/DropshipOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/service/DropshipOrderServiceTest.java @@ -97,7 +97,7 @@ class DropshipOrderServiceTest { when(cartons.listProfile("C-1", 2L)).thenReturn(List.of(secondCarton)); service.activateAfterStockIn("C-1", 10L, 50L, List.of( - receipt(101L, "WH-P-1", 201L, 1), receipt(102L, "WH-P-2", 202L, 1))); + receipt(101L, "SOURCE-P-1", 201L, 1), receipt(102L, "SOURCE-P-2", 202L, 1))); assertThat(first.getStatus()).isEqualTo("ACTIVE"); assertThat(first.getWarehousePalletId()).isEqualTo(101L); 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 d009229fe..210cf2dba 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 @@ -12,6 +12,9 @@ import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway; import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository; +import com.baiyue.lastleg.dropship.service.DropshipOrderService; +import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot; +import com.baiyue.lastleg.entity.LastlegOrder; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -48,6 +51,7 @@ class InboundPdaWorkflowServiceTest { @Mock InboundStockReceiptService stockReceipts; @Mock InboundUnloadCompletedOutboxService unloadCompletedOutbox; @Mock UnloadPhotoFileGateway unloadPhotoFiles; + @Mock DropshipOrderService dropshipOrders; private InboundPdaWorkflowService service; private InboundPlan plan; @@ -69,6 +73,7 @@ class InboundPdaWorkflowServiceTest { ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts); ReflectionTestUtils.setField(service, "unloadCompletedOutbox", unloadCompletedOutbox); ReflectionTestUtils.setField(service, "unloadPhotoFiles", unloadPhotoFiles); + ReflectionTestUtils.setField(service, "dropshipOrders", dropshipOrders); ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules()); service.initializeObjectMapper(); plan = plan(); @@ -492,6 +497,53 @@ class InboundPdaWorkflowServiceTest { verify(unloadCompletedOutbox, never()).publish(any(), any(), any()); } + @Test + void dropshipTaskIsMarkedAndWholePalletPickReceivesThePlannedPalletAtOnce() { + plan.setSourceOrderId(101L); + LastlegOrder order = new LastlegOrder(); + order.setId(101L); order.setOrderType("ONE_CLICK_DROPSHIP"); + when(repository.order(101L)).thenReturn(order); + when(dropshipOrders.palletSnapshots("100", 101L)).thenReturn(List.of( + new DropshipPalletSnapshot("PLT-21", 3, + List.of(new DropshipPalletSnapshot.Sku("SKU-A", "Product A", 3, 30))))); + 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.task("100", plan.getId())).thenReturn(task); + activeTask(); + when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target); + when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine); + when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet); + + var taskResult = service.task("100", "LIP1"); + var pickResult = service.pickWholePallet("100", "LIP1", target.getId(), + new PdaRequests.WholePalletOperation("whole-1", "7", "worker")); + + assertEquals("ONE_CLICK_DROPSHIP", taskResult.orderType()); + assertTrue(taskResult.wholePalletInbound()); + assertEquals("SKU-A", taskResult.dropshipPallets().get(0).skus().get(0).skuCode()); + assertEquals(targetPallet.getId(), taskResult.dropshipPallets().get(0).planPalletId()); + assertEquals(target.getId(), taskResult.dropshipPallets().get(0).allocationId()); + assertEquals("WHOLE_PALLET_PICKED", pickResult.result()); + assertEquals(3, target.getPickedPackageCount()); + assertEquals(3, targetPallet.getActualPackageCount()); + } + + @Test + void dropshipPlanRejectsOrdinaryPackagePicking() { + plan.setSourceOrderId(101L); + LastlegOrder order = new LastlegOrder(); + order.setId(101L); order.setOrderType("ONE_CLICK_DROPSHIP"); + when(repository.order(101L)).thenReturn(order); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.pick("100", "LIP1", target.getId(), + new PdaRequests.PackageOperation(1, "wrong-mode", "7", "worker"))); + + assertEquals(409, exception.getStatusCode().value()); + verify(repository, never()).lockAllocation(plan.getId(), target.getId()); + } + @Test void rejectsReuseOfIdempotencyKeyWithDifferentActionOrPayload() { InboundPdaOperation existing = new InboundPdaOperation(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java index 75ba697ee..e46a8ed57 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java @@ -17,6 +17,7 @@ import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import com.baiyue.lastleg.dropship.service.DropshipOrderService; +import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -166,6 +167,7 @@ class InboundStockReceiptServiceTest { void activatesDropshipAfterGenericStockInReceiptIsCreated() { Fixture fixture = fixture(5, 5, 5, 10); fixture.order.setOrderType("ONE_CLICK_DROPSHIP"); + when(dropshipOrders.palletSnapshots("100", 101L)).thenReturn(List.of(dropshipPallet("PLT-21", 5))); when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order)); when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class))) .thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 5, 0, 0, 0, false, false)); @@ -183,6 +185,7 @@ class InboundStockReceiptServiceTest { void partialStockInSynchronizesOrderWithoutActivatingDropship() { Fixture fixture = fixture(5, 2, 2, 10); fixture.order.setOrderType("ONE_CLICK_DROPSHIP"); + when(dropshipOrders.palletSnapshots("100", 101L)).thenReturn(List.of(dropshipPallet("PLT-21", 2))); when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order)); when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class))) .thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 2, 0, 0, 0, false, false)); @@ -197,6 +200,26 @@ class InboundStockReceiptServiceTest { verify(dropshipOrders, never()).activateAfterStockIn(any(), any(), any(), any()); } + @Test + void rejectsPartiallyPickedDropshipSourcePallet() { + Fixture fixture = fixture(5, 5, 2, 10); + fixture.order.setOrderType("ONE_CLICK_DROPSHIP"); + when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order)); + when(dropshipOrders.palletSnapshots("100", 101L)).thenReturn(List.of(dropshipPallet("PLT-21", 5))); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line), + List.of(fixture.allocation), List.of(fixture.pallet), LocalDateTime.now(), true)); + + assertEquals(409, exception.getStatusCode().value()); + verify(inventory, never()).stockIn(any(), any(), any()); + } + + private DropshipPalletSnapshot dropshipPallet(String palletNo, long packages) { + return new DropshipPalletSnapshot(palletNo, packages, + List.of(new DropshipPalletSnapshot.Sku("SKU-1", "Product", packages, packages * 10))); + } + private Fixture fixture(int required, int planned, int picked, int quantity) { InboundPlan plan = new InboundPlan(); plan.setId(1L); plan.setPlanNo("IP-1"); InboundPdaTask task = new InboundPdaTask(); task.setId(2L); task.setOperatorId("operator"); 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 7dd827a96..9422763b1 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 @@ -146,6 +146,27 @@ class InboundPlanServiceTest { verify(planMapper, never()).insert(any(InboundPlan.class)); } + @Test + void convertsDropshipSourcePalletsWithoutRedistributingTheirPackages() { + 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); + + service.convertDropship("100", 2L, List.of( + new InboundPlanService.WholePalletInput("SOURCE-P-1", 3), + new InboundPlanService.WholePalletInput("SOURCE-P-2", 7))); + + ArgumentCaptor pallets = ArgumentCaptor.forClass(InboundPlanPallet.class); + verify(palletMapper, times(2)).insert(pallets.capture()); + assertEquals(List.of("SOURCE-P-1", "SOURCE-P-2"), + pallets.getAllValues().stream().map(InboundPlanPallet::getPalletNo).toList()); + ArgumentCaptor allocations = + ArgumentCaptor.forClass(InboundPlanPalletAllocation.class); + verify(allocationMapper, times(2)).insert(allocations.capture()); + assertEquals(List.of(3, 7), allocations.getAllValues().stream() + .map(InboundPlanPalletAllocation::getPlannedPackageCount).toList()); + } + private SourceOrderSnapshot order(Long id, String inboundCode, String volume, String warehouse) { return new SourceOrderSnapshot(id, "100", "CONT-1", inboundCode, "PENDING_ASSIGNMENT", null, new BigDecimal(volume), new BigDecimal(volume).multiply(BigDecimal.TEN).intValue(), 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 34dae31ba..0c075c064 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 @@ -3,12 +3,12 @@ import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com @ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class) class LoadingCompletedConsumerTest { @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InboundPlanService inboundPlans; @Mock InventoryCommandService inventory; 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,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inboundPlans",inboundPlans); ReflectionTestUtils.setField(service,"inventory",inventory); 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)); lenient().when(inboundPlans.convert(anyString(),anyLong())).thenReturn(new InboundPlanResponse(1L,"LIP-1","100",1L,"MSCU1","PENDING_ASSIGNMENT",1,1,BigDecimal.ONE,List.of(),List.of(),List.of())); } + @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inboundPlans",inboundPlans); ReflectionTestUtils.setField(service,"inventory",inventory); 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)); var plan=new InboundPlanResponse(1L,"LIP-1","100",1L,"MSCU1","PENDING_ASSIGNMENT",1,1,BigDecimal.ONE,List.of(),List.of(),List.of()); lenient().when(inboundPlans.convert(anyString(),anyLong())).thenReturn(plan); lenient().when(inboundPlans.convertDropship(anyString(),anyLong(),anyList())).thenReturn(plan); } @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(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),any(InventoryCommandRequests.InTransit.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()); } @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 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()); ArgumentCaptor> plans=ArgumentCaptor.forClass(List.class); verify(inboundPlans).convertDropship(eq("100"),anyLong(),plans.capture()); assertEquals(List.of(new InboundPlanService.WholePalletInput("PLT-1",2)),plans.getValue()); verify(inventory).registerInTransit(eq("100"),eq("SYSTEM"),any()); } @Test void oneDropshipInboundWithMultipleSkusCreatesOneOrderAndMultipleSkuDetails(){ var itemA=new FirstLegLoadingPlanCompletedV1.Item("d-a","SKU-A","产品A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-A","MARK-A",null,List.of("BOX-A1","BOX-A2")); var itemB=new FirstLegLoadingPlanCompletedV1.Item("d-b","SKU-B","产品B","Product B",3,15,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-B","MARK-B",null,List.of("BOX-B1","BOX-B2","BOX-B3")); From 5c331f173d42a421e07bbc175f29065de13e3f32 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Mon, 24 Aug 2026 17:58:47 +0800 Subject: [PATCH 04/24] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../inboundplan/pda/dto/PdaResponses.java | 4 +- .../service/InboundPdaWorkflowService.java | 52 +++++++++++++------ .../InboundPdaWorkflowServiceTest.java | 28 +++++----- 3 files changed, 51 insertions(+), 33 deletions(-) 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 20fb0f557..268374c33 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 @@ -12,7 +12,7 @@ public final class PdaResponses { List unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt, LocalDateTime estimatedEmptyReturnAt, int expectedPackageCount, int pickedPackageCount, String orderType, boolean wholePalletInbound, - List dropshipPallets) { + List dropshipPallets, String inboundMode) { public Task(long taskId, String taskNo, String planNo, String status, LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt, List unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt, @@ -20,7 +20,7 @@ public final class PdaResponses { int pickedPackageCount) { this(taskId, taskNo, planNo, status, pushedAt, startedAt, completedAt, unloadPhotoFileIds, unloadPhotoUploadedAt, estimatedEmptyReturnAt, - expectedPackageCount, pickedPackageCount, null, false, List.of()); + expectedPackageCount, pickedPackageCount, null, false, List.of(), null); } public Task(long taskId, String taskNo, String planNo, String status, 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 228017b3a..92d3be40d 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 @@ -146,7 +146,7 @@ public class InboundPdaWorkflowService { public PdaResponses.Operation count(String customerId, String planNo, long allocationId, PdaRequests.PackageOperation request) { InboundPlan plan = requirePlan(customerId, planNo); - requireLegacyCounting(plan); + requireCountMode(plan); InboundPdaOperation replay = replay(customerId, "COUNT", request.idempotencyKey(), request); if (replay != null) return read(replay, PdaResponses.Operation.class); InboundPdaTask task = requireActiveTask(customerId, plan); @@ -174,7 +174,7 @@ public class InboundPdaWorkflowService { public PdaResponses.Operation pick(String customerId, String planNo, long allocationId, PdaRequests.PackageOperation request) { InboundPlan plan = requirePlan(customerId, planNo); - requireLegacyCounting(plan); + requireCountMode(plan); InboundPdaOperation replay = replay(customerId, "PICK", request.idempotencyKey(), request); if (replay != null) return read(replay, PdaResponses.Operation.class); InboundPdaTask task = requireActiveTask(customerId, plan); @@ -250,6 +250,7 @@ public class InboundPdaWorkflowService { @Transactional public PdaResponses.Carton scanCarton(String customerId, String planNo, PdaRequests.ScanCarton request) { InboundPlan plan = requirePlan(customerId, planNo); + requireCartonScanMode(plan); InboundPdaOperation replay = replay(customerId, "SCAN_CARTON", request.idempotencyKey(), request); if (replay != null) return read(replay, PdaResponses.Carton.class); InboundPdaTask task = requireActiveTask(customerId, plan); @@ -271,22 +272,12 @@ public class InboundPdaWorkflowService { : requireLine(plan.getId(), carton.getLineId()); if (line == null) throw notFound("Carton mark does not belong to cargo in this inbound plan"); int requestedCount = request.packageCount() == null ? 1 : request.packageCount(); - if (requestedCount < 1) throw conflict("Package count must be positive"); + if (requestedCount != 1) throw conflict("Overseas warehouse carton scanning accepts exactly one carton per scan"); 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"); - } + List cartonsToScan = List.of(carton); InboundPlanPalletAllocation allocation = repository.lockAllocation(plan.getId(), line.getId(), pallet.getId()); if (allocation == null) { allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId()); allocation.setPlanId(plan.getId()); @@ -323,6 +314,7 @@ public class InboundPdaWorkflowService { @Transactional public PdaResponses.Carton removeCarton(String customerId, String planNo, PdaRequests.RemoveCarton request) { InboundPlan plan = requirePlan(customerId, planNo); + requireCartonScanMode(plan); InboundPdaOperation replay = replay(customerId, "REMOVE_CARTON", request.idempotencyKey(), request); if (replay != null) return read(replay, PdaResponses.Carton.class); InboundPdaTask task = requireActiveTask(customerId, plan); @@ -460,7 +452,7 @@ public class InboundPdaWorkflowService { } } List cartons = repository.cartons(plan.getId()); - if (!isDropship(plan) && !cartons.isEmpty() + if (mode(plan) == InboundMode.OWN_OVERSEAS && !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"); } @@ -676,6 +668,17 @@ public class InboundPdaWorkflowService { repository.update(plan); } + private void requireCountMode(InboundPlan plan) { + InboundMode mode = mode(plan); + if (mode == InboundMode.DROPSHIP) { + throw conflict("One-click dropship inbound must be picked by complete source pallet"); + } + if (mode == InboundMode.OWN_OVERSEAS) { + throw conflict("Overseas warehouse inbound must be operated by one-carton scanning"); + } + } + + /** Backward-compatible guard for pallet merge, which remains a legacy quantity workflow. */ private void requireLegacyCounting(InboundPlan plan) { if (isDropship(plan)) { throw conflict("One-click dropship inbound must be picked by complete source pallet"); @@ -685,6 +688,14 @@ public class InboundPdaWorkflowService { } } + private void requireCartonScanMode(InboundPlan plan) { + if (mode(plan) != InboundMode.OWN_OVERSEAS) { + throw conflict(mode(plan) == InboundMode.FBA + ? "FBA inbound must be operated by package quantity, not carton scanning" + : "One-click dropship inbound must be operated by complete source pallet"); + } + } + private boolean isNotAssigned(InboundPlanPallet pallet) { return pallet.getWarehousePalletId() == null || pallet.getWarehouseLocationId() == null || pallet.getWarehouseResourceLockId() == null @@ -768,7 +779,7 @@ public class InboundPdaWorkflowService { return new PdaResponses.Task(task.getId(), task.getTaskNo(), planNo, task.getStatus(), task.getPushedAt(), task.getStartedAt(), task.getCompletedAt(), unloadPhotoFileIds(task), task.getUnloadPhotoUploadedAt(), task.getEstimatedEmptyReturnAt(), expected, picked, - orderType, wholePalletInbound, dropshipPallets); + orderType, wholePalletInbound, dropshipPallets, mode(plan).name()); } private PdaResponses.Task task(InboundPdaTask task, String planNo) { @@ -801,6 +812,15 @@ public class InboundPdaWorkflowService { return order != null && "ONE_CLICK_DROPSHIP".equals(order.getOrderType()); } + private InboundMode mode(InboundPlan plan) { + if (isDropship(plan)) return InboundMode.DROPSHIP; + List lines = repository.lines(plan.getId()); + return lines.stream().anyMatch(line -> line.getFbaWarehouseCode() != null + && !line.getFbaWarehouseCode().isBlank()) ? InboundMode.FBA : InboundMode.OWN_OVERSEAS; + } + + private enum InboundMode { DROPSHIP, OWN_OVERSEAS, FBA } + private List unloadPhotoFileIds(InboundPdaTask task) { if (task.getUnloadPhotoFileIds() == null || task.getUnloadPhotoFileIds().isBlank()) return List.of(); try { 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 210cf2dba..023378307 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 @@ -167,27 +167,17 @@ class InboundPdaWorkflowServiceTest { } @Test - void cartonScanCanAutoSelectDestinationPalletAndBindAQuantity() { + void cartonScanRejectsBatchQuantityAndRequiresOneRealCartonPerScan() { 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()); + assertThrows(ResponseStatusException.class, () -> service.scanCarton("100", "LIP1", + new PdaRequests.ScanCarton("BOX-BATCH-1", null, 2, "scan-batch", "7", "worker"))); + assertEquals("UNASSIGNED", first.getStatus()); + assertEquals("UNASSIGNED", second.getStatus()); } @Test @@ -226,7 +216,9 @@ class InboundPdaWorkflowServiceTest { @Test void countMustPrecedePickAndPickedBoxesDriveActualVolume() { + targetLine.setFbaWarehouseCode("ONT8"); activeTask(); + when(repository.lines(plan.getId())).thenReturn(List.of(targetLine)); target.setCountedPackageCount(0); when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target); when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine); @@ -249,7 +241,9 @@ class InboundPdaWorkflowServiceTest { @Test void overPickReturnsConfirmationWithoutChangingAllocationOrPallet() { + targetLine.setFbaWarehouseCode("ONT8"); activeTask(); + when(repository.lines(plan.getId())).thenReturn(List.of(targetLine)); when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target); when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine); candidateQueries(); @@ -546,6 +540,8 @@ class InboundPdaWorkflowServiceTest { @Test void rejectsReuseOfIdempotencyKeyWithDifferentActionOrPayload() { + targetLine.setFbaWarehouseCode("ONT8"); + when(repository.lines(plan.getId())).thenReturn(List.of(targetLine)); InboundPdaOperation existing = new InboundPdaOperation(); existing.setAction("COUNT"); existing.setRequestHash("different"); @@ -557,7 +553,9 @@ class InboundPdaWorkflowServiceTest { @Test void completedTaskStillReplaysAnAlreadyPersistedPick() { + targetLine.setFbaWarehouseCode("ONT8"); activeTask(); + when(repository.lines(plan.getId())).thenReturn(List.of(targetLine)); when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target); when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine); when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet); From 3bf68c108315a00f3c3b03407adbe5cc4b205980 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Tue, 25 Aug 2026 19:58:08 +0800 Subject: [PATCH 05/24] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DeliveryTransferController.java | 15 ++- .../controller/OutboundPdaController.java | 43 ++++++++ .../delivery/dto/DeliveryRequests.java | 12 +++ .../delivery/dto/DeliveryResponses.java | 1 + .../delivery/entity/OutboundOrder.java | 2 + .../delivery/entity/OutboundPalletItem.java | 3 + .../delivery/entity/OutboundPdaPick.java | 20 ++++ .../delivery/entity/OutboundPdaTask.java | 21 ++++ .../delivery/mapper/OutboundOrderMapper.java | 2 + .../mapper/OutboundPdaPickMapper.java | 11 +++ .../mapper/OutboundPdaTaskMapper.java | 16 +++ .../service/DeliveryTransferService.java | 98 ++++++++++++++++++- .../dropship/mapper/DropshipCartonMapper.java | 2 + .../mapper/DropshipOrderProfileMapper.java | 2 + .../mapper/DropshipSkuInventoryMapper.java | 3 + .../src/main/resources/application.yml | 2 + .../src/main/resources/db/lastleg.sql | 33 ++++++- scripts/db/incremental.sql | 15 +++ 18 files changed, 295 insertions(+), 6 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/OutboundPdaController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPdaPick.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPdaTask.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPdaPickMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPdaTaskMapper.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java index b56b7d5e3..960f108f1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java @@ -32,11 +32,19 @@ public class DeliveryTransferController { return ApiResponse.success(service.transfer(scope(user, customerId), dispatchNo, operator(user), request)); } + @PostMapping("/outbound-orders/one-click-dropship") + @Operation(summary = "Create one-click dropship outbound and push a PDA picking task") + public ApiResponse createOneClick( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, + @Valid @RequestBody DeliveryRequests.OneClickOutbound request) { + return ApiResponse.success(service.createOneClick(scope(user, customerId), operator(user), request)); + } + @GetMapping("/outbound-orders") public ApiResponse> outboundPage( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid DeliveryRequests.Query query) { - return ApiResponse.success(service.outboundPage(scope(user, customerId), query)); + return ApiResponse.success(service.outboundPage(listScope(user, customerId), query)); } @GetMapping("/outbound-orders/{outboundNo}") @@ -73,4 +81,9 @@ public class DeliveryTransferController { private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); } + private String listScope(AuthenticatedUser user, String requestedCustomer) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) return requestedCustomer == null || requestedCustomer.isBlank() ? null : requestedCustomer.trim(); + return String.valueOf(user.tenantId()); + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/OutboundPdaController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/OutboundPdaController.java new file mode 100644 index 000000000..75918ebe5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/OutboundPdaController.java @@ -0,0 +1,43 @@ +package com.baiyue.lastleg.delivery.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.delivery.dto.DeliveryRequests; +import com.baiyue.lastleg.delivery.dto.DeliveryResponses; +import com.baiyue.lastleg.delivery.service.DeliveryTransferService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@ConditionalOnProperty(name="lastleg.future-outbound-api.enabled", havingValue="true") +@RequestMapping("/api/pda/v1/last-leg/outbound-tasks") +public class OutboundPdaController { + @Resource private DeliveryTransferService service; + @PostMapping("/{taskNo}/pick") + public ApiResponse pick(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String taskNo, @RequestParam(required=false) String customerId, + @Valid @RequestBody DeliveryRequests.PdaPick request) { + return ApiResponse.success(service.pdaPick(scope(user, customerId), operator(user), taskNo, request)); + } + @GetMapping + public ApiResponse> list(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required=false) String customerId, @RequestParam(required=false) String status) { + return ApiResponse.success(service.pdaTasks(scope(user, customerId), status)); + } + @PostMapping("/{taskNo}/complete") + public ApiResponse complete(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String taskNo, @RequestParam(required=false) String customerId) { + return ApiResponse.success(service.completePda(scope(user, customerId), taskNo)); + } + private String scope(AuthenticatedUser user, String requested) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if (requested != null && !requested.isBlank()) return requested.trim(); + return String.valueOf(user.tenantId()); + } + private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java index ac8083c6f..49cd60a3f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java @@ -3,7 +3,10 @@ package com.baiyue.lastleg.delivery.dto; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; +import jakarta.validation.Valid; +import java.util.List; public final class DeliveryRequests { private DeliveryRequests() {} @@ -20,4 +23,13 @@ public final class DeliveryRequests { String destinationWarehouseCode) { public PalletQuery { if (page == 0) page = 1; if (size == 0) size = 20; } } + + public record OneClickOutbound(@NotBlank @Size(max=1000) String recipient, + @NotNull @Size(min=1, max=200) List<@Valid OneClickItem> items, + @Size(max=1000) String remark, + @NotBlank @Size(max=255) String idempotencyKey) {} + public record OneClickItem(@NotNull Long inventoryPositionId, @NotNull @Min(1) Long packages, + @NotBlank @Size(max=128) String sku) {} + public record PdaPick(@NotNull Long outboundPalletItemId, @NotNull Long inventoryPositionId, + @NotNull @Min(1) Long packages, @NotBlank @Size(max=255) String idempotencyKey) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java index b763efe88..5a0e3e0d0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java @@ -17,4 +17,5 @@ public final class DeliveryResponses { int packageCount, LocalDateTime createdAt) {} public record OutboundDetail(OutboundRow outbound, DeliveryRow delivery) {} + public record PdaTask(Long id, String taskNo, String status, String outboundNo, LocalDateTime pushedAt, LocalDateTime completedAt) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundOrder.java index 0f438791f..1df985dac 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundOrder.java @@ -18,6 +18,8 @@ public class OutboundOrder { private Long dispatchOrderId; private String dispatchNo; private String overseasWarehouseId; + private String outboundType; + private String recipientInfo; private String status; private Integer packageCount; private Integer palletCount; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPalletItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPalletItem.java index 8145f8f82..0e1d60d84 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPalletItem.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPalletItem.java @@ -21,6 +21,9 @@ public class OutboundPalletItem { private String destinationWarehouseCode; private String destinationWarehouseName; private Integer packageCount; + private Integer pickedPackageCount; + private String status; + private String skuCode; private String orderRefs; private String inboundCode; private LocalDateTime createdAt; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPdaPick.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPdaPick.java new file mode 100644 index 000000000..64d4b091b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPdaPick.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.delivery.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_outbound_pda_pick") +public class OutboundPdaPick { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private String customerId; + private Long taskId; + private Long outboundPalletItemId; + private Long inventoryPositionId; + private Long packages; + private String idempotencyKey; + private LocalDateTime pickedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPdaTask.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPdaTask.java new file mode 100644 index 000000000..bb1640a6e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPdaTask.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.delivery.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_outbound_pda_task") +public class OutboundPdaTask { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private String customerId; + private Long outboundOrderId; + private String taskNo; + private String status; + private LocalDateTime pushedAt; + private LocalDateTime completedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java index e16968f70..2059ee200 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java @@ -14,4 +14,6 @@ public interface OutboundOrderMapper extends BaseMapper { OutboundOrder findByIdempotency(@Param("customerId") String customerId, @Param("key") String key); @Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1 FOR UPDATE") OutboundOrder lockByDispatch(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId); + @Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND outbound_no=#{outboundNo} LIMIT 1 FOR UPDATE") + OutboundOrder lockByNo(@Param("customerId") String customerId, @Param("outboundNo") String outboundNo); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPdaPickMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPdaPickMapper.java new file mode 100644 index 000000000..6f19262c1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPdaPickMapper.java @@ -0,0 +1,11 @@ +package com.baiyue.lastleg.delivery.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.delivery.entity.OutboundPdaPick; +import org.apache.ibatis.annotations.*; + +@Mapper +public interface OutboundPdaPickMapper extends BaseMapper { + @Select("SELECT * FROM ll_outbound_pda_pick WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") + OutboundPdaPick findByKey(@Param("customerId") String customerId, @Param("key") String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPdaTaskMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPdaTaskMapper.java new file mode 100644 index 000000000..88307fa3a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPdaTaskMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.delivery.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.delivery.entity.OutboundPdaTask; +import org.apache.ibatis.annotations.*; +import java.util.List; + +@Mapper +public interface OutboundPdaTaskMapper extends BaseMapper { + @Select("SELECT * FROM ll_outbound_pda_task WHERE customer_id=#{customerId} AND outbound_order_id=#{outboundOrderId} LIMIT 1 FOR UPDATE") + OutboundPdaTask lockByOutbound(@Param("customerId") String customerId, @Param("outboundOrderId") Long outboundOrderId); + @Select("SELECT * FROM ll_outbound_pda_task WHERE customer_id=#{customerId} AND task_no=#{taskNo} LIMIT 1 FOR UPDATE") + OutboundPdaTask lockByNo(@Param("customerId") String customerId, @Param("taskNo") String taskNo); + @Select("SELECT * FROM ll_outbound_pda_task WHERE customer_id=#{customerId} AND (#{status} IS NULL OR status=#{status}) ORDER BY pushed_at DESC LIMIT 200") + List list(@Param("customerId") String customerId, @Param("status") String status); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index 57ec1814b..a0b1a210b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -22,6 +22,19 @@ import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; +import com.baiyue.lastleg.delivery.entity.OutboundPdaTask; +import com.baiyue.lastleg.delivery.entity.OutboundPdaPick; +import com.baiyue.lastleg.delivery.mapper.OutboundPdaTaskMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundPdaPickMapper; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile; +import com.baiyue.lastleg.dropship.entity.DropshipCarton; +import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper; +import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper; +import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; import jakarta.annotation.Resource; @@ -43,6 +56,13 @@ public class DeliveryTransferService { @Resource private OutboundOrderMapper outboundOrders; @Resource private OutboundPalletItemMapper outboundPallets; @Resource private DeliveryOrderMapper deliveryOrders; + @Resource private OutboundPdaTaskMapper pdaTasks; + @Resource private OutboundPdaPickMapper pdaPicks; + @Resource private InventoryPositionMapper inventoryPositions; + @Resource private InventoryCommandService inventoryCommands; + @Resource private DropshipOrderProfileMapper dropshipProfiles; + @Resource private DropshipCartonMapper dropshipCartons; + @Resource private DropshipSkuInventoryMapper dropshipSkuInventory; @Resource private InboundPlanPalletAllocationMapper planAllocations; @Resource private InboundPlanLineMapper planLines; @Resource private LastlegOrderMapper tailOrders; @@ -76,7 +96,7 @@ public class DeliveryTransferService { outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId); outbound.setOutboundNo("OUT" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + outbound.getId()); outbound.setDispatchOrderId(dispatch.getId()); outbound.setDispatchNo(dispatch.getDispatchNo()); - outbound.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); outbound.setStatus("WAIT_OUTBOUND"); + outbound.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); outbound.setStatus("WAIT_OUTBOUND"); outbound.setOutboundType("DISPATCH_DELIVERY"); outbound.setPackageCount(items.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); outbound.setPalletCount(items.size()); outbound.setIdempotencyKey(request.idempotencyKey().trim()); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); @@ -103,7 +123,7 @@ public class DeliveryTransferService { item.setDestinationWarehouseId(source.getDestinationWarehouseId()); item.setDestinationWarehouseCode(source.getDestinationWarehouseCode()); item.setDestinationWarehouseName(source.getDestinationWarehouseName()); - item.setPackageCount(Objects.requireNonNullElse(source.getPackageCount(), 0)); + item.setPackageCount(Objects.requireNonNullElse(source.getPackageCount(), 0)); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); item.setOrderRefs(source.getOrderRefs()); item.setInboundCode(source.getInboundCode()); item.setCreatedAt(now); outboundPallets.insert(item); } @@ -121,9 +141,81 @@ public class DeliveryTransferService { return result(outbound, delivery, false); } + @Transactional + public DeliveryResponses.PdaTask createOneClick(String customerId, String operatorId, DeliveryRequests.OneClickOutbound request) { + OutboundOrder existing = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim()); + if (existing != null) return taskResult(existing, pdaTasks.lockByOutbound(customerId, existing.getId())); + LocalDateTime now = LocalDateTime.now(); + OutboundOrder outbound = new OutboundOrder(); + outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId); + outbound.setOutboundNo("FBOO" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + outbound.getId()); + outbound.setOutboundType("ONE_CLICK_DROPSHIP"); outbound.setStatus("WAIT_PICK"); outbound.setRecipientInfo(request.recipient().trim()); + outbound.setPackageCount(request.items().stream().mapToInt(i -> i.packages().intValue()).sum()); outbound.setPalletCount(request.items().size()); + outbound.setIdempotencyKey(request.idempotencyKey().trim()); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); + outboundOrders.insert(outbound); + String selectedWarehouseId = null; + for (DeliveryRequests.OneClickItem input : request.items()) { + InventoryPosition position = inventoryPositions.selectById(input.inventoryPositionId()); + if (position == null || !customerId.equals(position.getCustomerId())) throw notFound("Inventory position not found"); + DropshipOrderProfile profile = dropshipProfiles.findPosition(customerId, position.getId()); + if (profile == null) throw conflict("Selected pallet is not one-click dropship inventory"); + if (selectedWarehouseId == null) selectedWarehouseId = position.getOverseasWarehouseId(); + else if (!Objects.equals(selectedWarehouseId, position.getOverseasWarehouseId())) throw conflict("One outbound order must use inventory from the same overseas warehouse"); + if (value(position.getAvailablePackages()) < input.packages()) throw conflict("Insufficient available inventory for pallet " + position.getPalletNo()); + OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setOutboundOrderId(outbound.getId()); + item.setWarehousePalletId(position.getPalletId()); item.setPalletNo(position.getPalletNo()); item.setPackageCount(input.packages().intValue()); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); item.setSkuCode(input.sku().trim()); item.setCreatedAt(now); + outboundPallets.insert(item); + } + outbound.setOverseasWarehouseId(selectedWarehouseId); outboundOrders.updateById(outbound); + OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setOutboundOrderId(outbound.getId()); + task.setTaskNo("PDA" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + task.getId()); task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task); + return taskResult(outbound, task); + } + + @Transactional + public DeliveryResponses.PdaTask pdaPick(String customerId, String operatorId, String taskNo, DeliveryRequests.PdaPick request) { + OutboundPdaTask task = pdaTasks.lockByNo(customerId, taskNo); if (task == null) throw notFound("PDA task not found"); + OutboundPdaPick replay = pdaPicks.findByKey(customerId, request.idempotencyKey().trim()); if (replay != null) return taskResult(outboundOrders.selectById(task.getOutboundOrderId()), task); + OutboundOrder outbound = outboundOrders.lockByNo(customerId, outboundNo(task.getOutboundOrderId())); + OutboundPalletItem item = outboundPallets.selectById(request.outboundPalletItemId()); + if (item == null || !task.getOutboundOrderId().equals(item.getOutboundOrderId())) throw notFound("Outbound pallet item not found"); + if (request.packages() > item.getPackageCount() - value(item.getPickedPackageCount())) throw conflict("Picked quantity exceeds outbound pallet quantity"); + InventoryPosition selectedPosition = inventoryPositions.selectById(request.inventoryPositionId()); + if (selectedPosition == null || !customerId.equals(selectedPosition.getCustomerId()) || !Objects.equals(selectedPosition.getPalletId(), item.getWarehousePalletId())) throw conflict("Scanned inventory position does not belong to the outbound pallet"); + DropshipOrderProfile profile = dropshipProfiles.findPosition(customerId, request.inventoryPositionId()); + if (profile == null) throw conflict("Scanned pallet is not one-click dropship inventory"); + List cartons = dropshipCartons.lockAvailable(customerId, profile.getId(), item.getSkuCode(), request.packages()); + if (cartons.size() != request.packages()) throw conflict("Insufficient available one-click dropship cartons"); + InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(request.inventoryPositionId(), request.packages(), outbound.getOutboundNo(), outbound.getOutboundNo(), null, request.idempotencyKey() + ":ALLOCATE", null); + inventoryCommands.allocate(customerId, operatorId, move); + inventoryCommands.pick(customerId, operatorId, new InventoryCommandRequests.Move(request.inventoryPositionId(), request.packages(), outbound.getOutboundNo(), outbound.getOutboundNo(), null, request.idempotencyKey() + ":PICK", null)); + inventoryCommands.ship(customerId, operatorId, new InventoryCommandRequests.Move(request.inventoryPositionId(), request.packages(), outbound.getOutboundNo(), outbound.getOutboundNo(), null, request.idempotencyKey() + ":SHIP", null)); + if (dropshipSkuInventory.deduct(customerId, profile.getId(), item.getSkuCode(), request.packages()) != 1) throw conflict("Insufficient one-click dropship SKU inventory"); + for (DropshipCarton carton : cartons) { carton.setStatus("SHIPPED"); carton.setOutboundOrderNo(outbound.getOutboundNo()); carton.setVersion((carton.getVersion() == null ? 0 : carton.getVersion()) + 1); carton.setUpdatedAt(LocalDateTime.now()); dropshipCartons.updateById(carton); } + OutboundPdaPick pick = new OutboundPdaPick(); pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId()); pick.setInventoryPositionId(request.inventoryPositionId()); pick.setPackages(request.packages()); pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick); + item.setPickedPackageCount(value(item.getPickedPackageCount()) + request.packages().intValue()); item.setStatus(item.getPickedPackageCount() >= item.getPackageCount() ? "PICKED" : "PARTIAL_PICKED"); outboundPallets.updateById(item); + task.setStatus("IN_PROGRESS"); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task); + return taskResult(outbound, task); + } + + @Transactional + public DeliveryResponses.PdaTask completePda(String customerId, String taskNo) { + OutboundPdaTask task = pdaTasks.lockByNo(customerId, taskNo); if (task == null) throw notFound("PDA task not found"); + OutboundOrder outbound = outboundOrders.selectById(task.getOutboundOrderId()); + List items = outboundPallets.findByOutbound(customerId, outbound.getId()); + if (items.stream().anyMatch(i -> value(i.getPickedPackageCount()) < value(i.getPackageCount()))) throw conflict("All outbound pallets must be picked before completion"); + task.setStatus("COMPLETED"); task.setCompletedAt(LocalDateTime.now()); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task); outbound.setStatus("OUTBOUNDED"); outbound.setUpdatedAt(LocalDateTime.now()); outboundOrders.updateById(outbound); return taskResult(outbound, task); + } + + public List pdaTasks(String customerId, String status) { return pdaTasks.list(customerId, StringUtils.hasText(status) ? status : null).stream().map(task -> taskResult(outboundOrders.selectById(task.getOutboundOrderId()), task)).toList(); } + + private DeliveryResponses.PdaTask taskResult(OutboundOrder outbound, OutboundPdaTask task) { return new DeliveryResponses.PdaTask(task.getId(), task.getTaskNo(), task.getStatus(), outbound.getOutboundNo(), task.getPushedAt(), task.getCompletedAt()); } + private String outboundNo(Long id) { OutboundOrder value = outboundOrders.selectById(id); return value == null ? "" : value.getOutboundNo(); } + private int value(Integer value) { return value == null ? 0 : value; } + public PageResult outboundPage(String customerId, DeliveryRequests.Query query) { Page page = outboundOrders.selectPage(new Page<>(query.page(), query.size()), - new LambdaQueryWrapper().eq(OutboundOrder::getCustomerId, customerId) + new LambdaQueryWrapper().eq(StringUtils.hasText(customerId), OutboundOrder::getCustomerId, customerId) .like(StringUtils.hasText(query.documentNo()), OutboundOrder::getOutboundNo, query.documentNo()) .eq(StringUtils.hasText(query.warehouseId()), OutboundOrder::getOverseasWarehouseId, query.warehouseId()) .eq(StringUtils.hasText(query.status()), OutboundOrder::getStatus, query.status()) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonMapper.java index 6c2ad8ed2..cb57e1de3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonMapper.java @@ -12,4 +12,6 @@ public interface DropshipCartonMapper extends BaseMapper { @Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY profile_id,carton_sequence") List listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId); + @Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND profile_id=#{profileId} AND sku_code=#{sku} AND status='AVAILABLE' ORDER BY carton_sequence LIMIT #{packages} FOR UPDATE") + List lockAvailable(@Param("customerId") String customerId, @Param("profileId") Long profileId, @Param("sku") String sku, @Param("packages") long packages); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java index 01a7d1da2..c02970849 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java @@ -6,4 +6,6 @@ import java.util.List; public interface DropshipOrderProfileMapper extends BaseMapper { @Select("SELECT * FROM ll_dropship_order_profile WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY source_pallet_no") List listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId); + @Select("SELECT * FROM ll_dropship_order_profile WHERE customer_id=#{customerId} AND inventory_position_id=#{positionId} LIMIT 1") + DropshipOrderProfile findPosition(@Param("customerId") String customerId, @Param("positionId") Long positionId); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipSkuInventoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipSkuInventoryMapper.java index b0f1c7a5a..3c97736f1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipSkuInventoryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipSkuInventoryMapper.java @@ -4,10 +4,13 @@ import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; import java.util.List; public interface DropshipSkuInventoryMapper extends BaseMapper { @Select("SELECT * FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY profile_id,sku_code") List listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId); + @Update("UPDATE ll_dropship_sku_inventory SET package_count=package_count-#{packages},updated_at=NOW(3) WHERE customer_id=#{customerId} AND profile_id=#{profileId} AND sku_code=#{sku} AND package_count>=#{packages}") + int deduct(@Param("customerId") String customerId, @Param("profileId") Long profileId, @Param("sku") String sku, @Param("packages") long packages); } diff --git a/lastleg-service/src/main/resources/application.yml b/lastleg-service/src/main/resources/application.yml index 3699c27ba..c9eaf0cdc 100644 --- a/lastleg-service/src/main/resources/application.yml +++ b/lastleg-service/src/main/resources/application.yml @@ -39,6 +39,8 @@ lastleg: enabled: ${LASTLEG_DISPATCH_QUERY_API_ENABLED:true} smart-dispatch-api: enabled: ${LASTLEG_SMART_DISPATCH_API_ENABLED:true} + future-outbound-api: + enabled: ${LASTLEG_FUTURE_OUTBOUND_API_ENABLED:true} baiyue: http: diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 893cea457..45c3be89d 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1103,11 +1103,13 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick_order ( -- Transfer a dispatch to delivery: one outbound header, many pallet rows, one delivery header. CREATE TABLE IF NOT EXISTS ll_outbound_order ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', - outbound_no VARCHAR(64) NOT NULL COMMENT '出库单号', dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID', dispatch_no VARCHAR(64) NOT NULL COMMENT '派车单号', + outbound_no VARCHAR(64) NOT NULL COMMENT '出库单号', dispatch_order_id BIGINT NULL COMMENT '派车单ID,一件代发为空', dispatch_no VARCHAR(64) NULL COMMENT '派车单号,一件代发为空', overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_OUTBOUND' COMMENT '业务状态', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', pallet_count INT NOT NULL DEFAULT 0 COMMENT '托盘数', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', created_by VARCHAR(128) NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + outbound_type VARCHAR(32) NOT NULL DEFAULT 'DISPATCH_DELIVERY' COMMENT '出库类型:派送转出库、一件代发', + recipient_info VARCHAR(1000) NULL COMMENT '一件代发收件信息', PRIMARY KEY (id), UNIQUE KEY uk_ll_outbound_no (customer_id,outbound_no), UNIQUE KEY uk_ll_outbound_idempotency (customer_id,idempotency_key), UNIQUE KEY uk_ll_outbound_dispatch (customer_id,dispatch_order_id), KEY idx_ll_outbound_list (customer_id,status,created_at) @@ -1117,12 +1119,39 @@ CREATE TABLE IF NOT EXISTS ll_outbound_pallet_item ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID', warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', - package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', picked_package_count INT NOT NULL DEFAULT 0 COMMENT '已拣箱数', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态', sku_code VARCHAR(128) NULL COMMENT '一件代发SKU', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), UNIQUE KEY uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id), KEY idx_ll_outbound_pallet_list (customer_id,outbound_order_id), KEY idx_ll_outbound_pallet_destination (customer_id,destination_warehouse_code) ) ENGINE=InnoDB COMMENT='出库托盘明细'; +CREATE TABLE IF NOT EXISTS ll_outbound_pda_task ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID', + task_no VARCHAR(64) NOT NULL COMMENT 'PDA任务号', status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态:待拣货、拣货中、已完成', + pushed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '推送时间', completed_at DATETIME(3) NULL COMMENT '完成时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), UNIQUE KEY uk_ll_outbound_pda_task_order(customer_id,outbound_order_id), UNIQUE KEY uk_ll_outbound_pda_task_no(customer_id,task_no) +) ENGINE=InnoDB COMMENT='一件代发出库PDA拣货任务'; + +CREATE TABLE IF NOT EXISTS ll_outbound_pda_pick ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL, task_id BIGINT NOT NULL COMMENT 'PDA任务ID', outbound_pallet_item_id BIGINT NOT NULL COMMENT '出库托盘明细ID', + inventory_position_id BIGINT NOT NULL COMMENT '库存仓位ID', packages BIGINT NOT NULL COMMENT '拣货箱数', idempotency_key VARCHAR(255) NOT NULL, + picked_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id), UNIQUE KEY uk_ll_outbound_pda_pick_key(customer_id,idempotency_key) +) ENGINE=InnoDB COMMENT='一件代发出库PDA托盘拣货记录'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='outbound_type'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN outbound_type VARCHAR(32) NOT NULL DEFAULT ''DISPATCH_DELIVERY'' COMMENT ''出库类型'' AFTER status')); +PREPARE outbound_upgrade_stmt FROM @ddl; EXECUTE outbound_upgrade_stmt; DEALLOCATE PREPARE outbound_upgrade_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='sku_code'), 'SELECT 1', 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN sku_code VARCHAR(128) NULL COMMENT ''一件代发SKU'' AFTER status')); +PREPARE outbound_upgrade_stmt FROM @ddl; EXECUTE outbound_upgrade_stmt; DEALLOCATE PREPARE outbound_upgrade_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='recipient_info'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN recipient_info VARCHAR(1000) NULL COMMENT ''一件代发收件信息'' AFTER outbound_type')); +PREPARE outbound_upgrade_stmt FROM @ddl; EXECUTE outbound_upgrade_stmt; DEALLOCATE PREPARE outbound_upgrade_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0 COMMENT ''已拣箱数'' AFTER package_count')); +PREPARE outbound_upgrade_stmt FROM @ddl; EXECUTE outbound_upgrade_stmt; DEALLOCATE PREPARE outbound_upgrade_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='status'), 'SELECT 1', 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN status VARCHAR(32) NOT NULL DEFAULT ''WAIT_PICK'' COMMENT ''拣货状态'' AFTER picked_package_count')); +PREPARE outbound_upgrade_stmt FROM @ddl; EXECUTE outbound_upgrade_stmt; DEALLOCATE PREPARE outbound_upgrade_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='dispatch_order_id' AND is_nullable='YES'), 'SELECT 1', 'ALTER TABLE ll_outbound_order MODIFY COLUMN dispatch_order_id BIGINT NULL COMMENT ''派车单ID,一件代发为空'', MODIFY COLUMN dispatch_no VARCHAR(64) NULL COMMENT ''派车单号,一件代发为空''')); +PREPARE outbound_upgrade_stmt FROM @ddl; EXECUTE outbound_upgrade_stmt; DEALLOCATE PREPARE outbound_upgrade_stmt; + CREATE TABLE IF NOT EXISTS ll_delivery_order ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', delivery_no VARCHAR(64) NOT NULL COMMENT '派送单号', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID', outbound_no VARCHAR(64) NOT NULL COMMENT '出库单号', diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index 81332bff9..ad21defb5 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -3805,6 +3805,21 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab AND table_name='ll_cargo_discard_application' AND column_name='review_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_cargo_discard_application ADD COLUMN review_idempotency_key VARCHAR(255) NULL COMMENT ''审核幂等键'' AFTER request_hash')); PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt; DEALLOCATE PREPARE phase4_discard_review_stmt; + +-- 一件代发出库与 PDA 按托盘拣货出库。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='outbound_type'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN outbound_type VARCHAR(32) NOT NULL DEFAULT ''DISPATCH_DELIVERY'' COMMENT ''出库类型'' AFTER status')); +PREPARE dropship_outbound_stmt FROM @ddl; EXECUTE dropship_outbound_stmt; DEALLOCATE PREPARE dropship_outbound_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='recipient_info'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN recipient_info VARCHAR(1000) NULL COMMENT ''一件代发收件信息'' AFTER outbound_type')); +PREPARE dropship_outbound_stmt FROM @ddl; EXECUTE dropship_outbound_stmt; DEALLOCATE PREPARE dropship_outbound_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0 COMMENT ''已拣箱数'' AFTER package_count')); +PREPARE dropship_outbound_stmt FROM @ddl; EXECUTE dropship_outbound_stmt; DEALLOCATE PREPARE dropship_outbound_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='status'), 'SELECT 1', 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN status VARCHAR(32) NOT NULL DEFAULT ''WAIT_PICK'' COMMENT ''拣货状态'' AFTER picked_package_count')); +PREPARE dropship_outbound_stmt FROM @ddl; EXECUTE dropship_outbound_stmt; DEALLOCATE PREPARE dropship_outbound_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='sku_code'), 'SELECT 1', 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN sku_code VARCHAR(128) NULL COMMENT ''一件代发SKU'' AFTER status')); +PREPARE dropship_outbound_stmt FROM @ddl; EXECUTE dropship_outbound_stmt; DEALLOCATE PREPARE dropship_outbound_stmt; +ALTER TABLE ll_outbound_order MODIFY COLUMN dispatch_order_id BIGINT NULL COMMENT '派车单ID,一件代发为空', MODIFY COLUMN dispatch_no VARCHAR(64) NULL COMMENT '派车单号,一件代发为空'; +CREATE TABLE IF NOT EXISTS ll_outbound_pda_task (id BIGINT NOT NULL,customer_id VARCHAR(128) NOT NULL,outbound_order_id BIGINT NOT NULL,task_no VARCHAR(64) NOT NULL,status VARCHAR(32) NOT NULL DEFAULT 'PENDING',pushed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),completed_at DATETIME(3) NULL,created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),PRIMARY KEY(id),UNIQUE KEY uk_ll_outbound_pda_task_order(customer_id,outbound_order_id),UNIQUE KEY uk_ll_outbound_pda_task_no(customer_id,task_no)) ENGINE=InnoDB COMMENT='一件代发出库PDA拣货任务'; +CREATE TABLE IF NOT EXISTS ll_outbound_pda_pick (id BIGINT NOT NULL,customer_id VARCHAR(128) NOT NULL,task_id BIGINT NOT NULL,outbound_pallet_item_id BIGINT NOT NULL,inventory_position_id BIGINT NOT NULL,packages BIGINT NOT NULL,idempotency_key VARCHAR(255) NOT NULL,picked_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),PRIMARY KEY(id),UNIQUE KEY uk_ll_outbound_pda_pick_key(customer_id,idempotency_key)) ENGINE=InnoDB COMMENT='一件代发出库PDA托盘拣货记录'; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='review_request_hash'), 'SELECT 1', 'ALTER TABLE ll_cargo_discard_application ADD COLUMN review_request_hash CHAR(64) NULL COMMENT ''审核请求内容哈希'' AFTER review_idempotency_key')); From 270372f56ab93c700b82a76318ea98c045b1da2c Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Wed, 26 Aug 2026 11:05:26 +0800 Subject: [PATCH 06/24] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/海外区域统一增量-MySQL8.sql | 19 +++++++ .../controller/LastlegOrderController.java | 16 ++++++ .../dto/LastlegOrderDetailResponse.java | 6 ++- .../lastleg/dto/LastlegOrderRequests.java | 13 +++++ .../lastleg/entity/LastlegOrderFee.java | 25 +++++++++ .../entity/LastlegOrderOperationLog.java | 20 +++++++ .../lastleg/mapper/LastlegOrderFeeMapper.java | 9 ++++ .../LastlegOrderOperationLogMapper.java | 9 ++++ .../lastleg/service/LastlegOrderService.java | 51 +++++++++++++++++- .../src/main/resources/db/lastleg.sql | 28 ++++++++++ .../openapi/overseas-business-v1.yaml | 54 +++++++++++++++++++ .../service/LastlegOrderServiceTest.java | 24 +++++++++ scripts/db/incremental.sql | 18 +++++++ 13 files changed, 290 insertions(+), 2 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderFee.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderOperationLog.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderFeeMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderOperationLogMapper.java diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 41f10d449..6fc9929f5 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -920,6 +920,25 @@ DELIMITER ; CALL assert_phase4_pda_operations_ready(); DROP PROCEDURE assert_phase4_pda_operations_ready; +CREATE TABLE IF NOT EXISTS ll_order_fee ( + id BIGINT NOT NULL COMMENT '费用明细主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', fee_item VARCHAR(128) NOT NULL COMMENT '费用项目', + currency VARCHAR(3) NOT NULL COMMENT '币种代码', amount DECIMAL(18,2) NOT NULL COMMENT '费用金额', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '费用状态:PENDING_REVIEW待审核、APPROVED审核通过、REJECTED已驳回', + supplier_description VARCHAR(500) NULL COMMENT '供应商或费用说明', created_by VARCHAR(128) NOT NULL COMMENT '创建操作人', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), KEY idx_ll_order_fee_customer_order(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='尾程订单费用明细'; + +CREATE TABLE IF NOT EXISTS ll_order_operation_log ( + id BIGINT NOT NULL COMMENT '操作记录主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', operation_node VARCHAR(64) NOT NULL COMMENT '操作节点', + operator_name VARCHAR(128) NOT NULL COMMENT '操作人名称', result VARCHAR(500) NOT NULL COMMENT '处理结果', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY(id), KEY idx_ll_order_operation_customer_order(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='尾程订单操作记录'; + CREATE DATABASE IF NOT EXISTS baiyue_overseas_security DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; USE baiyue_overseas_security; CREATE TABLE IF NOT EXISTS overseas_security_event_inbox ( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java index 7fd3375d5..9386f9c91 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java @@ -7,6 +7,7 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.dto.LastlegOrderDetailResponse; import com.baiyue.lastleg.dto.LastlegOrderRequests; import com.baiyue.lastleg.dto.LastlegOrderSummary; +import com.baiyue.lastleg.entity.LastlegOrderFee; import com.baiyue.lastleg.security.InternalServiceTokenGuard; import com.baiyue.lastleg.security.OverseasPermissions; import com.baiyue.lastleg.service.LastlegOrderService; @@ -81,6 +82,17 @@ public class LastlegOrderController { return ApiResponse.success(orderService.detail(platformScope(user, customerId), id)); } + @PostMapping("/api/platform/v1/last-leg/orders/{id}/fees") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") + @Operation(summary = "Add a fee to one tail-leg order", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT) + public ApiResponse createFee( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable long id, + @Valid @RequestBody LastlegOrderRequests.CreateFee request) { + return ApiResponse.success(orderService.createFee(platformScope(user, customerId), id, request, operatorName(user))); + } + @GetMapping(value = "/api/platform/v1/last-leg/orders/export", produces = "text/csv") @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EXPORT + "')") @Operation(summary = "Export filtered tail-leg orders as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EXPORT) @@ -126,4 +138,8 @@ public class LastlegOrderController { return requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer) ? null : requestedCustomer.trim(); } + + private String operatorName(AuthenticatedUser user) { + return user.username() == null || user.username().isBlank() ? String.valueOf(user.userId()) : user.username(); + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java index 6c1285038..27650b7b0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java @@ -1,6 +1,7 @@ package com.baiyue.lastleg.dto; import com.baiyue.lastleg.entity.LastlegOrderDetail; +import com.baiyue.lastleg.entity.LastlegOrderFee; import java.time.LocalDateTime; @@ -9,7 +10,10 @@ import java.util.List; public record LastlegOrderDetailResponse( LastlegOrderSummary order, List cargo, - List statusHistory + List statusHistory, + List fees, + List operationHistory ) { public record History(Long id, String fromStatus, String toStatus, String remark, LocalDateTime createdAt) {} + public record Operation(Long id, String operationNode, String operatorName, String result, LocalDateTime createdAt) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java index 99bf49851..01b53a403 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java @@ -5,7 +5,11 @@ import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.Size; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.NotNull; +import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.List; @@ -55,4 +59,13 @@ public final class LastlegOrderRequests { return new TransferDestination(List.of(orderId), warehouseId, warehouseCode, warehouseName, remark); } } + + public record CreateFee( + @NotBlank @Size(max = 128) String feeItem, + @NotBlank @Pattern(regexp = "[A-Z]{3}") String currency, + @NotNull @DecimalMin(value = "0.01") BigDecimal amount, + @NotBlank @Pattern(regexp = "PENDING_REVIEW|APPROVED|REJECTED") String status, + @Size(max = 500) String supplierDescription + ) { + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderFee.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderFee.java new file mode 100644 index 000000000..7e1b2d9e9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderFee.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@TableName("ll_order_fee") +public class LastlegOrderFee { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long orderId; + private String feeItem; + private String currency; + private BigDecimal amount; + private String status; + private String supplierDescription; + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderOperationLog.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderOperationLog.java new file mode 100644 index 000000000..db8a7b860 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderOperationLog.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.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_order_operation_log") +public class LastlegOrderOperationLog { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long orderId; + private String operationNode; + private String operatorName; + private String result; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderFeeMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderFeeMapper.java new file mode 100644 index 000000000..761e7620d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderFeeMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.entity.LastlegOrderFee; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface LastlegOrderFeeMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderOperationLogMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderOperationLogMapper.java new file mode 100644 index 000000000..c5b9fb76d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderOperationLogMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.entity.LastlegOrderOperationLog; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface LastlegOrderOperationLogMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java index 8b88d3694..a9ddd69c9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -1,6 +1,7 @@ package com.baiyue.lastleg.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; import com.baiyue.lastleg.dto.LastlegOrderDetailResponse; @@ -9,11 +10,15 @@ import com.baiyue.lastleg.dto.LastlegOrderSummary; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegOrderDetail; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.entity.LastlegOrderFee; +import com.baiyue.lastleg.entity.LastlegOrderOperationLog; import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import com.baiyue.lastleg.mapper.LastlegOrderFeeMapper; +import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper; import com.baiyue.lastleg.model.LastlegOrderType; import jakarta.annotation.Resource; import org.springframework.http.HttpStatus; @@ -37,6 +42,8 @@ public class LastlegOrderService { @Resource private LastlegOrderMapper orderMapper; @Resource private LastlegOrderDetailMapper detailMapper; @Resource private LastlegOrderStatusHistoryMapper historyMapper; + @Resource private LastlegOrderFeeMapper feeMapper; + @Resource private LastlegOrderOperationLogMapper operationLogMapper; @Resource private InboundPlanCartonMapper cartonMapper; public PageResult page(String customerId, LastlegOrderRequests.Query query) { @@ -56,9 +63,51 @@ public class LastlegOrderService { .eq(LastlegOrderStatusHistory::getCustomerId, order.getCustomerId()) .eq(LastlegOrderStatusHistory::getOrderId, orderId) .orderByAsc(LastlegOrderStatusHistory::getCreatedAt)); + List fees = feeMapper.selectList(new LambdaQueryWrapper() + .eq(LastlegOrderFee::getCustomerId, order.getCustomerId()) + .eq(LastlegOrderFee::getOrderId, orderId).orderByAsc(LastlegOrderFee::getCreatedAt)); + List operations = operationLogMapper.selectList( + new LambdaQueryWrapper() + .eq(LastlegOrderOperationLog::getCustomerId, order.getCustomerId()) + .eq(LastlegOrderOperationLog::getOrderId, orderId) + .orderByAsc(LastlegOrderOperationLog::getCreatedAt)); return new LastlegOrderDetailResponse(summary(order), cargo, history.stream() .map(value -> new LastlegOrderDetailResponse.History(value.getId(), value.getFromStatus(), - value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList()); + value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList(), fees, operations.stream() + .map(value -> new LastlegOrderDetailResponse.Operation(value.getId(), value.getOperationNode(), + value.getOperatorName(), value.getResult(), value.getCreatedAt())).toList()); + } + + @Transactional + public LastlegOrderFee createFee(String customerId, long orderId, LastlegOrderRequests.CreateFee request, + String operatorName) { + LastlegOrder order = findOwned(customerId, orderId); + LocalDateTime now = LocalDateTime.now(); + LastlegOrderFee fee = new LastlegOrderFee(); + fee.setId(IdWorker.getId()); + fee.setCustomerId(order.getCustomerId()); + fee.setOrderId(orderId); + fee.setFeeItem(request.feeItem().trim()); + fee.setCurrency(request.currency()); + fee.setAmount(request.amount()); + fee.setStatus(request.status()); + fee.setSupplierDescription(StringUtils.hasText(request.supplierDescription()) + ? request.supplierDescription().trim() : null); + fee.setCreatedBy(operatorName); + fee.setCreatedAt(now); + fee.setUpdatedAt(now); + feeMapper.insert(fee); + + LastlegOrderOperationLog operation = new LastlegOrderOperationLog(); + operation.setId(IdWorker.getId()); + operation.setCustomerId(order.getCustomerId()); + operation.setOrderId(orderId); + operation.setOperationNode("新增费用"); + operation.setOperatorName(operatorName); + operation.setResult("新增" + fee.getFeeItem() + ":" + fee.getCurrency() + " " + fee.getAmount().toPlainString()); + operation.setCreatedAt(now); + operationLogMapper.insert(operation); + return fee; } @Transactional diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 45c3be89d..26cd0f694 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -137,6 +137,34 @@ CREATE TABLE IF NOT EXISTS ll_loading_order_status_history ( KEY idx_ll_loading_history_customer_order(customer_id,order_id,created_at) ) ENGINE=InnoDB COMMENT='尾程订单状态变更历史'; +CREATE TABLE IF NOT EXISTS ll_order_fee ( + id BIGINT NOT NULL COMMENT '费用明细主键', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', + fee_item VARCHAR(128) NOT NULL COMMENT '费用项目', + currency VARCHAR(3) NOT NULL COMMENT '币种代码', + amount DECIMAL(18,2) NOT NULL COMMENT '费用金额', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '费用状态:PENDING_REVIEW待审核、APPROVED审核通过、REJECTED已驳回', + supplier_description VARCHAR(500) NULL COMMENT '供应商或费用说明', + created_by VARCHAR(128) NOT NULL COMMENT '创建操作人', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), + KEY idx_ll_order_fee_customer_order(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='尾程订单费用明细'; + +CREATE TABLE IF NOT EXISTS ll_order_operation_log ( + id BIGINT NOT NULL COMMENT '操作记录主键', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', + operation_node VARCHAR(64) NOT NULL COMMENT '操作节点', + operator_name VARCHAR(128) NOT NULL COMMENT '操作人名称', + result VARCHAR(500) NOT NULL COMMENT '处理结果', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY(id), + KEY idx_ll_order_operation_customer_order(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='尾程订单操作记录'; + CREATE TABLE IF NOT EXISTS ll_event_conflict ( id BIGINT NOT NULL COMMENT '冲突记录主键', event_id VARCHAR(128) NOT NULL COMMENT '冲突事件 ID', customer_id BIGINT NOT NULL COMMENT '租户 ID', conflict_type VARCHAR(64) NOT NULL COMMENT '冲突类型', diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 1c5d4c33f..04ae37a68 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -138,6 +138,29 @@ paths: - { name: createdFrom, in: query, schema: { type: string, format: date-time } } - { name: createdTo, in: query, schema: { type: string, format: date-time } } responses: { '200': { description: 统一 ApiResponse 包装的尾程订单分页;列表行 destinationType 为 FBA 或 OVERSEAS } } + /api/platform/v1/last-leg/orders/{id}: + get: + summary: 查询尾程订单详情 + description: 权限 platform:overseas-warehouse:last-mile-order:list;返回订单、货物、状态历史、费用明细和操作记录。 + security: [{ bearerAuth: [] }] + parameters: + - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } + responses: + '200': { description: 统一 ApiResponse 包装的尾程订单详情 } + '404': { description: 尾程订单不存在或不属于查询客户 } + /api/platform/v1/last-leg/orders/{id}/fees: + post: + summary: 新增尾程订单费用 + description: 权限 platform:overseas-warehouse:last-mile-order:edit;在订单客户范围内新增费用并记录操作人和处理结果。 + security: [{ bearerAuth: [] }] + parameters: + - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } + requestBody: + required: true + content: { application/json: { schema: { $ref: '#/components/schemas/LastMileFeeCreate' } } } + responses: + '200': { description: 新增后的费用明细, content: { application/json: { schema: { $ref: '#/components/schemas/LastMileFeeApiResponse' } } } } + '404': { description: 尾程订单不存在或不属于操作客户 } /api/platform/v1/last-leg/dispatches: get: summary: 分页查询排车单 @@ -513,6 +536,37 @@ paths: responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 非法状态迁移或关联尾程单缺失 } } components: schemas: + LastMileFeeCreate: + type: object + required: [feeItem, currency, amount, status] + properties: + feeItem: { type: string, minLength: 1, maxLength: 128 } + currency: { type: string, pattern: '^[A-Z]{3}$', example: USD } + amount: { type: number, format: decimal, minimum: 0.01 } + status: { type: string, enum: [PENDING_REVIEW, APPROVED, REJECTED] } + supplierDescription: { type: string, maxLength: 500 } + LastMileFee: + type: object + required: [id, customerId, orderId, feeItem, currency, amount, status, createdBy, createdAt, updatedAt] + properties: + id: { type: integer, format: int64 } + customerId: { type: string } + orderId: { type: integer, format: int64 } + feeItem: { type: string } + currency: { type: string } + amount: { type: number, format: decimal } + status: { type: string } + supplierDescription: { type: string, nullable: true } + createdBy: { type: string } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + LastMileFeeApiResponse: + type: object + required: [code, message, data] + properties: + code: { type: string } + message: { type: string } + data: { $ref: '#/components/schemas/LastMileFee' } PdaWarehouseScan: type: object required: [code] diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java index 9f7a4f9ab..737b4e848 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -9,6 +9,8 @@ import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import com.baiyue.lastleg.mapper.LastlegOrderFeeMapper; +import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -19,6 +21,7 @@ import org.springframework.web.server.ResponseStatusException; import org.springframework.test.util.ReflectionTestUtils; import java.util.List; +import java.math.BigDecimal; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -33,6 +36,8 @@ class LastlegOrderServiceTest { @Mock LastlegOrderMapper orders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; + @Mock LastlegOrderFeeMapper fees; + @Mock LastlegOrderOperationLogMapper operations; @Mock InboundPlanCartonMapper cartons; LastlegOrderService service; @@ -42,6 +47,8 @@ class LastlegOrderServiceTest { ReflectionTestUtils.setField(service, "orderMapper", orders); ReflectionTestUtils.setField(service, "detailMapper", details); ReflectionTestUtils.setField(service, "historyMapper", histories); + ReflectionTestUtils.setField(service, "feeMapper", fees); + ReflectionTestUtils.setField(service, "operationLogMapper", operations); ReflectionTestUtils.setField(service, "cartonMapper", cartons); } @@ -52,6 +59,8 @@ class LastlegOrderServiceTest { when(orders.selectOne(any())).thenReturn(order); when(details.selectList(any())).thenReturn(List.of()); when(histories.selectList(any())).thenReturn(List.of()); + when(fees.selectList(any())).thenReturn(List.of()); + when(operations.selectList(any())).thenReturn(List.of()); var response = service.detail("100", 11L); @@ -91,6 +100,21 @@ class LastlegOrderServiceTest { assertEquals("DESTINATION_ASSIGNED", history.getValue().getToStatus()); } + @Test + void createFeePersistsFeeAndOperationForOwnedOrder() { + when(orders.selectOne(any())).thenReturn(order(11L, "100", "PENDING_ASSIGNMENT", null)); + var request = new LastlegOrderRequests.CreateFee( + "清关费", "USD", new BigDecimal("420.00"), "PENDING_REVIEW", "报关代理"); + + var fee = service.createFee("100", 11L, request, "platform_admin"); + + assertEquals("100", fee.getCustomerId()); + assertEquals("清关费", fee.getFeeItem()); + assertEquals(new BigDecimal("420.00"), fee.getAmount()); + verify(fees).insert(any()); + verify(operations).insert(any()); + } + @Test void transferRejectsAnOrderAlreadyBoundToInboundPlan() { LastlegOrder order = order(11L, "100", "DESTINATION_ASSIGNED", "IB-1"); diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index ad21defb5..58a4dd551 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -3824,3 +3824,21 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab AND table_name='ll_cargo_discard_application' AND column_name='review_request_hash'), 'SELECT 1', 'ALTER TABLE ll_cargo_discard_application ADD COLUMN review_request_hash CHAR(64) NULL COMMENT ''审核请求内容哈希'' AFTER review_idempotency_key')); PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt; DEALLOCATE PREPARE phase4_discard_review_stmt; +CREATE TABLE IF NOT EXISTS ll_order_fee ( + id BIGINT NOT NULL COMMENT '费用明细主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', fee_item VARCHAR(128) NOT NULL COMMENT '费用项目', + currency VARCHAR(3) NOT NULL COMMENT '币种代码', amount DECIMAL(18,2) NOT NULL COMMENT '费用金额', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '费用状态:PENDING_REVIEW待审核、APPROVED审核通过、REJECTED已驳回', + supplier_description VARCHAR(500) NULL COMMENT '供应商或费用说明', created_by VARCHAR(128) NOT NULL COMMENT '创建操作人', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), KEY idx_ll_order_fee_customer_order(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='尾程订单费用明细'; + +CREATE TABLE IF NOT EXISTS ll_order_operation_log ( + id BIGINT NOT NULL COMMENT '操作记录主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', operation_node VARCHAR(64) NOT NULL COMMENT '操作节点', + operator_name VARCHAR(128) NOT NULL COMMENT '操作人名称', result VARCHAR(500) NOT NULL COMMENT '处理结果', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY(id), KEY idx_ll_order_operation_customer_order(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='尾程订单操作记录'; From 394c690601789f7cebf08a4634e7d15cb335cbca Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Wed, 26 Aug 2026 13:51:19 +0800 Subject: [PATCH 07/24] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 6 ++ .../DeliveryTransferController.java | 14 ++- .../delivery/dto/DeliveryRequests.java | 37 ++++++- .../delivery/dto/DeliveryResponses.java | 30 +++++- .../lastleg/delivery/entity/DeliveryFee.java | 24 +++++ .../delivery/entity/DeliveryOperation.java | 21 ++++ .../delivery/entity/DeliveryOrder.java | 22 ++++ .../delivery/mapper/DeliveryFeeMapper.java | 16 +++ .../mapper/DeliveryOperationMapper.java | 16 +++ .../mapper/OutboundPdaPickMapper.java | 3 + .../service/DeliveryTransferService.java | 101 ++++++++++++++++-- .../dropship/mapper/DropshipCartonMapper.java | 2 + .../mapper/DropshipSkuInventoryMapper.java | 2 + .../src/main/resources/db/lastleg.sql | 22 ++++ .../DeliveryLongIdJsonContractTest.java | 71 ++++++++++++ .../service/DeliveryTransferServiceTest.java | 9 ++ .../service/LastlegOrderServiceTest.java | 4 +- scripts/db/incremental.sql | 6 ++ 18 files changed, 390 insertions(+), 16 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryFee.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOperation.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryFeeMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOperationMapper.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index e71adf7d2..982373ef2 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -16563,6 +16563,12 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE 'ALTER TABLE ll_local_looseboard_order ADD UNIQUE KEY uk_ll_local_looseboard_lastleg(lastleg_order_id)')); PREPARE lastleg_looseboard_tail_stmt FROM @ddl; EXECUTE lastleg_looseboard_tail_stmt; DEALLOCATE PREPARE lastleg_looseboard_tail_stmt; +-- 派送单列表与详情快照、转派送费用及操作记录(当前上下文为尾程库)。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='overseas_warehouse_name'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT ''海外仓名称快照'', ADD COLUMN customer_name VARCHAR(255) NULL COMMENT ''客户名称快照'', ADD COLUMN waybill_no VARCHAR(128) NULL COMMENT ''承运运单号'', ADD COLUMN delivery_method VARCHAR(32) NULL COMMENT ''派送方式'', ADD COLUMN carrier_name VARCHAR(255) NULL COMMENT ''派送承运商'', ADD COLUMN pickup_method VARCHAR(64) NULL COMMENT ''提货方式'', ADD COLUMN loading_type VARCHAR(64) NULL COMMENT ''装车类型'', ADD COLUMN vehicle_type VARCHAR(64) NULL COMMENT ''车辆类型'', ADD COLUMN vehicle_size VARCHAR(64) NULL COMMENT ''车辆大小'', ADD COLUMN vehicle_plate VARCHAR(64) NULL COMMENT ''车牌号'', ADD COLUMN driver_name VARCHAR(128) NULL COMMENT ''司机或车队名称'', ADD COLUMN driver_phone VARCHAR(64) NULL COMMENT ''车队联系电话'', ADD COLUMN appointment_account VARCHAR(128) NULL COMMENT ''预约账号'', ADD COLUMN seller_name VARCHAR(128) NULL COMMENT ''卖家姓名'', ADD COLUMN total_weight_kg DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''总重量(千克)'', ADD COLUMN total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''总体积(立方米)'', ADD COLUMN planned_delivery_at DATETIME(3) NULL COMMENT ''预计派送时间'', ADD COLUMN dispatched_at DATETIME(3) NULL COMMENT ''实际派送时间'', ADD COLUMN expected_arrival_at DATETIME(3) NULL COMMENT ''预计到达时间'', ADD COLUMN signed_at DATETIME(3) NULL COMMENT ''签收时间'', ADD COLUMN remark VARCHAR(1000) NULL COMMENT ''派送备注''')); +PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt; +CREATE TABLE IF NOT EXISTS ll_delivery_fee (id BIGINT NOT NULL COMMENT '派送费用主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '派送单ID', fee_direction VARCHAR(16) NOT NULL DEFAULT 'PAYABLE' COMMENT '应收应付方向', fee_type VARCHAR(64) NOT NULL COMMENT '费用类型', currency VARCHAR(8) NOT NULL COMMENT '币种', amount DECIMAL(18,2) NOT NULL COMMENT '费用金额', remark VARCHAR(500) NULL COMMENT '费用备注', created_by VARCHAR(128) NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), KEY idx_ll_delivery_fee_order(customer_id,delivery_order_id,id)) ENGINE=InnoDB COMMENT='派送单费用明细'; +CREATE TABLE IF NOT EXISTS ll_delivery_operation (id BIGINT NOT NULL COMMENT '派送操作主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '派送单ID', operation_node VARCHAR(64) NOT NULL COMMENT '操作节点', operator_id VARCHAR(128) NULL COMMENT '操作人ID', operator_name VARCHAR(128) NULL COMMENT '操作人名称', result VARCHAR(500) NOT NULL COMMENT '处理结果', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', PRIMARY KEY(id), KEY idx_ll_delivery_operation_order(customer_id,delivery_order_id,created_at,id)) ENGINE=InnoDB COMMENT='派送单操作记录'; + CALL baiyue_firstleg.assert_unified_final_schema(); DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema; SELECT 'PASS' AS migration_status, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java index 960f108f1..24d02b3d3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java @@ -54,15 +54,25 @@ public class DeliveryTransferController { return ApiResponse.success(service.outboundDetail(scope(user, customerId), outboundNo, query)); } + @PostMapping("/outbound-orders/{outboundNo}/cancel") + @Operation(summary = "Cancel outbound and linked delivery, restoring inventory") + public ApiResponse cancelOutbound(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable String outboundNo, + @Valid @RequestBody DeliveryRequests.Cancel request) { + service.cancelOutbound(scope(user, customerId), operator(user), outboundNo, request); + return ApiResponse.success(null); + } + @GetMapping("/delivery-orders") public ApiResponse> deliveryPage( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid DeliveryRequests.Query query) { - return ApiResponse.success(service.deliveryPage(scope(user, customerId), query)); + return ApiResponse.success(service.deliveryPage(listScope(user, customerId), query)); } @GetMapping("/delivery-orders/{deliveryNo}") - public ApiResponse deliveryDetail( + public ApiResponse deliveryDetail( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @PathVariable String deliveryNo, @Valid DeliveryRequests.PalletQuery query) { return ApiResponse.success(service.deliveryDetail(scope(user, customerId), deliveryNo, query)); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java index 49cd60a3f..672d64a26 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java @@ -7,16 +7,48 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import jakarta.validation.Valid; import java.util.List; +import java.math.BigDecimal; +import java.time.LocalDateTime; public final class DeliveryRequests { private DeliveryRequests() {} public record Transfer(@NotBlank @Size(max = 255) String idempotencyKey, - @Size(max = 1000) String remark) {} + @NotNull LocalDateTime plannedDeliveryAt, + @NotBlank @Size(max = 32) String deliveryMethod, + @Size(max = 128) String waybillNo, + @Size(max = 255) String carrierName, + @Size(max = 64) String pickupMethod, + @Size(max = 64) String loadingType, + @Size(max = 64) String vehicleType, + @Size(max = 64) String vehicleSize, + @Size(max = 128) String driverName, + @Size(max = 64) String driverPhone, + @Size(max = 64) String vehiclePlate, + @Size(max = 128) String appointmentAccount, + @Size(max = 128) String sellerName, + @Size(max = 1000) String remark, + @Valid @Size(max = 20) List fees) { + public Transfer(String idempotencyKey, String remark) { + this(idempotencyKey, LocalDateTime.now(), "SELF_DELIVERY", null, null, null, null, + null, null, null, null, null, null, null, remark, List.of()); + } + } + + public record FeeItem(@NotBlank @Size(max = 64) String feeType, + @NotBlank @Size(max = 8) String currency, + @NotNull @Min(0) BigDecimal amount, + @Size(max = 500) String remark) {} public record Query(@Min(1) long page, @Min(1) @Max(200) long size, - String documentNo, String warehouseId, String status) { + String documentNo, String warehouseId, String status, + String dispatchNo, String waybillNo, String vehiclePlate, + String driverName, String deliveryMethod, + LocalDateTime plannedFrom, LocalDateTime plannedTo) { public Query { if (page == 0) page = 1; if (size == 0) size = 20; } + public Query(long page, long size, String documentNo, String warehouseId, String status) { + this(page, size, documentNo, warehouseId, status, null, null, null, null, null, null, null); + } } public record PalletQuery(@Min(1) long page, @Min(1) @Max(200) long size, @@ -32,4 +64,5 @@ public final class DeliveryRequests { @NotBlank @Size(max=128) String sku) {} public record PdaPick(@NotNull Long outboundPalletItemId, @NotNull Long inventoryPositionId, @NotNull @Min(1) Long packages, @NotBlank @Size(max=255) String idempotencyKey) {} + public record Cancel(@NotBlank @Size(max=1000) String reason) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java index 5a0e3e0d0..116cbd2ad 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java @@ -1,6 +1,8 @@ package com.baiyue.lastleg.delivery.dto; import java.time.LocalDateTime; +import java.math.BigDecimal; +import java.util.List; public final class DeliveryResponses { private DeliveryResponses() {} @@ -12,9 +14,31 @@ public final class DeliveryResponses { public record OutboundRow(Long id, String outboundNo, String dispatchNo, String overseasWarehouseId, String status, int palletCount, int packageCount, LocalDateTime createdAt) {} - public record DeliveryRow(Long id, String deliveryNo, String outboundNo, String dispatchNo, - String overseasWarehouseId, String status, int palletCount, - int packageCount, LocalDateTime createdAt) {} + public record DeliveryRow(Long id, String customerId, String customerName, String deliveryNo, + String outboundNo, String dispatchNo, String overseasWarehouseId, + String overseasWarehouseName, String status, String waybillNo, + String deliveryMethod, String carrierName, String pickupMethod, + String loadingType, String vehicleType, String vehicleSize, + String vehiclePlate, String driverName, String driverPhone, + String appointmentAccount, String sellerName, int palletCount, + int packageCount, BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, + LocalDateTime plannedDeliveryAt, LocalDateTime dispatchedAt, + LocalDateTime expectedArrivalAt, LocalDateTime signedAt, + String remark, LocalDateTime createdAt) {} + + public record DeliverySku(String inboundCode, String customerName, String destinationWarehouseCode, + String receivingAddress, String shipmentNo, String fbaTrackingNo, + String skuCode, String productName, int packageCount, + BigDecimal weightKg, BigDecimal volumeCbm) {} + + public record DeliveryFeeRow(Long id, String feeDirection, String feeType, String currency, + BigDecimal amount, String remark) {} + + public record DeliveryOperationRow(Long id, LocalDateTime operatedAt, String operationNode, + String operatorName, String result) {} + + public record DeliveryDetail(DeliveryRow order, List skus, + List fees, List operations) {} public record OutboundDetail(OutboundRow outbound, DeliveryRow delivery) {} public record PdaTask(Long id, String taskNo, String status, String outboundNo, LocalDateTime pushedAt, LocalDateTime completedAt) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryFee.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryFee.java new file mode 100644 index 000000000..adcf4cc51 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryFee.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.delivery.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@TableName("ll_delivery_fee") +public class DeliveryFee { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private String customerId; + private Long deliveryOrderId; + private String feeDirection; + private String feeType; + private String currency; + private BigDecimal amount; + private String remark; + private String createdBy; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOperation.java new file mode 100644 index 000000000..8cd075215 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOperation.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.delivery.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_delivery_operation") +public class DeliveryOperation { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private String customerId; + private Long deliveryOrderId; + private String operationNode; + private String operatorId; + private String operatorName; + private String result; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java index ee1b4f809..93dbf8173 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java @@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; +import java.math.BigDecimal; import java.time.LocalDateTime; /** Delivery document linked one-to-one to its outbound document and dispatch. */ @@ -20,9 +21,30 @@ public class DeliveryOrder { private Long dispatchOrderId; private String dispatchNo; private String overseasWarehouseId; + private String overseasWarehouseName; + private String customerName; private String status; + private String waybillNo; + private String deliveryMethod; + private String carrierName; + private String pickupMethod; + private String loadingType; + private String vehicleType; + private String vehicleSize; + private String vehiclePlate; + private String driverName; + private String driverPhone; + private String appointmentAccount; + private String sellerName; private Integer packageCount; private Integer palletCount; + private BigDecimal totalWeightKg; + private BigDecimal totalVolumeCbm; + private LocalDateTime plannedDeliveryAt; + private LocalDateTime dispatchedAt; + private LocalDateTime expectedArrivalAt; + private LocalDateTime signedAt; + private String remark; private String idempotencyKey; private String createdBy; private LocalDateTime createdAt; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryFeeMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryFeeMapper.java new file mode 100644 index 000000000..372d62e69 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryFeeMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.delivery.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.delivery.entity.DeliveryFee; +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 DeliveryFeeMapper extends BaseMapper { + @Select("SELECT * FROM ll_delivery_fee WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} ORDER BY id") + List findByDelivery(@Param("customerId") String customerId, + @Param("deliveryOrderId") Long deliveryOrderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOperationMapper.java new file mode 100644 index 000000000..1b34e5973 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOperationMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.delivery.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.delivery.entity.DeliveryOperation; +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 DeliveryOperationMapper extends BaseMapper { + @Select("SELECT * FROM ll_delivery_operation WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} ORDER BY created_at,id") + List findByDelivery(@Param("customerId") String customerId, + @Param("deliveryOrderId") Long deliveryOrderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPdaPickMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPdaPickMapper.java index 6f19262c1..c4d30223b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPdaPickMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPdaPickMapper.java @@ -3,9 +3,12 @@ package com.baiyue.lastleg.delivery.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.lastleg.delivery.entity.OutboundPdaPick; import org.apache.ibatis.annotations.*; +import java.util.List; @Mapper public interface OutboundPdaPickMapper extends BaseMapper { @Select("SELECT * FROM ll_outbound_pda_pick WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") OutboundPdaPick findByKey(@Param("customerId") String customerId, @Param("key") String key); + @Select("SELECT * FROM ll_outbound_pda_pick WHERE customer_id=#{customerId} AND task_id IN (SELECT id FROM ll_outbound_pda_task WHERE customer_id=#{customerId} AND outbound_order_id=#{outboundId})") + List findByOutbound(@Param("customerId") String customerId, @Param("outboundId") Long outboundId); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index a0b1a210b..0c9d26c6b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -7,9 +7,13 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.lastleg.delivery.dto.DeliveryRequests; import com.baiyue.lastleg.delivery.dto.DeliveryResponses; import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.delivery.entity.DeliveryFee; +import com.baiyue.lastleg.delivery.entity.DeliveryOperation; import com.baiyue.lastleg.delivery.entity.OutboundOrder; import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; +import com.baiyue.lastleg.delivery.mapper.DeliveryFeeMapper; +import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper; import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper; import com.baiyue.lastleg.dispatch.entity.DispatchItem; @@ -18,6 +22,7 @@ import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.entity.LastlegOrderDetail; import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper; @@ -37,6 +42,7 @@ import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper; import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; import jakarta.annotation.Resource; import org.springframework.dao.DuplicateKeyException; import org.springframework.http.HttpStatus; @@ -47,6 +53,7 @@ import org.springframework.web.server.ResponseStatusException; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.math.BigDecimal; import java.util.*; @Service @@ -56,6 +63,8 @@ public class DeliveryTransferService { @Resource private OutboundOrderMapper outboundOrders; @Resource private OutboundPalletItemMapper outboundPallets; @Resource private DeliveryOrderMapper deliveryOrders; + @Resource private DeliveryFeeMapper deliveryFees; + @Resource private DeliveryOperationMapper deliveryOperations; @Resource private OutboundPdaTaskMapper pdaTasks; @Resource private OutboundPdaPickMapper pdaPicks; @Resource private InventoryPositionMapper inventoryPositions; @@ -67,6 +76,7 @@ public class DeliveryTransferService { @Resource private InboundPlanLineMapper planLines; @Resource private LastlegOrderMapper tailOrders; @Resource private LastlegOrderStatusHistoryMapper histories; + @Resource private LastlegOrderDetailMapper tailOrderDetails; @Transactional public DeliveryResponses.TransferResult transfer(String customerId, String dispatchNo, String operatorId, @@ -133,10 +143,27 @@ public class DeliveryTransferService { delivery.setDeliveryNo("DEL" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + delivery.getId()); delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo()); delivery.setDispatchOrderId(dispatch.getId()); delivery.setDispatchNo(dispatch.getDispatchNo()); - delivery.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); delivery.setStatus("WAIT_DELIVERY"); + delivery.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); delivery.setOverseasWarehouseName(dispatch.getOverseasWarehouseName()); + LastlegOrder firstOrder = firstTailOrder(customerId, items); + delivery.setCustomerName(firstOrder == null ? null : firstOrder.getCustomerName()); delivery.setStatus("WAIT_DELIVERY"); + delivery.setWaybillNo(trim(request.waybillNo())); delivery.setDeliveryMethod(request.deliveryMethod().trim()); + delivery.setCarrierName(firstText(request.carrierName(), dispatch.getCarrierName())); delivery.setPickupMethod(trim(request.pickupMethod())); + delivery.setLoadingType(trim(request.loadingType())); delivery.setVehicleType(trim(request.vehicleType())); delivery.setVehicleSize(trim(request.vehicleSize())); + delivery.setVehiclePlate(firstText(request.vehiclePlate(), dispatch.getVehiclePlate())); delivery.setDriverName(firstText(request.driverName(), dispatch.getDriverName())); + delivery.setDriverPhone(firstText(request.driverPhone(), dispatch.getDriverPhone())); delivery.setAppointmentAccount(trim(request.appointmentAccount())); + delivery.setSellerName(trim(request.sellerName())); delivery.setPlannedDeliveryAt(request.plannedDeliveryAt()); delivery.setPackageCount(outbound.getPackageCount()); delivery.setPalletCount(outbound.getPalletCount()); + delivery.setTotalVolumeCbm(items.stream().map(DispatchItem::getVolumeCbm).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add)); + delivery.setTotalWeightKg(totalWeight(customerId, items)); delivery.setExpectedArrivalAt(dispatch.getEstimatedArrivalAt()); delivery.setRemark(trim(request.remark())); delivery.setIdempotencyKey(request.idempotencyKey().trim()); delivery.setCreatedBy(operatorId); delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery); + List feeInputs = request.fees() == null ? List.of() : request.fees(); + for (DeliveryRequests.FeeItem input : feeInputs) { + DeliveryFee fee = new DeliveryFee(); fee.setCustomerId(customerId); fee.setDeliveryOrderId(delivery.getId()); + fee.setFeeDirection("PAYABLE"); fee.setFeeType(input.feeType().trim()); fee.setCurrency(input.currency().trim().toUpperCase(Locale.ROOT)); + fee.setAmount(input.amount()); fee.setRemark(trim(input.remark())); fee.setCreatedBy(operatorId); fee.setCreatedAt(now); deliveryFees.insert(fee); + } + recordOperation(customerId, delivery.getId(), operatorId, "CREATE_DELIVERY", "CREATED", now); bindTailOrders(customerId, items, outbound.getOutboundNo(), delivery.getDeliveryNo(), request.remark(), now); return result(outbound, delivery, false); } @@ -194,6 +221,10 @@ public class DeliveryTransferService { for (DropshipCarton carton : cartons) { carton.setStatus("SHIPPED"); carton.setOutboundOrderNo(outbound.getOutboundNo()); carton.setVersion((carton.getVersion() == null ? 0 : carton.getVersion()) + 1); carton.setUpdatedAt(LocalDateTime.now()); dropshipCartons.updateById(carton); } OutboundPdaPick pick = new OutboundPdaPick(); pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId()); pick.setInventoryPositionId(request.inventoryPositionId()); pick.setPackages(request.packages()); pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick); item.setPickedPackageCount(value(item.getPickedPackageCount()) + request.packages().intValue()); item.setStatus(item.getPickedPackageCount() >= item.getPackageCount() ? "PICKED" : "PARTIAL_PICKED"); outboundPallets.updateById(item); + List pickedItems = outboundPallets.findByOutbound(customerId, outbound.getId()); + if (!pickedItems.isEmpty() && pickedItems.stream().allMatch(i -> "PICKED".equals(i.getStatus()))) { + outbound.setStatus("PICKED"); outbound.setUpdatedAt(LocalDateTime.now()); outboundOrders.updateById(outbound); + } task.setStatus("IN_PROGRESS"); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task); return taskResult(outbound, task); } @@ -207,11 +238,45 @@ public class DeliveryTransferService { task.setStatus("COMPLETED"); task.setCompletedAt(LocalDateTime.now()); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task); outbound.setStatus("OUTBOUNDED"); outbound.setUpdatedAt(LocalDateTime.now()); outboundOrders.updateById(outbound); return taskResult(outbound, task); } + @Transactional + public void cancelOutbound(String customerId, String operatorId, String outboundNo, DeliveryRequests.Cancel request) { + OutboundOrder outbound = outboundOrders.lockByNo(customerId, outboundNo); + if (outbound == null) throw notFound("Outbound order not found"); + if ("CANCELLED".equals(outbound.getStatus())) return; + List outboundItems = outboundPallets.findByOutbound(customerId, outbound.getId()); + if (outboundItems.isEmpty() || outboundItems.stream().anyMatch(i -> !"PICKED".equals(i.getStatus()))) + throw conflict("Outbound order can only be cancelled after picking is completed"); + DeliveryOrder existingDelivery = deliveryOrders.lockByOutbound(customerId, outbound.getId()); + if (existingDelivery != null && !"WAIT_DELIVERY".equals(existingDelivery.getStatus())) + throw conflict("Outbound order cannot be cancelled while delivery is in progress"); + LocalDateTime now = LocalDateTime.now(); + for (OutboundPdaPick pick : pdaPicks.findByOutbound(customerId, outbound.getId())) { + inventoryCommands.adjust(customerId, operatorId, + new InventoryCommandRequests.Adjustment(pick.getInventoryPositionId(), pick.getPackages(), "OUTBOUND_CANCEL", + outbound.getOutboundNo(), outbound.getOutboundNo() + ":CANCEL:" + pick.getId(), request.reason())); + OutboundPalletItem pickedItem = outboundItems.stream().filter(i -> Objects.equals(i.getId(), pick.getOutboundPalletItemId())).findFirst().orElse(null); + DropshipOrderProfile profile = dropshipProfiles.findPosition(customerId, pick.getInventoryPositionId()); + if (profile != null && pickedItem != null) dropshipSkuInventory.restore(customerId, profile.getId(), pickedItem.getSkuCode(), pick.getPackages()); + } + dropshipCartons.restoreOutbound(customerId, outbound.getOutboundNo()); + DeliveryOrder delivery = existingDelivery; + if (delivery != null) { delivery.setStatus("CANCELLED"); delivery.setUpdatedAt(now); deliveryOrders.updateById(delivery); } + outbound.setStatus("CANCELLED"); outbound.setUpdatedAt(now); outboundOrders.updateById(outbound); + for (OutboundPalletItem item : outboundItems) { item.setStatus("CANCELLED"); outboundPallets.updateById(item); } + if (delivery != null) { + for (LastlegOrder order : tailOrders.selectList(new LambdaQueryWrapper().eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getOutboundOrderNo, outboundNo))) { + String from = order.getCurrentStatus(); order.setCurrentStatus("RECEIVED"); order.setSubOrderStatus(null); order.setOutboundOrderNo(null); order.setDeliveryOrderNo(null); order.setUpdatedAt(now); tailOrders.updateById(order); + LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setOrderId(order.getId()); history.setFromStatus(from); history.setToStatus("RECEIVED"); history.setEventId("CANCEL_OUTBOUND:" + outboundNo); history.setRemark(request.reason().trim()); history.setCreatedAt(now); histories.insert(history); + } + } + } + public List pdaTasks(String customerId, String status) { return pdaTasks.list(customerId, StringUtils.hasText(status) ? status : null).stream().map(task -> taskResult(outboundOrders.selectById(task.getOutboundOrderId()), task)).toList(); } private DeliveryResponses.PdaTask taskResult(OutboundOrder outbound, OutboundPdaTask task) { return new DeliveryResponses.PdaTask(task.getId(), task.getTaskNo(), task.getStatus(), outbound.getOutboundNo(), task.getPushedAt(), task.getCompletedAt()); } private String outboundNo(Long id) { OutboundOrder value = outboundOrders.selectById(id); return value == null ? "" : value.getOutboundNo(); } private int value(Integer value) { return value == null ? 0 : value; } + private long value(Long value) { return value == null ? 0L : value; } public PageResult outboundPage(String customerId, DeliveryRequests.Query query) { Page page = outboundOrders.selectPage(new Page<>(query.page(), query.size()), @@ -233,21 +298,35 @@ public class DeliveryTransferService { public PageResult deliveryPage(String customerId, DeliveryRequests.Query query) { Page page = deliveryOrders.selectPage(new Page<>(query.page(), query.size()), - new LambdaQueryWrapper().eq(DeliveryOrder::getCustomerId, customerId) + new LambdaQueryWrapper().eq(StringUtils.hasText(customerId), DeliveryOrder::getCustomerId, customerId) .like(StringUtils.hasText(query.documentNo()), DeliveryOrder::getDeliveryNo, query.documentNo()) .eq(StringUtils.hasText(query.warehouseId()), DeliveryOrder::getOverseasWarehouseId, query.warehouseId()) .eq(StringUtils.hasText(query.status()), DeliveryOrder::getStatus, query.status()) + .like(StringUtils.hasText(query.dispatchNo()), DeliveryOrder::getDispatchNo, query.dispatchNo()) + .like(StringUtils.hasText(query.waybillNo()), DeliveryOrder::getWaybillNo, query.waybillNo()) + .like(StringUtils.hasText(query.vehiclePlate()), DeliveryOrder::getVehiclePlate, query.vehiclePlate()) + .like(StringUtils.hasText(query.driverName()), DeliveryOrder::getDriverName, query.driverName()) + .eq(StringUtils.hasText(query.deliveryMethod()), DeliveryOrder::getDeliveryMethod, query.deliveryMethod()) + .ge(query.plannedFrom() != null, DeliveryOrder::getPlannedDeliveryAt, query.plannedFrom()) + .le(query.plannedTo() != null, DeliveryOrder::getPlannedDeliveryAt, query.plannedTo()) .orderByDesc(DeliveryOrder::getCreatedAt)); return page(page, page.getRecords().stream().map(this::deliveryRow).toList()); } - public DeliveryResponses.OutboundDetail deliveryDetail(String customerId, String deliveryNo, + public DeliveryResponses.DeliveryDetail deliveryDetail(String customerId, String deliveryNo, DeliveryRequests.PalletQuery query) { DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo); if (delivery == null) throw notFound("Delivery order not found"); - OutboundOrder outbound = outboundOrders.selectById(delivery.getOutboundOrderId()); - if (outbound == null || !customerId.equals(outbound.getCustomerId())) throw notFound("Outbound order not found"); - return outboundDetail(customerId, outbound.getOutboundNo(), query); + List items = dispatchItems.findByDispatchOrder(customerId, delivery.getDispatchOrderId()); + List orderIds = items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).distinct().toList(); + Map ordersById = orderIds.isEmpty() ? Map.of() : tailOrders.selectBatchIds(orderIds).stream() + .filter(order -> customerId.equals(order.getCustomerId())).collect(java.util.stream.Collectors.toMap(LastlegOrder::getId, order -> order)); + List details = orderIds.isEmpty() ? List.of() : tailOrderDetails.selectList( + new LambdaQueryWrapper().eq(LastlegOrderDetail::getCustomerId, customerId).in(LastlegOrderDetail::getOrderId, orderIds)); + List skus = details.stream().map(detail -> sku(detail, ordersById.get(detail.getOrderId()))).toList(); + return new DeliveryResponses.DeliveryDetail(deliveryRow(delivery), skus, + deliveryFees.findByDelivery(customerId, delivery.getId()).stream().map(this::feeRow).toList(), + deliveryOperations.findByDelivery(customerId, delivery.getId()).stream().map(this::operationRow).toList()); } private void bindTailOrders(String customerId, List items, String outboundNo, String deliveryNo, @@ -290,7 +369,15 @@ public class DeliveryTransferService { d.getDeliveryNo(), d.getStatus(), o.getPalletCount(), o.getPackageCount(), replayed); } private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getCreatedAt()); } - private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getStatus(), d.getPalletCount(), d.getPackageCount(), d.getCreatedAt()); } + private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt()); } + private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); } + private DeliveryResponses.DeliveryFeeRow feeRow(DeliveryFee f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getFeeDirection(), f.getFeeType(), f.getCurrency(), f.getAmount(), f.getRemark()); } + private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); } + private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); } + private LastlegOrder firstTailOrder(String customerId, List items) { return items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).map(tailOrders::selectById).filter(Objects::nonNull).filter(order -> customerId.equals(order.getCustomerId())).findFirst().orElse(null); } + private BigDecimal totalWeight(String customerId, List items) { List ids = items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).distinct().toList(); if (ids.isEmpty()) return BigDecimal.ZERO; return tailOrderDetails.selectList(new LambdaQueryWrapper().eq(LastlegOrderDetail::getCustomerId, customerId).in(LastlegOrderDetail::getOrderId, ids)).stream().map(LastlegOrderDetail::getGrossWeightKg).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); } + private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private static String firstText(String primary, String fallback) { return StringUtils.hasText(primary) ? primary.trim() : trim(fallback); } private static PageResult page(Page page, List records) { return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); } private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonMapper.java index cb57e1de3..89ed7542a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonMapper.java @@ -14,4 +14,6 @@ public interface DropshipCartonMapper extends BaseMapper { List listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId); @Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND profile_id=#{profileId} AND sku_code=#{sku} AND status='AVAILABLE' ORDER BY carton_sequence LIMIT #{packages} FOR UPDATE") List lockAvailable(@Param("customerId") String customerId, @Param("profileId") Long profileId, @Param("sku") String sku, @Param("packages") long packages); + @Update("UPDATE ll_dropship_carton SET status='AVAILABLE', outbound_order_no=NULL, updated_at=NOW(3) WHERE customer_id=#{customerId} AND outbound_order_no=#{outboundNo} AND status='SHIPPED'") + int restoreOutbound(@Param("customerId") String customerId, @Param("outboundNo") String outboundNo); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipSkuInventoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipSkuInventoryMapper.java index 3c97736f1..1ea85d9ed 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipSkuInventoryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipSkuInventoryMapper.java @@ -13,4 +13,6 @@ public interface DropshipSkuInventoryMapper extends BaseMapper listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId); @Update("UPDATE ll_dropship_sku_inventory SET package_count=package_count-#{packages},updated_at=NOW(3) WHERE customer_id=#{customerId} AND profile_id=#{profileId} AND sku_code=#{sku} AND package_count>=#{packages}") int deduct(@Param("customerId") String customerId, @Param("profileId") Long profileId, @Param("sku") String sku, @Param("packages") long packages); + @Update("UPDATE ll_dropship_sku_inventory SET package_count=package_count+#{packages},updated_at=NOW(3) WHERE customer_id=#{customerId} AND profile_id=#{profileId} AND sku_code=#{sku}") + int restore(@Param("customerId") String customerId, @Param("profileId") Long profileId, @Param("sku") String sku, @Param("packages") long packages); } diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 26cd0f694..e85e97965 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -165,6 +165,28 @@ CREATE TABLE IF NOT EXISTS ll_order_operation_log ( KEY idx_ll_order_operation_customer_order(customer_id,order_id,created_at) ) ENGINE=InnoDB COMMENT='尾程订单操作记录'; +-- 派送单列表与详情快照、转派送费用及操作记录。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='overseas_warehouse_name'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT ''海外仓名称快照'' AFTER overseas_warehouse_id')); +PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='customer_name'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN customer_name VARCHAR(255) NULL COMMENT ''客户名称快照'' AFTER overseas_warehouse_name')); +PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='waybill_no'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN waybill_no VARCHAR(128) NULL COMMENT ''承运运单号'', ADD COLUMN delivery_method VARCHAR(32) NULL COMMENT ''派送方式'', ADD COLUMN carrier_name VARCHAR(255) NULL COMMENT ''派送承运商'', ADD COLUMN pickup_method VARCHAR(64) NULL COMMENT ''提货方式'', ADD COLUMN loading_type VARCHAR(64) NULL COMMENT ''装车类型'', ADD COLUMN vehicle_type VARCHAR(64) NULL COMMENT ''车辆类型'', ADD COLUMN vehicle_size VARCHAR(64) NULL COMMENT ''车辆大小'', ADD COLUMN vehicle_plate VARCHAR(64) NULL COMMENT ''车牌号'', ADD COLUMN driver_name VARCHAR(128) NULL COMMENT ''司机或车队名称'', ADD COLUMN driver_phone VARCHAR(64) NULL COMMENT ''车队联系电话'', ADD COLUMN appointment_account VARCHAR(128) NULL COMMENT ''预约账号'', ADD COLUMN seller_name VARCHAR(128) NULL COMMENT ''卖家姓名'' AFTER status')); +PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='total_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN total_weight_kg DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''总重量(千克)'', ADD COLUMN total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''总体积(立方米)'', ADD COLUMN planned_delivery_at DATETIME(3) NULL COMMENT ''预计派送时间'', ADD COLUMN dispatched_at DATETIME(3) NULL COMMENT ''实际派送时间'', ADD COLUMN expected_arrival_at DATETIME(3) NULL COMMENT ''预计到达时间'', ADD COLUMN signed_at DATETIME(3) NULL COMMENT ''签收时间'', ADD COLUMN remark VARCHAR(1000) NULL COMMENT ''派送备注'' AFTER pallet_count')); +PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt; +CREATE TABLE IF NOT EXISTS ll_delivery_fee ( + id BIGINT NOT NULL COMMENT '派送费用主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '派送单ID', + fee_direction VARCHAR(16) NOT NULL DEFAULT 'PAYABLE' COMMENT '应收应付方向', fee_type VARCHAR(64) NOT NULL COMMENT '费用类型', currency VARCHAR(8) NOT NULL COMMENT '币种', + amount DECIMAL(18,2) NOT NULL COMMENT '费用金额', remark VARCHAR(500) NULL COMMENT '费用备注', created_by VARCHAR(128) NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), KEY idx_ll_delivery_fee_order(customer_id,delivery_order_id,id) +) ENGINE=InnoDB COMMENT='派送单费用明细'; +CREATE TABLE IF NOT EXISTS ll_delivery_operation ( + id BIGINT NOT NULL COMMENT '派送操作主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '派送单ID', + operation_node VARCHAR(64) NOT NULL COMMENT '操作节点', operator_id VARCHAR(128) NULL COMMENT '操作人ID', operator_name VARCHAR(128) NULL COMMENT '操作人名称', + result VARCHAR(500) NOT NULL COMMENT '处理结果', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY(id), KEY idx_ll_delivery_operation_order(customer_id,delivery_order_id,created_at,id) +) ENGINE=InnoDB COMMENT='派送单操作记录'; + CREATE TABLE IF NOT EXISTS ll_event_conflict ( id BIGINT NOT NULL COMMENT '冲突记录主键', event_id VARCHAR(128) NOT NULL COMMENT '冲突事件 ID', customer_id BIGINT NOT NULL COMMENT '租户 ID', conflict_type VARCHAR(64) NOT NULL COMMENT '冲突类型', diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java new file mode 100644 index 000000000..fd4e31675 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java @@ -0,0 +1,71 @@ +package com.baiyue.lastleg.delivery; + +import com.baiyue.common.web.LongIdJacksonConfiguration; +import com.baiyue.lastleg.delivery.dto.DeliveryRequests; +import com.baiyue.lastleg.delivery.dto.DeliveryResponses; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("派送单长整型 ID JSON 契约") +class DeliveryLongIdJsonContractTest { + + private static final long DELIVERY_ID = 19_000_000_000_000_001L; + private static final long FEE_ID = 19_000_000_000_000_002L; + private static final long OPERATION_ID = 19_000_000_000_000_003L; + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of( + JacksonAutoConfiguration.class, LongIdJacksonConfiguration.class)); + + @Test + void deliveryResponseIdsShouldBeSerializedAsJsonStrings() { + contextRunner.run(context -> { + ObjectMapper objectMapper = context.getBean(ObjectMapper.class); + DeliveryResponses.DeliveryDetail detail = new DeliveryResponses.DeliveryDetail( + deliveryRow(), + List.of(), + List.of(new DeliveryResponses.DeliveryFeeRow( + FEE_ID, "RECEIVABLE", "DELIVERY_FEE", "USD", null, null)), + List.of(new DeliveryResponses.DeliveryOperationRow( + OPERATION_ID, null, "CREATED", "tester", "success"))); + + JsonNode json = objectMapper.readTree(objectMapper.writeValueAsString(detail)); + + assertThat(json.at("/order/id").isTextual()).isTrue(); + assertThat(json.at("/order/id").textValue()).isEqualTo(Long.toString(DELIVERY_ID)); + assertThat(json.at("/fees/0/id").textValue()).isEqualTo(Long.toString(FEE_ID)); + assertThat(json.at("/operations/0/id").textValue()).isEqualTo(Long.toString(OPERATION_ID)); + }); + } + + @Test + void quotedRequestIdShouldBeDeserializedToLong() { + contextRunner.run(context -> { + ObjectMapper objectMapper = context.getBean(ObjectMapper.class); + + DeliveryRequests.OneClickItem item = objectMapper.readValue( + "{\"inventoryPositionId\":\"19000000000000001\",\"packages\":\"2\",\"sku\":\"SKU-1\"}", + DeliveryRequests.OneClickItem.class); + + assertThat(item.inventoryPositionId()).isEqualTo(DELIVERY_ID); + assertThat(item.packages()).isEqualTo(2L); + }); + } + + private DeliveryResponses.DeliveryRow deliveryRow() { + return new DeliveryResponses.DeliveryRow( + DELIVERY_ID, + null, null, "PS-1", null, null, null, null, "CREATED", null, + null, null, null, null, null, null, null, null, null, null, null, + 0, 0, null, null, null, null, null, null, null, null); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java index 29cbef8a2..7b4d63944 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -7,6 +7,8 @@ import com.baiyue.lastleg.delivery.entity.DeliveryOrder; import com.baiyue.lastleg.delivery.entity.OutboundOrder; import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; +import com.baiyue.lastleg.delivery.mapper.DeliveryFeeMapper; +import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper; import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper; import com.baiyue.lastleg.dispatch.entity.DispatchItem; @@ -19,6 +21,7 @@ import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -47,10 +50,13 @@ class DeliveryTransferServiceTest { @Mock OutboundOrderMapper outboundOrders; @Mock OutboundPalletItemMapper outboundPallets; @Mock DeliveryOrderMapper deliveryOrders; + @Mock DeliveryFeeMapper deliveryFees; + @Mock DeliveryOperationMapper deliveryOperations; @Mock InboundPlanPalletAllocationMapper planAllocations; @Mock InboundPlanLineMapper planLines; @Mock LastlegOrderMapper tailOrders; @Mock LastlegOrderStatusHistoryMapper histories; + @Mock LastlegOrderDetailMapper tailOrderDetails; private DeliveryTransferService service; @@ -62,10 +68,13 @@ class DeliveryTransferServiceTest { ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders); ReflectionTestUtils.setField(service, "outboundPallets", outboundPallets); ReflectionTestUtils.setField(service, "deliveryOrders", deliveryOrders); + ReflectionTestUtils.setField(service, "deliveryFees", deliveryFees); + ReflectionTestUtils.setField(service, "deliveryOperations", deliveryOperations); ReflectionTestUtils.setField(service, "planAllocations", planAllocations); ReflectionTestUtils.setField(service, "planLines", planLines); ReflectionTestUtils.setField(service, "tailOrders", tailOrders); ReflectionTestUtils.setField(service, "histories", histories); + ReflectionTestUtils.setField(service, "tailOrderDetails", tailOrderDetails); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java index 737b4e848..6746ae723 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -111,8 +111,8 @@ class LastlegOrderServiceTest { assertEquals("100", fee.getCustomerId()); assertEquals("清关费", fee.getFeeItem()); assertEquals(new BigDecimal("420.00"), fee.getAmount()); - verify(fees).insert(any()); - verify(operations).insert(any()); + verify(fees).insert(any(com.baiyue.lastleg.entity.LastlegOrderFee.class)); + verify(operations).insert(any(com.baiyue.lastleg.entity.LastlegOrderOperationLog.class)); } @Test diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index 58a4dd551..5f5fe442e 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -3842,3 +3842,9 @@ CREATE TABLE IF NOT EXISTS ll_order_operation_log ( created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', PRIMARY KEY(id), KEY idx_ll_order_operation_customer_order(customer_id,order_id,created_at) ) ENGINE=InnoDB COMMENT='尾程订单操作记录'; + +-- 派送单列表与详情快照、转派送费用及操作记录。 +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='overseas_warehouse_name'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT ''海外仓名称快照'', ADD COLUMN customer_name VARCHAR(255) NULL COMMENT ''客户名称快照'', ADD COLUMN waybill_no VARCHAR(128) NULL COMMENT ''承运运单号'', ADD COLUMN delivery_method VARCHAR(32) NULL COMMENT ''派送方式'', ADD COLUMN carrier_name VARCHAR(255) NULL COMMENT ''派送承运商'', ADD COLUMN pickup_method VARCHAR(64) NULL COMMENT ''提货方式'', ADD COLUMN loading_type VARCHAR(64) NULL COMMENT ''装车类型'', ADD COLUMN vehicle_type VARCHAR(64) NULL COMMENT ''车辆类型'', ADD COLUMN vehicle_size VARCHAR(64) NULL COMMENT ''车辆大小'', ADD COLUMN vehicle_plate VARCHAR(64) NULL COMMENT ''车牌号'', ADD COLUMN driver_name VARCHAR(128) NULL COMMENT ''司机或车队名称'', ADD COLUMN driver_phone VARCHAR(64) NULL COMMENT ''车队联系电话'', ADD COLUMN appointment_account VARCHAR(128) NULL COMMENT ''预约账号'', ADD COLUMN seller_name VARCHAR(128) NULL COMMENT ''卖家姓名'', ADD COLUMN total_weight_kg DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''总重量(千克)'', ADD COLUMN total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''总体积(立方米)'', ADD COLUMN planned_delivery_at DATETIME(3) NULL COMMENT ''预计派送时间'', ADD COLUMN dispatched_at DATETIME(3) NULL COMMENT ''实际派送时间'', ADD COLUMN expected_arrival_at DATETIME(3) NULL COMMENT ''预计到达时间'', ADD COLUMN signed_at DATETIME(3) NULL COMMENT ''签收时间'', ADD COLUMN remark VARCHAR(1000) NULL COMMENT ''派送备注''')); +PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt; +CREATE TABLE IF NOT EXISTS ll_delivery_fee (id BIGINT NOT NULL COMMENT '派送费用主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '派送单ID', fee_direction VARCHAR(16) NOT NULL DEFAULT 'PAYABLE' COMMENT '应收应付方向', fee_type VARCHAR(64) NOT NULL COMMENT '费用类型', currency VARCHAR(8) NOT NULL COMMENT '币种', amount DECIMAL(18,2) NOT NULL COMMENT '费用金额', remark VARCHAR(500) NULL COMMENT '费用备注', created_by VARCHAR(128) NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), KEY idx_ll_delivery_fee_order(customer_id,delivery_order_id,id)) ENGINE=InnoDB COMMENT='派送单费用明细'; +CREATE TABLE IF NOT EXISTS ll_delivery_operation (id BIGINT NOT NULL COMMENT '派送操作主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '派送单ID', operation_node VARCHAR(64) NOT NULL COMMENT '操作节点', operator_id VARCHAR(128) NULL COMMENT '操作人ID', operator_name VARCHAR(128) NULL COMMENT '操作人名称', result VARCHAR(500) NOT NULL COMMENT '处理结果', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', PRIMARY KEY(id), KEY idx_ll_delivery_operation_order(customer_id,delivery_order_id,created_at,id)) ENGINE=InnoDB COMMENT='派送单操作记录'; From e31fd8caa2b043360e7eba7755751b8efe088cda Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Wed, 26 Aug 2026 14:36:04 +0800 Subject: [PATCH 08/24] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 29 +++ .../controller/ExceptionOrderController.java | 75 ++++++ .../dto/ExceptionOrderRequests.java | 34 +++ .../dto/ExceptionOrderResponses.java | 25 ++ .../exceptionorder/entity/ExceptionOrder.java | 42 +++ .../mapper/ExceptionOrderMapper.java | 15 ++ .../service/ExceptionOrderService.java | 246 ++++++++++++++++++ .../src/main/resources/db/lastleg.sql | 17 ++ .../service/ExceptionOrderServiceTest.java | 74 ++++++ scripts/db/incremental.sql | 17 ++ 10 files changed, 574 insertions(+) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/ExceptionOrderController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrder.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderServiceTest.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 982373ef2..3d6f4f558 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -16569,6 +16569,35 @@ PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE CREATE TABLE IF NOT EXISTS ll_delivery_fee (id BIGINT NOT NULL COMMENT '派送费用主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '派送单ID', fee_direction VARCHAR(16) NOT NULL DEFAULT 'PAYABLE' COMMENT '应收应付方向', fee_type VARCHAR(64) NOT NULL COMMENT '费用类型', currency VARCHAR(8) NOT NULL COMMENT '币种', amount DECIMAL(18,2) NOT NULL COMMENT '费用金额', remark VARCHAR(500) NULL COMMENT '费用备注', created_by VARCHAR(128) NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), KEY idx_ll_delivery_fee_order(customer_id,delivery_order_id,id)) ENGINE=InnoDB COMMENT='派送单费用明细'; CREATE TABLE IF NOT EXISTS ll_delivery_operation (id BIGINT NOT NULL COMMENT '派送操作主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '派送单ID', operation_node VARCHAR(64) NOT NULL COMMENT '操作节点', operator_id VARCHAR(128) NULL COMMENT '操作人ID', operator_name VARCHAR(128) NULL COMMENT '操作人名称', result VARCHAR(500) NOT NULL COMMENT '处理结果', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', PRIMARY KEY(id), KEY idx_ll_delivery_operation_order(customer_id,delivery_order_id,created_at,id)) ENGINE=InnoDB COMMENT='派送单操作记录'; +CREATE TABLE IF NOT EXISTS ll_exception_order ( + id BIGINT NOT NULL, customer_id VARCHAR(128) NOT NULL, exception_no VARCHAR(64) NOT NULL, + exception_type VARCHAR(32) NOT NULL, status VARCHAR(32) NOT NULL DEFAULT 'PENDING', reason VARCHAR(1000) NOT NULL, + last_mile_order_id BIGINT NULL, last_mile_order_no VARCHAR(64) NULL, inbound_order_id BIGINT NULL, inbound_code VARCHAR(128) NULL, + delivery_order_id BIGINT NULL, delivery_no VARCHAR(64) NULL, outbound_no VARCHAR(64) NULL, + customer_name VARCHAR(255) NULL, overseas_warehouse_id VARCHAR(128) NULL, overseas_warehouse_name VARCHAR(255) NULL, + sku_code VARCHAR(128) NULL, package_count INT NULL, return_direction VARCHAR(32) NULL, return_logistics_no VARCHAR(128) NULL, + handling_action VARCHAR(500) NULL, handler_id VARCHAR(128) NULL, handler_name VARCHAR(128) NULL, + source_type VARCHAR(32) NOT NULL DEFAULT 'MANUAL', created_by VARCHAR(128) NULL, + exception_at DATETIME(3) NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), UNIQUE KEY uk_ll_exception_no(customer_id,exception_no), + UNIQUE KEY uk_ll_exception_delivery_return(customer_id,delivery_order_id,exception_type), + KEY idx_ll_exception_list(customer_id,status,exception_at), KEY idx_ll_exception_tail(customer_id,last_mile_order_no), + KEY idx_ll_exception_inbound(customer_id,inbound_code) +) ENGINE=InnoDB COMMENT='海外仓异常订单'; + +INSERT INTO baiyue_iam.iam_permission + (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted) +VALUES + (5107070,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:exception-order:list','海外异常订单','/overseas-warehouse/exception-orders','GET',770,'ACTIVE',1,0), + (5107071,0,'PLATFORM','BUTTON',5107070,'platform:overseas-warehouse:exception-order:create','新增海外异常订单',NULL,'POST',771,'ACTIVE',1,0), + (5107072,0,'PLATFORM','BUTTON',5107070,'platform:overseas-warehouse:delivery:return','派送单标记退货',NULL,'POST',772,'ACTIVE',1,0) +ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),route_path=VALUES(route_path),http_method=VALUES(http_method),sort_no=VALUES(sort_no),status='ACTIVE',built_in=1,deleted=0; +INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id,role_id,permission_id,created_at) +SELECT 0,role.id,permission.id,NOW(3) FROM baiyue_iam.iam_role role +JOIN baiyue_iam.iam_permission permission ON permission.id BETWEEN 5107070 AND 5107072 AND permission.tenant_id=0 +WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN' AND role.status='ACTIVE' AND role.deleted=0; + CALL baiyue_firstleg.assert_unified_final_schema(); DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema; SELECT 'PASS' AS migration_status, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/ExceptionOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/ExceptionOrderController.java new file mode 100644 index 000000000..b6ef71087 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/ExceptionOrderController.java @@ -0,0 +1,75 @@ +package com.baiyue.lastleg.exceptionorder.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests; +import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderResponses; +import com.baiyue.lastleg.exceptionorder.service.ExceptionOrderService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +@RestController +@ConditionalOnProperty(name = "lastleg.future-outbound-api.enabled", havingValue = "true") +@RequestMapping("/api/platform/v1/last-leg") +public class ExceptionOrderController { + @Resource private ExceptionOrderService service; + + @GetMapping("/exception-orders") + public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, + @Valid ExceptionOrderRequests.Query query) { + return ApiResponse.success(service.page(scope(user), query)); + } + + @GetMapping("/exception-orders/resolve") + public ApiResponse resolve(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @RequestParam(required = false) String inboundCode, + @RequestParam(required = false) String lastMileOrderNo) { + return ApiResponse.success(service.resolve(scope(user), customerId, inboundCode, lastMileOrderNo)); + } + + @PostMapping("/exception-orders") + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ExceptionOrderRequests.Create request) { + return ApiResponse.success(service.create(scope(user), operator(user), request)); + } + + @GetMapping("/delivery-orders/{deliveryNo}/return-inbound-options") + public ApiResponse> returnOptions(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable String deliveryNo) { + return ApiResponse.success(service.returnCandidates(requiredScope(user, customerId), deliveryNo)); + } + + @PostMapping("/delivery-orders/{deliveryNo}/mark-return") + public ApiResponse markReturn(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable String deliveryNo, + @Valid @RequestBody ExceptionOrderRequests.MarkReturn request) { + return ApiResponse.success(service.markReturn(requiredScope(user, customerId), operator(user), deliveryNo, request)); + } + + private String scope(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + return "PLATFORM".equals(user.appCode()) && user.tenantId() == 0L ? null : String.valueOf(user.tenantId()); + } + + private String requiredScope(AuthenticatedUser user, String requestedCustomer) { + String own = scope(user); + if (own != null) return own; + if (requestedCustomer == null || requestedCustomer.isBlank()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择客户"); + return requestedCustomer.trim(); + } + + private String operator(AuthenticatedUser user) { + return user.userId() == null ? user.username() : String.valueOf(user.userId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderRequests.java new file mode 100644 index 000000000..8af47a000 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderRequests.java @@ -0,0 +1,34 @@ +package com.baiyue.lastleg.exceptionorder.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; + +public final class ExceptionOrderRequests { + private ExceptionOrderRequests() {} + + public record Query(@Min(1) long page, @Min(1) @Max(200) long size, + String keyword, String exceptionType, String status, + String customerId, String warehouseId, + LocalDateTime createdFrom, LocalDateTime createdTo) { + public Query { if (page == 0) page = 1; if (size == 0) size = 20; } + } + + public record Create(String customerId, @Size(max = 128) String inboundCode, + @Size(max = 64) String lastMileOrderNo, + @NotBlank @Size(max = 32) String exceptionType, + @NotBlank @Size(max = 1000) String reason, + @Min(1) Integer packageCount, + @Size(max = 128) String skuCode) {} + + public record MarkReturn(@NotNull Long inboundOrderId, + @NotBlank @Size(max = 1000) String reason, + @NotBlank @Size(max = 32) String returnDirection, + @Size(max = 128) String returnLogisticsNo, + @Min(1) Integer packageCount, + @Size(max = 128) String skuCode) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java new file mode 100644 index 000000000..5847820c8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.exceptionorder.dto; + +import java.time.LocalDateTime; + +public final class ExceptionOrderResponses { + private ExceptionOrderResponses() {} + + public record Row(Long id, String exceptionNo, String exceptionType, String status, + String reason, String customerId, String customerName, + String overseasWarehouseId, String overseasWarehouseName, + Long lastMileOrderId, String lastMileOrderNo, + Long inboundOrderId, String inboundCode, + Long deliveryOrderId, String deliveryNo, String outboundNo, + String skuCode, Integer packageCount, String returnDirection, + String returnLogisticsNo, String handlingAction, + String handlerName, LocalDateTime exceptionAt, LocalDateTime updatedAt) {} + + public record Relation(Long lastMileOrderId, String lastMileOrderNo, + Long inboundOrderId, String inboundCode, + String customerId, String customerName, + String overseasWarehouseId, String overseasWarehouseName, + String deliveryNo, String outboundNo, Integer availablePackages) {} + + public record Created(Long id, String exceptionNo, String status) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrder.java new file mode 100644 index 000000000..b60908bb5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrder.java @@ -0,0 +1,42 @@ +package com.baiyue.lastleg.exceptionorder.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_exception_order") +public class ExceptionOrder { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String customerId; + private String exceptionNo; + private String exceptionType; + private String status; + private String reason; + private Long lastMileOrderId; + private String lastMileOrderNo; + private Long inboundOrderId; + private String inboundCode; + private Long deliveryOrderId; + private String deliveryNo; + private String outboundNo; + private String customerName; + private String overseasWarehouseId; + private String overseasWarehouseName; + private String skuCode; + private Integer packageCount; + private String returnDirection; + private String returnLogisticsNo; + private String handlingAction; + private String handlerId; + private String handlerName; + private String sourceType; + private String createdBy; + private LocalDateTime exceptionAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderMapper.java new file mode 100644 index 000000000..39c25ca45 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderMapper.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.exceptionorder.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrder; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface ExceptionOrderMapper extends BaseMapper { + @Select("SELECT * FROM ll_exception_order WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} " + + "AND exception_type='RETURN' LIMIT 1") + ExceptionOrder findDeliveryReturn(@Param("customerId") String customerId, + @Param("deliveryOrderId") Long deliveryOrderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java new file mode 100644 index 000000000..c2c1ca972 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java @@ -0,0 +1,246 @@ +package com.baiyue.lastleg.exceptionorder.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.delivery.entity.DeliveryOperation; +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper; +import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; +import com.baiyue.lastleg.entity.LastlegInboundOrder; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests; +import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderResponses; +import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrder; +import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderMapper; +import com.baiyue.lastleg.mapper.LastlegInboundOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Objects; + +@Service +public class ExceptionOrderService { + @Resource private ExceptionOrderMapper exceptionOrders; + @Resource private LastlegOrderMapper lastMileOrders; + @Resource private LastlegInboundOrderMapper inboundOrders; + @Resource private DeliveryOrderMapper deliveryOrders; + @Resource private DeliveryOperationMapper deliveryOperations; + + public PageResult page(String scopedCustomerId, ExceptionOrderRequests.Query query) { + String customerId = customerScope(scopedCustomerId, query.customerId()); + Page result = exceptionOrders.selectPage(new Page<>(query.page(), query.size()), + new LambdaQueryWrapper() + .eq(StringUtils.hasText(customerId), ExceptionOrder::getCustomerId, customerId) + .and(StringUtils.hasText(query.keyword()), wrapper -> wrapper + .like(ExceptionOrder::getExceptionNo, trim(query.keyword())) + .or().like(ExceptionOrder::getLastMileOrderNo, trim(query.keyword())) + .or().like(ExceptionOrder::getInboundCode, trim(query.keyword())) + .or().like(ExceptionOrder::getDeliveryNo, trim(query.keyword())) + .or().like(ExceptionOrder::getCustomerName, trim(query.keyword())) + .or().like(ExceptionOrder::getReason, trim(query.keyword()))) + .eq(StringUtils.hasText(query.exceptionType()), ExceptionOrder::getExceptionType, trim(query.exceptionType())) + .eq(StringUtils.hasText(query.status()), ExceptionOrder::getStatus, trim(query.status())) + .eq(StringUtils.hasText(query.warehouseId()), ExceptionOrder::getOverseasWarehouseId, trim(query.warehouseId())) + .ge(query.createdFrom() != null, ExceptionOrder::getExceptionAt, query.createdFrom()) + .le(query.createdTo() != null, ExceptionOrder::getExceptionAt, query.createdTo()) + .orderByDesc(ExceptionOrder::getExceptionAt).orderByDesc(ExceptionOrder::getId)); + return new PageResult<>(result.getRecords().stream().map(this::row).toList(), result.getCurrent(), + result.getSize(), result.getTotal(), result.getPages()); + } + + public ExceptionOrderResponses.Relation resolve(String scopedCustomerId, String requestedCustomerId, + String inboundCode, String lastMileOrderNo) { + return relation(scopedCustomerId, requestedCustomerId, inboundCode, lastMileOrderNo); + } + + public List returnCandidates(String customerId, String deliveryNo) { + DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo); + if (delivery == null) throw notFound("派送单不存在"); + return lastMileOrders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, customerId) + .eq(LastlegOrder::getDeliveryOrderNo, deliveryNo) + .orderByAsc(LastlegOrder::getOrderNo)).stream() + .map(order -> relation(customerId, customerId, order.getInboundCode(), order.getOrderNo())) + .distinct().toList(); + } + + @Transactional + public ExceptionOrderResponses.Created create(String scopedCustomerId, String operatorId, + ExceptionOrderRequests.Create request) { + ExceptionOrderResponses.Relation relation = relation(scopedCustomerId, request.customerId(), + request.inboundCode(), request.lastMileOrderNo()); + return insert(relation, null, normalizeType(request.exceptionType()), request.reason(), request.packageCount(), + request.skuCode(), null, null, "MANUAL", operatorId); + } + + @Transactional + public ExceptionOrderResponses.Created markReturn(String customerId, String operatorId, String deliveryNo, + ExceptionOrderRequests.MarkReturn request) { + DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo); + if (delivery == null) throw notFound("派送单不存在"); + ExceptionOrder existing = exceptionOrders.findDeliveryReturn(customerId, delivery.getId()); + if (existing != null) return new ExceptionOrderResponses.Created(existing.getId(), existing.getExceptionNo(), existing.getStatus()); + + List candidates = returnCandidates(customerId, deliveryNo); + ExceptionOrderResponses.Relation selected = candidates.stream() + .filter(candidate -> Objects.equals(candidate.inboundOrderId(), request.inboundOrderId())) + .findFirst().orElseThrow(() -> conflict("所选入仓单不属于该派送单")); + ExceptionOrderResponses.Created created = insert(selected, delivery, "RETURN", request.reason(), + request.packageCount(), request.skuCode(), request.returnDirection(), request.returnLogisticsNo(), + "DELIVERY_RETURN", operatorId); + + LocalDateTime now = LocalDateTime.now(); + delivery.setStatus("RETURN_PENDING"); + delivery.setUpdatedAt(now); + deliveryOrders.updateById(delivery); + LastlegOrder tail = lastMileOrders.selectById(selected.lastMileOrderId()); + if (tail != null) { + tail.setAbnormal(true); + tail.setSubOrderStatus("RETURN_PENDING"); + tail.setUpdatedAt(now); + lastMileOrders.updateById(tail); + } + DeliveryOperation operation = new DeliveryOperation(); + operation.setCustomerId(customerId); operation.setDeliveryOrderId(delivery.getId()); + operation.setOperationNode("MARK_RETURN"); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); + operation.setResult("已生成异常退货单 " + created.exceptionNo()); operation.setCreatedAt(now); + deliveryOperations.insert(operation); + return created; + } + + private ExceptionOrderResponses.Relation relation(String scopedCustomerId, String requestedCustomerId, + String inboundCode, String lastMileOrderNo) { + String customerId = customerScope(scopedCustomerId, requestedCustomerId); + String inbound = trim(inboundCode); + String tailNo = trim(lastMileOrderNo); + if (!StringUtils.hasText(inbound) && !StringUtils.hasText(tailNo)) { + throw badRequest("入仓码和尾程订单号至少填写一个"); + } + LastlegOrder tail = StringUtils.hasText(tailNo) ? uniqueTail(customerId, tailNo) : null; + LastlegInboundOrder inboundOrder = StringUtils.hasText(inbound) ? uniqueInbound(customerId, inbound) : null; + if (tail != null && inboundOrder != null && !Objects.equals(tail.getInboundCode(), inboundOrder.getInboundCode())) { + throw conflict("尾程订单与入仓单不是同一关联关系"); + } + if (tail == null && inboundOrder != null) tail = optionalTail(inboundOrder.getCustomerId(), inboundOrder.getInboundCode()); + if (inboundOrder == null && tail != null) inboundOrder = optionalInbound(tail.getCustomerId(), tail.getInboundCode()); + if (tail == null && inboundOrder == null) throw notFound("未找到对应单据"); + String resolvedCustomer = tail != null ? tail.getCustomerId() : inboundOrder.getCustomerId(); + if (tail != null && inboundOrder != null && !Objects.equals(tail.getCustomerId(), inboundOrder.getCustomerId())) { + throw conflict("尾程订单与入仓单不属于同一客户"); + } + return new ExceptionOrderResponses.Relation( + tail == null ? null : tail.getId(), tail == null ? null : tail.getOrderNo(), + inboundOrder == null ? null : inboundOrder.getId(), inboundOrder == null ? null : inboundOrder.getInboundCode(), + resolvedCustomer, tail == null ? null : tail.getCustomerName(), + tail == null ? null : tail.getOverseasWarehouseId(), tail == null ? null : tail.getOverseasWarehouseName(), + tail == null ? null : tail.getDeliveryOrderNo(), tail == null ? null : tail.getOutboundOrderNo(), + tail == null ? inboundOrder.getPackageCount() : tail.getPackageCount()); + } + + private ExceptionOrderResponses.Created insert(ExceptionOrderResponses.Relation relation, DeliveryOrder delivery, + String type, String reason, Integer packages, String sku, + String direction, String logisticsNo, String sourceType, + String operatorId) { + if (packages != null && relation.availablePackages() != null && packages > relation.availablePackages()) { + throw conflict("异常箱数不能大于订单箱数 " + relation.availablePackages()); + } + LocalDateTime now = LocalDateTime.now(); + ExceptionOrder value = new ExceptionOrder(); + value.setId(IdWorker.getId()); value.setCustomerId(relation.customerId()); + value.setExceptionNo("EXC" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + value.getId()); + value.setExceptionType(type); value.setStatus("PENDING"); value.setReason(trim(reason)); + value.setLastMileOrderId(relation.lastMileOrderId()); value.setLastMileOrderNo(relation.lastMileOrderNo()); + value.setInboundOrderId(relation.inboundOrderId()); value.setInboundCode(relation.inboundCode()); + value.setDeliveryOrderId(delivery == null ? null : delivery.getId()); + value.setDeliveryNo(delivery == null ? relation.deliveryNo() : delivery.getDeliveryNo()); + value.setOutboundNo(delivery == null ? relation.outboundNo() : delivery.getOutboundNo()); + value.setCustomerName(relation.customerName()); + value.setOverseasWarehouseId(delivery == null ? relation.overseasWarehouseId() : delivery.getOverseasWarehouseId()); + value.setOverseasWarehouseName(delivery == null ? relation.overseasWarehouseName() : delivery.getOverseasWarehouseName()); + value.setSkuCode(trim(sku)); value.setPackageCount(packages); value.setReturnDirection(trim(direction)); + value.setReturnLogisticsNo(trim(logisticsNo)); + if (StringUtils.hasText(direction)) { + value.setHandlingAction("OVERSEAS_WAREHOUSE".equals(direction) ? "退回海外仓" : "CUSTOMER".equals(direction) ? "退回客户" : "其他退货处理"); + value.setHandlerId(operatorId); value.setHandlerName(operatorId); + } + value.setSourceType(sourceType); value.setCreatedBy(operatorId); + value.setExceptionAt(now); value.setCreatedAt(now); value.setUpdatedAt(now); + try { exceptionOrders.insert(value); } + catch (DuplicateKeyException exception) { throw conflict("该关联单据已存在异常退货单"); } + return new ExceptionOrderResponses.Created(value.getId(), value.getExceptionNo(), value.getStatus()); + } + + private LastlegOrder uniqueTail(String customerId, String orderNo) { + List values = lastMileOrders.selectList(new LambdaQueryWrapper() + .eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId) + .eq(LastlegOrder::getOrderNo, orderNo).last("LIMIT 2")); + if (values.isEmpty()) throw notFound("尾程订单不存在"); + if (values.size() > 1) throw conflict("尾程订单号不唯一,请先选择客户"); + return values.get(0); + } + + private LastlegInboundOrder uniqueInbound(String customerId, String inboundCode) { + List values = inboundOrders.selectList(new LambdaQueryWrapper() + .eq(StringUtils.hasText(customerId), LastlegInboundOrder::getCustomerId, customerId) + .eq(LastlegInboundOrder::getInboundCode, inboundCode).last("LIMIT 2")); + if (values.isEmpty()) throw notFound("入仓单不存在"); + if (values.size() > 1) throw conflict("入仓码不唯一,请先选择客户"); + return values.get(0); + } + + private LastlegOrder optionalTail(String customerId, String inboundCode) { + List values = lastMileOrders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getInboundCode, inboundCode).last("LIMIT 2")); + if (values.size() > 1) throw conflict("该入仓码关联了多个尾程订单,请修复关联关系后重试"); + return values.size() == 1 ? values.get(0) : null; + } + + private LastlegInboundOrder optionalInbound(String customerId, String inboundCode) { + if (!StringUtils.hasText(inboundCode)) return null; + List values = inboundOrders.selectList(new LambdaQueryWrapper() + .eq(LastlegInboundOrder::getCustomerId, customerId).eq(LastlegInboundOrder::getInboundCode, inboundCode).last("LIMIT 2")); + if (values.size() > 1) throw conflict("该尾程订单关联了多个入仓单,请修复关联关系后重试"); + return values.size() == 1 ? values.get(0) : null; + } + + private ExceptionOrderResponses.Row row(ExceptionOrder value) { + return new ExceptionOrderResponses.Row(value.getId(), value.getExceptionNo(), value.getExceptionType(), value.getStatus(), + value.getReason(), value.getCustomerId(), value.getCustomerName(), value.getOverseasWarehouseId(), + value.getOverseasWarehouseName(), value.getLastMileOrderId(), value.getLastMileOrderNo(), + value.getInboundOrderId(), value.getInboundCode(), value.getDeliveryOrderId(), value.getDeliveryNo(), + value.getOutboundNo(), value.getSkuCode(), value.getPackageCount(), value.getReturnDirection(), + value.getReturnLogisticsNo(), value.getHandlingAction(), value.getHandlerName(), + value.getExceptionAt(), value.getUpdatedAt()); + } + + private String customerScope(String scopedCustomerId, String requestedCustomerId) { + if (StringUtils.hasText(scopedCustomerId)) { + if (StringUtils.hasText(requestedCustomerId) && !scopedCustomerId.equals(trim(requestedCustomerId))) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "不能访问其他客户数据"); + } + return scopedCustomerId; + } + return trim(requestedCustomerId); + } + + private static String normalizeType(String value) { + String normalized = trim(value).toUpperCase(); + if (!java.util.Set.of("RETURN", "REJECT", "DAMAGED", "OTHER").contains(normalized)) throw badRequest("不支持的异常类型"); + return normalized; + } + private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } +} diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index e85e97965..6d969864d 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1703,3 +1703,20 @@ CREATE TABLE IF NOT EXISTS ll_local_looseboard_operation ( UNIQUE KEY uk_ll_local_looseboard_operation_key(customer_id,order_id,idempotency_key), KEY idx_ll_local_looseboard_operation_order(customer_id,order_id,created_at) ) ENGINE=InnoDB COMMENT='本土散板订单幂等操作账'; + +CREATE TABLE IF NOT EXISTS ll_exception_order ( + id BIGINT NOT NULL, customer_id VARCHAR(128) NOT NULL, exception_no VARCHAR(64) NOT NULL, + exception_type VARCHAR(32) NOT NULL, status VARCHAR(32) NOT NULL DEFAULT 'PENDING', reason VARCHAR(1000) NOT NULL, + last_mile_order_id BIGINT NULL, last_mile_order_no VARCHAR(64) NULL, inbound_order_id BIGINT NULL, inbound_code VARCHAR(128) NULL, + delivery_order_id BIGINT NULL, delivery_no VARCHAR(64) NULL, outbound_no VARCHAR(64) NULL, + customer_name VARCHAR(255) NULL, overseas_warehouse_id VARCHAR(128) NULL, overseas_warehouse_name VARCHAR(255) NULL, + sku_code VARCHAR(128) NULL, package_count INT NULL, return_direction VARCHAR(32) NULL, return_logistics_no VARCHAR(128) NULL, + handling_action VARCHAR(500) NULL, handler_id VARCHAR(128) NULL, handler_name VARCHAR(128) NULL, + source_type VARCHAR(32) NOT NULL DEFAULT 'MANUAL', created_by VARCHAR(128) NULL, + exception_at DATETIME(3) NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), UNIQUE KEY uk_ll_exception_no(customer_id,exception_no), + UNIQUE KEY uk_ll_exception_delivery_return(customer_id,delivery_order_id,exception_type), + KEY idx_ll_exception_list(customer_id,status,exception_at), KEY idx_ll_exception_tail(customer_id,last_mile_order_no), + KEY idx_ll_exception_inbound(customer_id,inbound_code) +) ENGINE=InnoDB COMMENT='海外仓异常订单'; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderServiceTest.java new file mode 100644 index 000000000..6a9036e12 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderServiceTest.java @@ -0,0 +1,74 @@ +package com.baiyue.lastleg.exceptionorder.service; + +import com.baiyue.lastleg.entity.LastlegInboundOrder; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderMapper; +import com.baiyue.lastleg.mapper.LastlegInboundOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ExceptionOrderServiceTest { + @Mock private ExceptionOrderMapper exceptionOrders; + @Mock private LastlegOrderMapper lastMileOrders; + @Mock private LastlegInboundOrderMapper inboundOrders; + private ExceptionOrderService service; + + @BeforeEach + void setUp() { + service = new ExceptionOrderService(); + ReflectionTestUtils.setField(service, "exceptionOrders", exceptionOrders); + ReflectionTestUtils.setField(service, "lastMileOrders", lastMileOrders); + ReflectionTestUtils.setField(service, "inboundOrders", inboundOrders); + } + + @Test + void resolvesTheOneToOneInboundAndLastMileRelation() { + LastlegOrder tail = tail("LM-1", "IN-1"); + LastlegInboundOrder inbound = inbound("IN-1"); + when(lastMileOrders.selectList(any())).thenReturn(List.of(tail)); + when(inboundOrders.selectList(any())).thenReturn(List.of(inbound)); + + var relation = service.resolve(null, null, "IN-1", "LM-1"); + + assertThat(relation.lastMileOrderNo()).isEqualTo("LM-1"); + assertThat(relation.inboundCode()).isEqualTo("IN-1"); + assertThat(relation.customerId()).isEqualTo("1001"); + } + + @Test + void rejectsInputsThatExistButDoNotBelongTogether() { + when(lastMileOrders.selectList(any())).thenReturn(List.of(tail("LM-1", "IN-1"))); + when(inboundOrders.selectList(any())).thenReturn(List.of(inbound("IN-2"))); + + assertThatThrownBy(() -> service.resolve(null, null, "IN-2", "LM-1")) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("不是同一关联关系"); + } + + private LastlegOrder tail(String orderNo, String inboundCode) { + LastlegOrder value = new LastlegOrder(); + value.setId(12L); value.setCustomerId("1001"); value.setOrderNo(orderNo); value.setInboundCode(inboundCode); + value.setCustomerName("测试客户"); value.setPackageCount(10); + return value; + } + + private LastlegInboundOrder inbound(String inboundCode) { + LastlegInboundOrder value = new LastlegInboundOrder(); + value.setId(11L); value.setCustomerId("1001"); value.setInboundCode(inboundCode); value.setPackageCount(10); + return value; + } +} diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index 5f5fe442e..c246f4826 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -3848,3 +3848,20 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt; CREATE TABLE IF NOT EXISTS ll_delivery_fee (id BIGINT NOT NULL COMMENT '派送费用主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '派送单ID', fee_direction VARCHAR(16) NOT NULL DEFAULT 'PAYABLE' COMMENT '应收应付方向', fee_type VARCHAR(64) NOT NULL COMMENT '费用类型', currency VARCHAR(8) NOT NULL COMMENT '币种', amount DECIMAL(18,2) NOT NULL COMMENT '费用金额', remark VARCHAR(500) NULL COMMENT '费用备注', created_by VARCHAR(128) NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), KEY idx_ll_delivery_fee_order(customer_id,delivery_order_id,id)) ENGINE=InnoDB COMMENT='派送单费用明细'; CREATE TABLE IF NOT EXISTS ll_delivery_operation (id BIGINT NOT NULL COMMENT '派送操作主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '派送单ID', operation_node VARCHAR(64) NOT NULL COMMENT '操作节点', operator_id VARCHAR(128) NULL COMMENT '操作人ID', operator_name VARCHAR(128) NULL COMMENT '操作人名称', result VARCHAR(500) NOT NULL COMMENT '处理结果', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', PRIMARY KEY(id), KEY idx_ll_delivery_operation_order(customer_id,delivery_order_id,created_at,id)) ENGINE=InnoDB COMMENT='派送单操作记录'; + +CREATE TABLE IF NOT EXISTS ll_exception_order ( + id BIGINT NOT NULL, customer_id VARCHAR(128) NOT NULL, exception_no VARCHAR(64) NOT NULL, + exception_type VARCHAR(32) NOT NULL, status VARCHAR(32) NOT NULL DEFAULT 'PENDING', reason VARCHAR(1000) NOT NULL, + last_mile_order_id BIGINT NULL, last_mile_order_no VARCHAR(64) NULL, inbound_order_id BIGINT NULL, inbound_code VARCHAR(128) NULL, + delivery_order_id BIGINT NULL, delivery_no VARCHAR(64) NULL, outbound_no VARCHAR(64) NULL, + customer_name VARCHAR(255) NULL, overseas_warehouse_id VARCHAR(128) NULL, overseas_warehouse_name VARCHAR(255) NULL, + sku_code VARCHAR(128) NULL, package_count INT NULL, return_direction VARCHAR(32) NULL, return_logistics_no VARCHAR(128) NULL, + handling_action VARCHAR(500) NULL, handler_id VARCHAR(128) NULL, handler_name VARCHAR(128) NULL, + source_type VARCHAR(32) NOT NULL DEFAULT 'MANUAL', created_by VARCHAR(128) NULL, + exception_at DATETIME(3) NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), UNIQUE KEY uk_ll_exception_no(customer_id,exception_no), + UNIQUE KEY uk_ll_exception_delivery_return(customer_id,delivery_order_id,exception_type), + KEY idx_ll_exception_list(customer_id,status,exception_at), KEY idx_ll_exception_tail(customer_id,last_mile_order_no), + KEY idx_ll_exception_inbound(customer_id,inbound_code) +) ENGINE=InnoDB COMMENT='海外仓异常订单'; From 93e1540e3f996bbc22e84ed383d88e8da62773a6 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Wed, 26 Aug 2026 15:02:14 +0800 Subject: [PATCH 09/24] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 36 +- .../service/InternalBusinessFileService.java | 3 +- .../config/LastlegRestClientProperties.java | 4 +- .../controller/CustomerVisitController.java | 94 ++++++ .../dto/CustomerVisitRequests.java | 35 ++ .../dto/CustomerVisitResponses.java | 31 ++ .../customervisit/entity/CustomerVisit.java | 37 ++ .../gateway/CustomerVisitFileGateway.java | 10 + .../gateway/HttpCustomerVisitFileGateway.java | 101 ++++++ .../mapper/CustomerVisitMapper.java | 17 + .../service/CustomerVisitService.java | 315 ++++++++++++++++++ .../src/main/resources/application.yml | 3 + .../src/main/resources/db/lastleg.sql | 30 ++ scripts/db/incremental.sql | 30 ++ 14 files changed, 743 insertions(+), 3 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/controller/CustomerVisitController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/dto/CustomerVisitRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/dto/CustomerVisitResponses.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/entity/CustomerVisit.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/gateway/CustomerVisitFileGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/gateway/HttpCustomerVisitFileGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/mapper/CustomerVisitMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/service/CustomerVisitService.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 3d6f4f558..f9bd01018 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -16586,16 +16586,50 @@ CREATE TABLE IF NOT EXISTS ll_exception_order ( KEY idx_ll_exception_inbound(customer_id,inbound_code) ) ENGINE=InnoDB COMMENT='海外仓异常订单'; +CREATE TABLE IF NOT EXISTS ll_customer_visit ( + id BIGINT NOT NULL COMMENT '客户回访记录ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + delivery_order_id BIGINT NOT NULL COMMENT '关联派送单ID', + delivery_no VARCHAR(64) NOT NULL COMMENT '关联派送单号', + customer_name VARCHAR(255) NULL COMMENT '客户名称快照', + contact_name VARCHAR(128) NULL COMMENT '客户联系人姓名快照', + contact_phone VARCHAR(64) NULL COMMENT '客户联系人电话快照', + destination VARCHAR(500) NULL COMMENT '派送目的仓编码汇总快照', + delivery_completed_at DATETIME(3) NOT NULL COMMENT '派送完成时间', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '回访状态:PENDING待回访或COMPLETED已回访', + selected_last_mile_order_id BIGINT NULL COMMENT '本次回访选择的尾程订单ID', + selected_last_mile_order_no VARCHAR(64) NULL COMMENT '本次回访选择的尾程订单号', + selected_inbound_order_id BIGINT NULL COMMENT '本次回访选择的入仓单ID', + selected_inbound_code VARCHAR(128) NULL COMMENT '本次回访选择的入仓码', + visit_result VARCHAR(32) NULL COMMENT '最近一次回访结果', + customer_feedback VARCHAR(2000) NULL COMMENT '最近一次客户反馈', + remark VARCHAR(1000) NULL COMMENT '最近一次回访备注', + attachment_json JSON NULL COMMENT '当前回访资料文件元数据列表', + visited_by VARCHAR(128) NULL COMMENT '最近一次回访操作人ID', + visited_at DATETIME(3) NULL COMMENT '最近一次回访时间', + visit_count INT NOT NULL DEFAULT 0 COMMENT '累计保存回访次数', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), + UNIQUE KEY uk_ll_customer_visit_delivery(customer_id,delivery_order_id), + KEY idx_ll_customer_visit_list(customer_id,status,delivery_completed_at), + KEY idx_ll_customer_visit_delivery_no(customer_id,delivery_no) +) ENGINE=InnoDB COMMENT='派送单维度客户回访记录'; + + INSERT INTO baiyue_iam.iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted) VALUES (5107070,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:exception-order:list','海外异常订单','/overseas-warehouse/exception-orders','GET',770,'ACTIVE',1,0), (5107071,0,'PLATFORM','BUTTON',5107070,'platform:overseas-warehouse:exception-order:create','新增海外异常订单',NULL,'POST',771,'ACTIVE',1,0), (5107072,0,'PLATFORM','BUTTON',5107070,'platform:overseas-warehouse:delivery:return','派送单标记退货',NULL,'POST',772,'ACTIVE',1,0) + ,(5107080,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:customer-visit:list','客户回访','/overseas-warehouse/customer-visits','GET',780,'ACTIVE',1,0) + ,(5107081,0,'PLATFORM','BUTTON',5107080,'platform:overseas-warehouse:customer-visit:edit','保存客户回访',NULL,'PUT',781,'ACTIVE',1,0) + ,(5107082,0,'PLATFORM','BUTTON',5107080,'platform:overseas-warehouse:delivery:status','编辑派送单状态',NULL,'PUT',782,'ACTIVE',1,0) ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),route_path=VALUES(route_path),http_method=VALUES(http_method),sort_no=VALUES(sort_no),status='ACTIVE',built_in=1,deleted=0; INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id,role_id,permission_id,created_at) SELECT 0,role.id,permission.id,NOW(3) FROM baiyue_iam.iam_role role -JOIN baiyue_iam.iam_permission permission ON permission.id BETWEEN 5107070 AND 5107072 AND permission.tenant_id=0 +JOIN baiyue_iam.iam_permission permission ON permission.id BETWEEN 5107070 AND 5107082 AND permission.tenant_id=0 WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN' AND role.status='ACTIVE' AND role.deleted=0; CALL baiyue_firstleg.assert_unified_final_schema(); diff --git a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java index 86268a298..6ee3f5737 100644 --- a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java +++ b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java @@ -24,7 +24,8 @@ public class InternalBusinessFileService { "CONTAINER_DELIVERY_ORDER", "CONTAINER_PICKUP_ORDER", "OVERSEAS_UNLOAD_PHOTO", - "OVERSEAS_RELABEL_PHOTO"); + "OVERSEAS_RELABEL_PHOTO", + "OVERSEAS_CUSTOMER_VISIT"); @Resource private FileObjectMapper files; @Resource private BusinessFileStorage storage; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java b/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java index be6fd6900..0cec9a7e7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java @@ -10,6 +10,7 @@ import java.time.Duration; public class LastlegRestClientProperties { private ClientTimeouts unloadPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30)); private ClientTimeouts relabelPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30)); + private ClientTimeouts customerVisitFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30)); private ClientTimeouts warehouseResource = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(5)); private ClientTimeouts palletPhysical = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(5)); @@ -17,6 +18,8 @@ public class LastlegRestClientProperties { public void setUnloadPhotoFile(ClientTimeouts value) { unloadPhotoFile = required(value, "unload-photo-file"); } public ClientTimeouts getRelabelPhotoFile() { return relabelPhotoFile; } public void setRelabelPhotoFile(ClientTimeouts value) { relabelPhotoFile = required(value, "relabel-photo-file"); } + public ClientTimeouts getCustomerVisitFile() { return customerVisitFile; } + public void setCustomerVisitFile(ClientTimeouts value) { customerVisitFile = required(value, "customer-visit-file"); } public ClientTimeouts getWarehouseResource() { return warehouseResource; } public void setWarehouseResource(ClientTimeouts value) { warehouseResource = required(value, "warehouse-resource"); } public ClientTimeouts getPalletPhysical() { return palletPhysical; } @@ -51,4 +54,3 @@ public class LastlegRestClientProperties { } } } - diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/controller/CustomerVisitController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/controller/CustomerVisitController.java new file mode 100644 index 000000000..c213639e1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/controller/CustomerVisitController.java @@ -0,0 +1,94 @@ +package com.baiyue.lastleg.customervisit.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.customervisit.dto.CustomerVisitRequests; +import com.baiyue.lastleg.customervisit.dto.CustomerVisitResponses; +import com.baiyue.lastleg.customervisit.service.CustomerVisitService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@ConditionalOnProperty(name = "lastleg.future-outbound-api.enabled", havingValue = "true") +@RequestMapping("/api/platform/v1/last-leg") +public class CustomerVisitController { + @Resource private CustomerVisitService service; + + @GetMapping("/customer-visits") + public ApiResponse page(@AuthenticationPrincipal AuthenticatedUser user, + @Valid CustomerVisitRequests.Query query) { + return ApiResponse.success(service.page(scope(user), query)); + } + + @GetMapping("/customer-visits/{visitId}") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable long visitId) { + return ApiResponse.success(service.detail(requiredScope(user, customerId), visitId)); + } + + @PutMapping("/customer-visits/{visitId}") + public ApiResponse save(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable long visitId, + @Valid @RequestBody CustomerVisitRequests.Save request) { + return ApiResponse.success(service.save(requiredScope(user, customerId), operator(user), visitId, request)); + } + + @PostMapping(value = "/customer-visits/{visitId}/attachments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse upload(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable long visitId, + @RequestPart("file") MultipartFile file) { + if (user.userId() == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "当前用户缺少有效ID"); + return ApiResponse.success(service.upload(requiredScope(user, customerId), user.userId(), visitId, file)); + } + + @PostMapping("/delivery-orders/{deliveryNo}/complete") + public ApiResponse completeDelivery( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, + @PathVariable String deliveryNo) { + return ApiResponse.success(service.completeDelivery(requiredScope(user, customerId), operator(user), deliveryNo)); + } + + @PutMapping("/delivery-orders/{deliveryNo}/status") + public ApiResponse updateDeliveryStatus( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, + @PathVariable String deliveryNo, @Valid @RequestBody CustomerVisitRequests.DeliveryStatus request) { + return ApiResponse.success(service.updateDeliveryStatus( + requiredScope(user, customerId), operator(user), deliveryNo, request)); + } + + private String scope(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + return "PLATFORM".equals(user.appCode()) && user.tenantId() == 0L ? null : String.valueOf(user.tenantId()); + } + + private String requiredScope(AuthenticatedUser user, String requestedCustomer) { + String own = scope(user); + if (own != null) return own; + if (!StringUtils.hasText(requestedCustomer)) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择客户"); + return requestedCustomer.trim(); + } + + private String operator(AuthenticatedUser user) { + return user.userId() == null ? user.username() : String.valueOf(user.userId()); + } + +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/dto/CustomerVisitRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/dto/CustomerVisitRequests.java new file mode 100644 index 000000000..5c7ffcb36 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/dto/CustomerVisitRequests.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.customervisit.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.List; + +public final class CustomerVisitRequests { + private CustomerVisitRequests() { } + + public record Query(@Min(1) long page, @Min(1) @Max(200) long size, + String keyword, String status, String customerId, + LocalDateTime completedFrom, LocalDateTime completedTo) { + public Query { if (page == 0) page = 1; if (size == 0) size = 20; } + } + + public record Save(@NotNull Long selectedInboundOrderId, + @NotBlank @Size(max = 32) String visitResult, + @Size(max = 2000) String customerFeedback, + @Size(max = 1000) String remark, + @Valid @Size(max = 20) List attachments) { } + + public record Attachment(@NotNull Long fileId, + @NotBlank @Size(max = 255) String fileName, + @Size(max = 128) String contentType, + @NotNull @Min(0) Long sizeBytes) { } + + public record DeliveryStatus(@NotBlank @Size(max = 32) String status, + @Size(max = 500) String remark) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/dto/CustomerVisitResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/dto/CustomerVisitResponses.java new file mode 100644 index 000000000..943fb2f28 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/dto/CustomerVisitResponses.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.customervisit.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public final class CustomerVisitResponses { + private CustomerVisitResponses() { } + + public record Row(Long id, String customerId, String deliveryNo, String customerName, + String contactName, String contactPhone, String destination, + LocalDateTime deliveryCompletedAt, String status, + String selectedInboundCode, String visitResult, LocalDateTime visitedAt, + int visitCount, List attachments) { } + + public record Page(List records, long current, long size, long total, long pages, + long visitable, long pending, long completed) { } + + public record InboundOption(Long inboundOrderId, String inboundCode, Long lastMileOrderId, + String lastMileOrderNo, String destinationWarehouseCode, + String destinationWarehouseName, int packageCount) { } + + public record Attachment(Long fileId, String fileName, String contentType, long sizeBytes) { } + + public record Detail(Row visit, List inboundOrders, + String customerFeedback, String remark, String visitedBy) { } + + public record UploadedFile(Long fileId, String fileName, String contentType, long sizeBytes) { } + + public record Completed(Long visitId, String deliveryNo, String deliveryStatus, + String visitStatus, boolean replayed) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/entity/CustomerVisit.java b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/entity/CustomerVisit.java new file mode 100644 index 000000000..d750d39c1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/entity/CustomerVisit.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.customervisit.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_customer_visit") +public class CustomerVisit { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String customerId; + private Long deliveryOrderId; + private String deliveryNo; + private String customerName; + private String contactName; + private String contactPhone; + private String destination; + private LocalDateTime deliveryCompletedAt; + private String status; + private Long selectedLastMileOrderId; + private String selectedLastMileOrderNo; + private Long selectedInboundOrderId; + private String selectedInboundCode; + private String visitResult; + private String customerFeedback; + private String remark; + private String attachmentJson; + private String visitedBy; + private LocalDateTime visitedAt; + private Integer visitCount; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/gateway/CustomerVisitFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/gateway/CustomerVisitFileGateway.java new file mode 100644 index 000000000..b3d81e99d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/gateway/CustomerVisitFileGateway.java @@ -0,0 +1,10 @@ +package com.baiyue.lastleg.customervisit.gateway; + +import org.springframework.web.multipart.MultipartFile; + +public interface CustomerVisitFileGateway { + UploadedFile upload(String customerId, long visitId, long operatorId, MultipartFile file); + void validate(String customerId, long visitId, long fileId); + + record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/gateway/HttpCustomerVisitFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/gateway/HttpCustomerVisitFileGateway.java new file mode 100644 index 000000000..a301603e2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/gateway/HttpCustomerVisitFileGateway.java @@ -0,0 +1,101 @@ +package com.baiyue.lastleg.customervisit.gateway; + +import com.baiyue.lastleg.config.LastlegRestClientFactory; +import com.baiyue.lastleg.config.LastlegRestClientProperties; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.io.IOException; + +@Component +public class HttpCustomerVisitFileGateway implements CustomerVisitFileGateway { + private static final String BUSINESS_TYPE = "OVERSEAS_CUSTOMER_VISIT"; + @Resource private RestClient.Builder restClientBuilder; + @Resource private LastlegRestClientFactory restClientFactory; + @Resource private LastlegRestClientProperties restClientProperties; + @Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl; + @Value("${baiyue.security.internal-service-token:}") private String internalToken; + private RestClient client; + + @PostConstruct + void initializeClient() { + client = restClientFactory.build(restClientBuilder, baseUrl, restClientProperties.getCustomerVisitFile()); + } + + @Override + public UploadedFile upload(String customerId, long visitId, long operatorId, MultipartFile file) { + MultipartBodyBuilder body = new MultipartBodyBuilder(); + try { + body.part("file", new ByteArrayResource(file.getBytes()) { + @Override public String getFilename() { return file.getOriginalFilename(); } + }).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM + : MediaType.parseMediaType(file.getContentType())); + } catch (IOException exception) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "无法读取回访资料", exception); + } + MetadataEnvelope response = client.post() + .uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}", + tenantId(customerId), BUSINESS_TYPE, visitId) + .header("X-Internal-Service-Token", internalToken) + .header("X-Operator-Id", String.valueOf(operatorId)) + .contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw new ResponseStatusException(result.getStatusCode().is4xxClientError() + ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, "回访资料上传失败"); + }).body(MetadataEnvelope.class); + requireSuccess(response, "回访资料上传失败"); + Metadata data = response == null ? null : response.data(); + if (data == null || data.fileId() == null) throw new ResponseStatusException( + HttpStatus.SERVICE_UNAVAILABLE, "文件服务未返回回访资料元数据"); + return new UploadedFile(data.fileId(), data.fileName(), data.contentType(), + data.sizeBytes() == null ? 0L : data.sizeBytes()); + } + + @Override + public void validate(String customerId, long visitId, long fileId) { + long tenantId = tenantId(customerId); + MetadataEnvelope response = client.get() + .uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}", + tenantId, BUSINESS_TYPE, visitId, fileId) + .header("X-Internal-Service-Token", internalToken).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw new ResponseStatusException(result.getStatusCode().is4xxClientError() + ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, "回访资料不存在或归属不正确"); + }).body(MetadataEnvelope.class); + requireSuccess(response, "回访资料不存在或归属不正确"); + Metadata data = response == null ? null : response.data(); + if (data == null || data.fileId() == null || data.fileId() != fileId + || data.tenantId() == null || data.tenantId() != tenantId + || !BUSINESS_TYPE.equals(data.businessType()) || !String.valueOf(visitId).equals(data.businessId())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "回访资料与当前记录不匹配"); + } + } + + private static long tenantId(String customerId) { + try { return Long.parseLong(customerId); } + catch (NumberFormatException exception) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "客户ID格式无效", exception); + } + } + + private static void requireSuccess(MetadataEnvelope response, String fallback) { + if (response == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, fallback); + if ("200".equals(response.code())) return; + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + response.message() == null || response.message().isBlank() ? fallback : response.message()); + } + + private record MetadataEnvelope(String code, String message, Metadata data) { } + private record Metadata(Long fileId, Long tenantId, String businessType, String businessId, + String fileName, String contentType, Long sizeBytes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/mapper/CustomerVisitMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/mapper/CustomerVisitMapper.java new file mode 100644 index 000000000..f52254c71 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/mapper/CustomerVisitMapper.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.customervisit.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.customervisit.entity.CustomerVisit; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface CustomerVisitMapper extends BaseMapper { + @Select("SELECT * FROM ll_customer_visit WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} LIMIT 1") + CustomerVisit findByDelivery(@Param("customerId") String customerId, + @Param("deliveryOrderId") Long deliveryOrderId); + + @Select("SELECT * FROM ll_customer_visit WHERE customer_id=#{customerId} AND id=#{id} LIMIT 1 FOR UPDATE") + CustomerVisit lockById(@Param("customerId") String customerId, @Param("id") Long id); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/service/CustomerVisitService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/service/CustomerVisitService.java new file mode 100644 index 000000000..416d19088 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/service/CustomerVisitService.java @@ -0,0 +1,315 @@ +package com.baiyue.lastleg.customervisit.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.lastleg.customervisit.dto.CustomerVisitRequests; +import com.baiyue.lastleg.customervisit.dto.CustomerVisitResponses; +import com.baiyue.lastleg.customervisit.entity.CustomerVisit; +import com.baiyue.lastleg.customervisit.gateway.CustomerVisitFileGateway; +import com.baiyue.lastleg.customervisit.mapper.CustomerVisitMapper; +import com.baiyue.lastleg.delivery.entity.DeliveryOperation; +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper; +import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; +import com.baiyue.lastleg.entity.LastlegInboundOrder; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.mapper.LastlegInboundOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +@Service +public class CustomerVisitService { + private static final Set RESULTS = Set.of("SATISFIED", "NORMAL", "FOLLOW_UP", "NO_CONTACT"); + private static final long MAX_FILE_BYTES = 20L * 1024L * 1024L; + private static final Set FILE_EXTENSIONS = Set.of( + "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "jpg", "jpeg", "png", "webp"); + + @Resource private CustomerVisitMapper visits; + @Resource private DeliveryOrderMapper deliveryOrders; + @Resource private DeliveryOperationMapper deliveryOperations; + @Resource private LastlegOrderMapper lastMileOrders; + @Resource private LastlegInboundOrderMapper inboundOrders; + @Resource private LastlegOrderStatusHistoryMapper histories; + @Resource private CustomerVisitFileGateway files; + @Resource private ObjectMapper objectMapper; + + public CustomerVisitResponses.Page page(String scopedCustomerId, CustomerVisitRequests.Query query) { + String customerId = customerScope(scopedCustomerId, query.customerId()); + LambdaQueryWrapper filter = filter(customerId, query); + Page result = visits.selectPage(new Page<>(query.page(), query.size()), + filter.orderByDesc(CustomerVisit::getDeliveryCompletedAt).orderByDesc(CustomerVisit::getId)); + LambdaQueryWrapper countScope = new LambdaQueryWrapper() + .eq(StringUtils.hasText(customerId), CustomerVisit::getCustomerId, customerId); + long visitable = visits.selectCount(countScope); + long pending = visits.selectCount(new LambdaQueryWrapper() + .eq(StringUtils.hasText(customerId), CustomerVisit::getCustomerId, customerId) + .eq(CustomerVisit::getStatus, "PENDING")); + long completed = visits.selectCount(new LambdaQueryWrapper() + .eq(StringUtils.hasText(customerId), CustomerVisit::getCustomerId, customerId) + .eq(CustomerVisit::getStatus, "COMPLETED")); + return new CustomerVisitResponses.Page(result.getRecords().stream().map(this::row).toList(), + result.getCurrent(), result.getSize(), result.getTotal(), result.getPages(), visitable, pending, completed); + } + + public CustomerVisitResponses.Detail detail(String customerId, long visitId) { + CustomerVisit visit = required(customerId, visitId); + return detail(visit); + } + + @Transactional + public CustomerVisitResponses.Completed completeDelivery(String customerId, String operatorId, String deliveryNo) { + DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo); + if (delivery == null) throw notFound("派送单不存在"); + if (Set.of("RETURN_PENDING", "REJECTED", "CANCELLED").contains(delivery.getStatus())) { + throw conflict("当前派送状态不能标记为派送完成"); + } + boolean replayed = "DELIVERED".equals(delivery.getStatus()); + LocalDateTime now = replayed && delivery.getSignedAt() != null ? delivery.getSignedAt() : LocalDateTime.now(); + if (!replayed) { + delivery.setStatus("DELIVERED"); + delivery.setSignedAt(now); + delivery.setUpdatedAt(now); + deliveryOrders.updateById(delivery); + for (LastlegOrder order : lastMileOrders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, customerId) + .eq(LastlegOrder::getDeliveryOrderNo, deliveryNo))) { + String from = order.getCurrentStatus(); + order.setCurrentStatus("DELIVERED"); + order.setSubOrderStatus("DELIVERY:DELIVERED"); + order.setUpdatedAt(now); + lastMileOrders.updateById(order); + LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); + history.setCustomerId(customerId); history.setOrderId(order.getId()); history.setFromStatus(from); + history.setToStatus("DELIVERED"); history.setEventId("DELIVERY_COMPLETED:" + deliveryNo); + history.setRemark("派送完成"); history.setCreatedAt(now); histories.insert(history); + } + recordOperation(delivery, operatorId, "COMPLETE_DELIVERY", "派送完成并生成客户回访记录", now); + } + CustomerVisit visit = ensureVisit(delivery, now); + return new CustomerVisitResponses.Completed(visit.getId(), deliveryNo, delivery.getStatus(), visit.getStatus(), replayed); + } + + @Transactional + public CustomerVisitResponses.Completed updateDeliveryStatus(String customerId, String operatorId, + String deliveryNo, + CustomerVisitRequests.DeliveryStatus request) { + String target = trim(request.status()).toUpperCase(Locale.ROOT); + if (!Set.of("WAIT_DELIVERY", "DELIVERING", "DELIVERED", "REJECTED", "CANCELLED").contains(target)) { + throw badRequest("不支持的派送状态"); + } + if ("DELIVERED".equals(target)) return completeDelivery(customerId, operatorId, deliveryNo); + DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo); + if (delivery == null) throw notFound("派送单不存在"); + if ("RETURN_PENDING".equals(delivery.getStatus())) throw conflict("退货处理中的派送单不能直接修改状态"); + if (target.equals(delivery.getStatus())) { + CustomerVisit existing = visits.findByDelivery(customerId, delivery.getId()); + return new CustomerVisitResponses.Completed(existing == null ? null : existing.getId(), deliveryNo, + delivery.getStatus(), existing == null ? null : existing.getStatus(), true); + } + String from = delivery.getStatus(); + LocalDateTime now = LocalDateTime.now(); + delivery.setStatus(target); + if ("DELIVERING".equals(target) && delivery.getDispatchedAt() == null) delivery.setDispatchedAt(now); + delivery.setUpdatedAt(now); + deliveryOrders.updateById(delivery); + String result = "派送状态由 " + from + " 修改为 " + target; + if (StringUtils.hasText(request.remark())) result += ",备注:" + trim(request.remark()); + recordOperation(delivery, operatorId, "UPDATE_DELIVERY_STATUS", result, now); + CustomerVisit existing = visits.findByDelivery(customerId, delivery.getId()); + return new CustomerVisitResponses.Completed(existing == null ? null : existing.getId(), deliveryNo, + delivery.getStatus(), existing == null ? null : existing.getStatus(), false); + } + + @Transactional + public CustomerVisitResponses.Detail save(String customerId, String operatorId, long visitId, + CustomerVisitRequests.Save request) { + CustomerVisit visit = visits.lockById(customerId, visitId); + if (visit == null) throw notFound("回访记录不存在"); + CustomerVisitResponses.InboundOption selected = inboundOptions(visit).stream() + .filter(option -> Objects.equals(option.inboundOrderId(), request.selectedInboundOrderId())) + .findFirst().orElseThrow(() -> conflict("所选入仓单不属于该派送单")); + String result = trim(request.visitResult()).toUpperCase(Locale.ROOT); + if (!RESULTS.contains(result)) throw badRequest("不支持的回访结果"); + List attachments = new ArrayList<>(); + Map unique = new LinkedHashMap<>(); + if (request.attachments() != null) { + for (CustomerVisitRequests.Attachment attachment : request.attachments()) { + files.validate(customerId, visitId, attachment.fileId()); + unique.put(attachment.fileId(), new CustomerVisitResponses.Attachment(attachment.fileId(), + trim(attachment.fileName()), trim(attachment.contentType()), attachment.sizeBytes())); + } + } + attachments.addAll(unique.values()); + LocalDateTime now = LocalDateTime.now(); + visit.setSelectedInboundOrderId(selected.inboundOrderId()); + visit.setSelectedInboundCode(selected.inboundCode()); + visit.setSelectedLastMileOrderId(selected.lastMileOrderId()); + visit.setSelectedLastMileOrderNo(selected.lastMileOrderNo()); + visit.setVisitResult(result); + visit.setCustomerFeedback(trim(request.customerFeedback())); + visit.setRemark(trim(request.remark())); + visit.setAttachmentJson(writeAttachments(attachments)); + visit.setStatus("COMPLETED"); + visit.setVisitedBy(operatorId); + visit.setVisitedAt(now); + visit.setVisitCount(Objects.requireNonNullElse(visit.getVisitCount(), 0) + 1); + visit.setUpdatedAt(now); + visits.updateById(visit); + DeliveryOrder delivery = deliveryOrders.findByNo(customerId, visit.getDeliveryNo()); + if (delivery != null) recordOperation(delivery, operatorId, "CUSTOMER_VISIT", + "已完成第" + visit.getVisitCount() + "次客户回访,入仓单 " + selected.inboundCode(), now); + return detail(visit); + } + + public CustomerVisitResponses.UploadedFile upload(String customerId, long operatorId, long visitId, + MultipartFile file) { + required(customerId, visitId); + validateFile(file); + CustomerVisitFileGateway.UploadedFile uploaded = files.upload(customerId, visitId, operatorId, file); + return new CustomerVisitResponses.UploadedFile(uploaded.fileId(), uploaded.fileName(), + uploaded.contentType(), uploaded.sizeBytes()); + } + + private CustomerVisit ensureVisit(DeliveryOrder delivery, LocalDateTime completedAt) { + CustomerVisit existing = visits.findByDelivery(delivery.getCustomerId(), delivery.getId()); + if (existing != null) return existing; + List orders = orders(delivery); + CustomerVisit value = new CustomerVisit(); + value.setId(IdWorker.getId()); value.setCustomerId(delivery.getCustomerId()); + value.setDeliveryOrderId(delivery.getId()); value.setDeliveryNo(delivery.getDeliveryNo()); + value.setCustomerName(delivery.getCustomerName()); + value.setDestination(orders.stream().map(LastlegOrder::getFbaWarehouseCode) + .filter(StringUtils::hasText).distinct().reduce((left, right) -> left + ", " + right).orElse(null)); + value.setDeliveryCompletedAt(completedAt); value.setStatus("PENDING"); value.setVisitCount(0); + value.setAttachmentJson("[]"); value.setCreatedAt(LocalDateTime.now()); value.setUpdatedAt(value.getCreatedAt()); + try { visits.insert(value); } + catch (DuplicateKeyException exception) { + CustomerVisit concurrent = visits.findByDelivery(delivery.getCustomerId(), delivery.getId()); + if (concurrent != null) return concurrent; + throw exception; + } + return value; + } + + private CustomerVisitResponses.Detail detail(CustomerVisit visit) { + return new CustomerVisitResponses.Detail(row(visit), inboundOptions(visit), visit.getCustomerFeedback(), + visit.getRemark(), visit.getVisitedBy()); + } + + private List inboundOptions(CustomerVisit visit) { + List orders = lastMileOrders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, visit.getCustomerId()) + .eq(LastlegOrder::getDeliveryOrderNo, visit.getDeliveryNo()) + .orderByAsc(LastlegOrder::getInboundCode)); + if (orders.isEmpty()) return List.of(); + List codes = orders.stream().map(LastlegOrder::getInboundCode).filter(StringUtils::hasText).distinct().toList(); + Map byCode = new LinkedHashMap<>(); + if (!codes.isEmpty()) inboundOrders.selectList(new LambdaQueryWrapper() + .eq(LastlegInboundOrder::getCustomerId, visit.getCustomerId()).in(LastlegInboundOrder::getInboundCode, codes)) + .forEach(value -> byCode.putIfAbsent(value.getInboundCode(), value)); + return orders.stream().map(order -> { + LastlegInboundOrder inbound = byCode.get(order.getInboundCode()); + return new CustomerVisitResponses.InboundOption(inbound == null ? null : inbound.getId(), + order.getInboundCode(), order.getId(), order.getOrderNo(), order.getFbaWarehouseCode(), + inbound == null ? null : inbound.getDestinationWarehouseName(), + Objects.requireNonNullElse(order.getPackageCount(), 0)); + }).filter(option -> option.inboundOrderId() != null).toList(); + } + + private List orders(DeliveryOrder delivery) { + return lastMileOrders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, delivery.getCustomerId()) + .eq(LastlegOrder::getDeliveryOrderNo, delivery.getDeliveryNo())); + } + + private CustomerVisitResponses.Row row(CustomerVisit value) { + return new CustomerVisitResponses.Row(value.getId(), value.getCustomerId(), value.getDeliveryNo(), + value.getCustomerName(), value.getContactName(), value.getContactPhone(), value.getDestination(), + value.getDeliveryCompletedAt(), value.getStatus(), value.getSelectedInboundCode(), value.getVisitResult(), + value.getVisitedAt(), Objects.requireNonNullElse(value.getVisitCount(), 0), readAttachments(value.getAttachmentJson())); + } + + private LambdaQueryWrapper filter(String customerId, CustomerVisitRequests.Query query) { + return new LambdaQueryWrapper() + .eq(StringUtils.hasText(customerId), CustomerVisit::getCustomerId, customerId) + .and(StringUtils.hasText(query.keyword()), wrapper -> wrapper + .like(CustomerVisit::getDeliveryNo, trim(query.keyword())) + .or().like(CustomerVisit::getCustomerName, trim(query.keyword())) + .or().like(CustomerVisit::getContactName, trim(query.keyword())) + .or().like(CustomerVisit::getContactPhone, trim(query.keyword()))) + .eq(StringUtils.hasText(query.status()), CustomerVisit::getStatus, trim(query.status())) + .ge(query.completedFrom() != null, CustomerVisit::getDeliveryCompletedAt, query.completedFrom()) + .le(query.completedTo() != null, CustomerVisit::getDeliveryCompletedAt, query.completedTo()); + } + + private CustomerVisit required(String customerId, long visitId) { + CustomerVisit value = visits.selectOne(new LambdaQueryWrapper() + .eq(CustomerVisit::getCustomerId, customerId).eq(CustomerVisit::getId, visitId)); + if (value == null) throw notFound("回访记录不存在"); + return value; + } + + private void recordOperation(DeliveryOrder delivery, String operatorId, String node, String result, LocalDateTime now) { + DeliveryOperation operation = new DeliveryOperation(); + operation.setCustomerId(delivery.getCustomerId()); operation.setDeliveryOrderId(delivery.getId()); + operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); + operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); + } + + private String writeAttachments(List values) { + try { return objectMapper.writeValueAsString(values); } + catch (JsonProcessingException exception) { throw new IllegalStateException("无法保存回访资料", exception); } + } + + private List readAttachments(String value) { + if (!StringUtils.hasText(value)) return List.of(); + try { return objectMapper.readValue(value, new TypeReference<>() { }); } + catch (JsonProcessingException exception) { throw new IllegalStateException("回访资料数据损坏", exception); } + } + + private static void validateFile(MultipartFile file) { + if (file == null || file.isEmpty()) throw badRequest("请选择回访资料文件"); + if (file.getSize() > MAX_FILE_BYTES) throw badRequest("单个回访资料不能超过20MB"); + String name = Objects.requireNonNullElse(file.getOriginalFilename(), ""); + int dot = name.lastIndexOf('.'); + String extension = dot < 0 ? "" : name.substring(dot + 1).toLowerCase(Locale.ROOT); + if (!FILE_EXTENSIONS.contains(extension)) throw badRequest("仅支持PDF、Office、JPG、PNG和WEBP文件"); + } + + private static String customerScope(String scopedCustomerId, String requestedCustomerId) { + if (StringUtils.hasText(scopedCustomerId)) { + if (StringUtils.hasText(requestedCustomerId) && !scopedCustomerId.equals(trim(requestedCustomerId))) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "不能访问其他客户数据"); + } + return scopedCustomerId; + } + return trim(requestedCustomerId); + } + + private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } +} diff --git a/lastleg-service/src/main/resources/application.yml b/lastleg-service/src/main/resources/application.yml index c9eaf0cdc..dfdda1790 100644 --- a/lastleg-service/src/main/resources/application.yml +++ b/lastleg-service/src/main/resources/application.yml @@ -51,6 +51,9 @@ baiyue: relabel-photo-file: connect-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_CONNECT_TIMEOUT:3s} read-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_READ_TIMEOUT:30s} + customer-visit-file: + connect-timeout: ${LASTLEG_CUSTOMER_VISIT_FILE_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_CUSTOMER_VISIT_FILE_READ_TIMEOUT:30s} warehouse-resource: connect-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_CONNECT_TIMEOUT:3s} read-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_READ_TIMEOUT:5s} diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 6d969864d..4c6221194 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1720,3 +1720,33 @@ CREATE TABLE IF NOT EXISTS ll_exception_order ( KEY idx_ll_exception_list(customer_id,status,exception_at), KEY idx_ll_exception_tail(customer_id,last_mile_order_no), KEY idx_ll_exception_inbound(customer_id,inbound_code) ) ENGINE=InnoDB COMMENT='海外仓异常订单'; + +CREATE TABLE IF NOT EXISTS ll_customer_visit ( + id BIGINT NOT NULL COMMENT '客户回访记录ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + delivery_order_id BIGINT NOT NULL COMMENT '关联派送单ID', + delivery_no VARCHAR(64) NOT NULL COMMENT '关联派送单号', + customer_name VARCHAR(255) NULL COMMENT '客户名称快照', + contact_name VARCHAR(128) NULL COMMENT '客户联系人姓名快照', + contact_phone VARCHAR(64) NULL COMMENT '客户联系人电话快照', + destination VARCHAR(500) NULL COMMENT '派送目的仓编码汇总快照', + delivery_completed_at DATETIME(3) NOT NULL COMMENT '派送完成时间', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '回访状态:PENDING待回访或COMPLETED已回访', + selected_last_mile_order_id BIGINT NULL COMMENT '本次回访选择的尾程订单ID', + selected_last_mile_order_no VARCHAR(64) NULL COMMENT '本次回访选择的尾程订单号', + selected_inbound_order_id BIGINT NULL COMMENT '本次回访选择的入仓单ID', + selected_inbound_code VARCHAR(128) NULL COMMENT '本次回访选择的入仓码', + visit_result VARCHAR(32) NULL COMMENT '最近一次回访结果', + customer_feedback VARCHAR(2000) NULL COMMENT '最近一次客户反馈', + remark VARCHAR(1000) NULL COMMENT '最近一次回访备注', + attachment_json JSON NULL COMMENT '当前回访资料文件元数据列表', + visited_by VARCHAR(128) NULL COMMENT '最近一次回访操作人ID', + visited_at DATETIME(3) NULL COMMENT '最近一次回访时间', + visit_count INT NOT NULL DEFAULT 0 COMMENT '累计保存回访次数', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), + UNIQUE KEY uk_ll_customer_visit_delivery(customer_id,delivery_order_id), + KEY idx_ll_customer_visit_list(customer_id,status,delivery_completed_at), + KEY idx_ll_customer_visit_delivery_no(customer_id,delivery_no) +) ENGINE=InnoDB COMMENT='派送单维度客户回访记录'; diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index c246f4826..40ffbc876 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -3865,3 +3865,33 @@ CREATE TABLE IF NOT EXISTS ll_exception_order ( KEY idx_ll_exception_list(customer_id,status,exception_at), KEY idx_ll_exception_tail(customer_id,last_mile_order_no), KEY idx_ll_exception_inbound(customer_id,inbound_code) ) ENGINE=InnoDB COMMENT='海外仓异常订单'; + +CREATE TABLE IF NOT EXISTS ll_customer_visit ( + id BIGINT NOT NULL COMMENT '客户回访记录ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + delivery_order_id BIGINT NOT NULL COMMENT '关联派送单ID', + delivery_no VARCHAR(64) NOT NULL COMMENT '关联派送单号', + customer_name VARCHAR(255) NULL COMMENT '客户名称快照', + contact_name VARCHAR(128) NULL COMMENT '客户联系人姓名快照', + contact_phone VARCHAR(64) NULL COMMENT '客户联系人电话快照', + destination VARCHAR(500) NULL COMMENT '派送目的仓编码汇总快照', + delivery_completed_at DATETIME(3) NOT NULL COMMENT '派送完成时间', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '回访状态:PENDING待回访或COMPLETED已回访', + selected_last_mile_order_id BIGINT NULL COMMENT '本次回访选择的尾程订单ID', + selected_last_mile_order_no VARCHAR(64) NULL COMMENT '本次回访选择的尾程订单号', + selected_inbound_order_id BIGINT NULL COMMENT '本次回访选择的入仓单ID', + selected_inbound_code VARCHAR(128) NULL COMMENT '本次回访选择的入仓码', + visit_result VARCHAR(32) NULL COMMENT '最近一次回访结果', + customer_feedback VARCHAR(2000) NULL COMMENT '最近一次客户反馈', + remark VARCHAR(1000) NULL COMMENT '最近一次回访备注', + attachment_json JSON NULL COMMENT '当前回访资料文件元数据列表', + visited_by VARCHAR(128) NULL COMMENT '最近一次回访操作人ID', + visited_at DATETIME(3) NULL COMMENT '最近一次回访时间', + visit_count INT NOT NULL DEFAULT 0 COMMENT '累计保存回访次数', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), + UNIQUE KEY uk_ll_customer_visit_delivery(customer_id,delivery_order_id), + KEY idx_ll_customer_visit_list(customer_id,status,delivery_completed_at), + KEY idx_ll_customer_visit_delivery_no(customer_id,delivery_no) +) ENGINE=InnoDB COMMENT='派送单维度客户回访记录'; From 5c6c0ef5ca0dcee3660003cb1765a02b7ff7be18 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Wed, 26 Aug 2026 15:51:46 +0800 Subject: [PATCH 10/24] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../delivery/dto/DeliveryRequests.java | 11 +++------ .../service/DeliveryTransferService.java | 24 +++++++++++++++++++ .../service/DeliveryTransferServiceTest.java | 19 ++++++++++----- 3 files changed, 40 insertions(+), 14 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java index 672d64a26..d3058a005 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java @@ -18,8 +18,8 @@ public final class DeliveryRequests { @NotBlank @Size(max = 32) String deliveryMethod, @Size(max = 128) String waybillNo, @Size(max = 255) String carrierName, - @Size(max = 64) String pickupMethod, - @Size(max = 64) String loadingType, + @NotBlank @Size(max = 64) String pickupMethod, + @NotBlank @Size(max = 64) String loadingType, @Size(max = 64) String vehicleType, @Size(max = 64) String vehicleSize, @Size(max = 128) String driverName, @@ -28,12 +28,7 @@ public final class DeliveryRequests { @Size(max = 128) String appointmentAccount, @Size(max = 128) String sellerName, @Size(max = 1000) String remark, - @Valid @Size(max = 20) List fees) { - public Transfer(String idempotencyKey, String remark) { - this(idempotencyKey, LocalDateTime.now(), "SELF_DELIVERY", null, null, null, null, - null, null, null, null, null, null, null, remark, List.of()); - } - } + @NotNull @Valid @Size(min = 1, max = 20) List fees) {} public record FeeItem(@NotBlank @Size(max = 64) String feeType, @NotBlank @Size(max = 8) String currency, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index 0c9d26c6b..4394ab6bd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -100,6 +100,7 @@ public class DeliveryTransferService { throw conflict("All dispatch pallets must be received before transfer to delivery"); } if (!StringUtils.hasText(dispatch.getOverseasWarehouseId())) throw conflict("Dispatch warehouse is required"); + validateTransferFees(request); LocalDateTime now = LocalDateTime.now(); OutboundOrder outbound = new OutboundOrder(); @@ -168,6 +169,29 @@ public class DeliveryTransferService { return result(outbound, delivery, false); } + private void validateTransferFees(DeliveryRequests.Transfer request) { + List inputs = request.fees() == null ? List.of() : request.fees(); + if (inputs.isEmpty()) throw conflict("Delivery fee is required"); + Set allowed = Set.of("DELIVERY_FEE", "LOADING_FEE", "APPOINTMENT_FEE"); + Set seen = new HashSet<>(); + for (DeliveryRequests.FeeItem input : inputs) { + String feeType = input.feeType().trim().toUpperCase(Locale.ROOT); + if (!allowed.contains(feeType)) throw conflict("Unsupported delivery fee type: " + feeType); + if (!seen.add(feeType)) throw conflict("Duplicate delivery fee type: " + feeType); + if (!"USD".equalsIgnoreCase(input.currency().trim())) throw conflict("Delivery fees must use USD"); + if (input.amount().signum() < 0) throw conflict("Delivery fee amount cannot be negative"); + } + if (!seen.contains("DELIVERY_FEE")) throw conflict("Delivery fee is required"); + if (Set.of("FLOOR", "MIXED").contains(request.loadingType().trim().toUpperCase(Locale.ROOT)) + && !seen.contains("LOADING_FEE")) { + throw conflict("Loading fee is required for floor or mixed loading"); + } + if ("BUY_APPOINTMENT".equalsIgnoreCase(trim(request.appointmentAccount()))) { + if (!StringUtils.hasText(request.sellerName())) throw conflict("Seller name is required when buying an appointment"); + if (!seen.contains("APPOINTMENT_FEE")) throw conflict("Appointment fee is required when buying an appointment"); + } + } + @Transactional public DeliveryResponses.PdaTask createOneClick(String customerId, String operatorId, DeliveryRequests.OneClickOutbound request) { OutboundOrder existing = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java index 7b4d63944..c23a505a6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -31,6 +31,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.List; @@ -88,7 +89,7 @@ class DeliveryTransferServiceTest { )); DeliveryResponses.TransferResult result = service.transfer("TENANT-A", "DSP-101", "U-1", - new DeliveryRequests.Transfer("transfer-101", "ready")); + transferRequest("transfer-101", "ready")); ArgumentCaptor outbound = ArgumentCaptor.forClass(OutboundOrder.class); ArgumentCaptor delivery = ArgumentCaptor.forClass(DeliveryOrder.class); @@ -125,7 +126,7 @@ class DeliveryTransferServiceTest { when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery); DeliveryResponses.TransferResult result = service.transfer("TENANT-A", "DSP-101", "U-1", - new DeliveryRequests.Transfer(" transfer-101 ", null)); + transferRequest(" transfer-101 ", null)); assertTrue(result.replayed()); assertEquals("OUT-301", result.outboundNo()); @@ -144,7 +145,7 @@ class DeliveryTransferServiceTest { when(outboundOrders.findByIdempotency("TENANT-A", "shared-key")).thenReturn(other); ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> - service.transfer("TENANT-A", "DSP-102", "U-1", new DeliveryRequests.Transfer("shared-key", null))); + service.transfer("TENANT-A", "DSP-102", "U-1", transferRequest("shared-key", null))); assertEquals(409, exception.getStatusCode().value()); verify(outboundOrders, never()).insert((OutboundOrder) any()); @@ -164,7 +165,7 @@ class DeliveryTransferServiceTest { when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery); DeliveryResponses.TransferResult result = service.transfer("TENANT-A", "DSP-101", "U-1", - new DeliveryRequests.Transfer("transfer-101", null)); + transferRequest("transfer-101", null)); assertTrue(result.replayed()); assertEquals("OUT-301", result.outboundNo()); @@ -185,7 +186,7 @@ class DeliveryTransferServiceTest { when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(source)); when(tailOrders.selectList(any())).thenReturn(List.of(tailOrder)); - service.transfer("TENANT-A", "DSP-101", "U-1", new DeliveryRequests.Transfer("direct-order", null)); + service.transfer("TENANT-A", "DSP-101", "U-1", transferRequest("direct-order", null)); assertEquals("WAIT_OUTBOUND", tailOrder.getCurrentStatus()); assertNotNull(tailOrder.getOutboundOrderNo()); @@ -204,7 +205,7 @@ class DeliveryTransferServiceTest { when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(planned)); ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> - service.transfer("TENANT-A", "DSP-101", "U-1", new DeliveryRequests.Transfer("transfer-101", null))); + service.transfer("TENANT-A", "DSP-101", "U-1", transferRequest("transfer-101", null))); assertEquals(409, exception.getStatusCode().value()); assertTrue(exception.getReason().contains("received")); @@ -248,6 +249,12 @@ class DeliveryTransferServiceTest { return value; } + private static DeliveryRequests.Transfer transferRequest(String idempotencyKey, String remark) { + return new DeliveryRequests.Transfer(idempotencyKey, LocalDateTime.now(), "SELF_DELIVERY", null, null, + "ONE_PICKUP_ONE_DROP", "PALLET", null, null, null, null, null, null, null, remark, + List.of(new DeliveryRequests.FeeItem("DELIVERY_FEE", "USD", BigDecimal.ZERO, null))); + } + private static DispatchItem item(Long id, Long warehousePalletId, String palletNo, String destinationCode, String destinationName, String inboundCode, int packages) { DispatchItem value = new DispatchItem(); From 397575945828ca657596b25d8542d5fbe60f9c58 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Wed, 26 Aug 2026 19:46:14 +0800 Subject: [PATCH 11/24] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/nacos/local/README.md | 11 ++ config/nacos/local/event-bridge-service.yml | 2 + config/nacos/local/gateway-overseas.yml | 16 +++ config/nacos/local/lastleg-service.yml | 30 ++++++ .../local/warehouse-overseas-service.yml | 19 ++++ config/nacos/overseas/README.md | 16 ++- deploy/sql/头程数据库统一增量-MySQL8.sql | 4 + docs/数据库环境切换说明.md | 102 +++++++++--------- .../service/EventInboxService.java | 6 +- .../src/main/resources/application.yml | 2 +- .../service/EventInboxServiceTest.java | 14 +++ .../src/main/resources/application.yml | 4 +- ...urityValidationDeploymentContractTest.java | 30 ++++++ iam/src/main/resources/application.yml | 2 +- .../CustomerVisitMapperConfiguration.java | 9 ++ .../ExceptionOrderMapperConfiguration.java | 9 ++ .../src/main/resources/db/lastleg.sql | 31 +++--- pom.xml | 45 +++++++- scripts/overseas-deployment-contract.test.mjs | 23 ++++ scripts/publish-nacos-config.ps1 | 68 ++++++++++++ scripts/use-nacos-environment.ps1 | 74 +++++++++++++ warehouse-overseas-service/pom.xml | 4 + 22 files changed, 446 insertions(+), 75 deletions(-) create mode 100644 config/nacos/local/gateway-overseas.yml create mode 100644 config/nacos/local/lastleg-service.yml create mode 100644 config/nacos/local/warehouse-overseas-service.yml create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/CustomerVisitMapperConfiguration.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/ExceptionOrderMapperConfiguration.java create mode 100644 scripts/publish-nacos-config.ps1 create mode 100644 scripts/use-nacos-environment.ps1 diff --git a/config/nacos/local/README.md b/config/nacos/local/README.md index 3585527bb..ce6d3e445 100644 --- a/config/nacos/local/README.md +++ b/config/nacos/local/README.md @@ -10,8 +10,19 @@ powershell -ExecutionPolicy Bypass -File D:\baiyue-firstleg\scripts\publish-local-nacos-config.ps1 ``` +也可以使用统一入口;`local` 会被强制限制在 `public` namespace: + +```powershell +powershell -ExecutionPolicy Bypass -File D:\baiyue-firstleg\scripts\publish-nacos-config.ps1 -Environment local +``` + 配置发布成功后,各服务仍由自身的 Nacos Discovery 配置在启动时自动注册,不需要手工创建服务实例。 +本地海外链路也发布在同一 `public / BAIYUE` 作用域,包括 +`gateway-overseas.yml`、`lastleg-service.yml` 和 +`warehouse-overseas-service.yml`。这些配置默认通过 `127.0.0.1` 连接本机 +Auth、国内仓库、文件和海外服务;需要跨主机运行时使用对应环境变量覆盖。 + ## MyBatis SQL 日志 本地公共配置使用 MyBatis `Slf4jImpl`,数据库服务的 Mapper 日志级别默认为 `DEBUG`。执行数据库操作时,控制台会打印 SQL 模板、参数和影响行数。 diff --git a/config/nacos/local/event-bridge-service.yml b/config/nacos/local/event-bridge-service.yml index 2f3d5dd51..e70f98f7f 100644 --- a/config/nacos/local/event-bridge-service.yml +++ b/config/nacos/local/event-bridge-service.yml @@ -5,5 +5,7 @@ spring.datasource: password: ${EVENT_BRIDGE_DB_PASSWORD:123456} baiyue: region: ${REGION_CODE:CN} + event-bridge: + accept-any-region: ${EVENT_BRIDGE_ACCEPT_ANY_REGION:true} deployment-cell: ${DEPLOYMENT_CELL:local-cn} data-residency: ${DATA_RESIDENCY:CN} diff --git a/config/nacos/local/gateway-overseas.yml b/config/nacos/local/gateway-overseas.yml new file mode 100644 index 000000000..ea2ef8cb5 --- /dev/null +++ b/config/nacos/local/gateway-overseas.yml @@ -0,0 +1,16 @@ +springdoc: + api-docs.enabled: ${SWAGGER_ENABLED:true} + swagger-ui.enabled: ${SWAGGER_ENABLED:true} +baiyue: + security: + issuer: ${JWT_ISSUER:auth-service} + audience: ${JWT_AUDIENCE:lastleg-api} + jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} + pda-session-validation: + auth-url: ${AUTH_SESSION_VALIDATION_URL:http://127.0.0.1:9100} + internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} + timeout: ${PDA_SESSION_VALIDATION_TIMEOUT:2s} + overseas-projection: + service-url: ${LASTLEG_SECURITY_SERVICE_URL:http://127.0.0.1:9303} + internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} + timeout: ${LASTLEG_SECURITY_VALIDATION_TIMEOUT:2s} diff --git a/config/nacos/local/lastleg-service.yml b/config/nacos/local/lastleg-service.yml new file mode 100644 index 000000000..4eb5e44fe --- /dev/null +++ b/config/nacos/local/lastleg-service.yml @@ -0,0 +1,30 @@ +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${LASTLEG_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_lastleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} + username: ${LASTLEG_DB_USERNAME:root} + password: ${LASTLEG_DB_PASSWORD:123456} +lastleg: + dispatch-query-api.enabled: ${LASTLEG_DISPATCH_QUERY_API_ENABLED:true} + smart-dispatch-api.enabled: ${LASTLEG_SMART_DISPATCH_API_ENABLED:true} + future-outbound-api.enabled: ${LASTLEG_FUTURE_OUTBOUND_API_ENABLED:true} +baiyue: + clients: + domestic-warehouse: + url: ${DOMESTIC_WAREHOUSE_URL:http://127.0.0.1:9205} + cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m} + file: + url: ${FILE_SERVICE_URL:http://127.0.0.1:9206} + event-bridge: + url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:http://127.0.0.1:9312}} + warehouse: + base-url: ${WAREHOUSE_OVERSEAS_SERVICE_URL:http://127.0.0.1:9305} + firstleg: + event-bridge-url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:http://127.0.0.1:9312}} + security: + internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} + jwt-mode: jwks + session-mode: claims + issuer: ${JWT_ISSUER:auth-service} + audience: ${JWT_AUDIENCE:lastleg-api} + jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} diff --git a/config/nacos/local/warehouse-overseas-service.yml b/config/nacos/local/warehouse-overseas-service.yml new file mode 100644 index 000000000..685994b87 --- /dev/null +++ b/config/nacos/local/warehouse-overseas-service.yml @@ -0,0 +1,19 @@ +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${WAREHOUSE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_lastlag_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} + username: ${WAREHOUSE_DB_USERNAME:root} + password: ${WAREHOUSE_DB_PASSWORD:123456} +baiyue: + clients: + domestic-warehouse: + url: ${DOMESTIC_WAREHOUSE_URL:http://127.0.0.1:9205} + cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m} + internal-service: + token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} + security: + jwt-mode: jwks + session-mode: claims + issuer: ${JWT_ISSUER:auth-service} + audience: ${JWT_AUDIENCE:lastleg-api} + jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} diff --git a/config/nacos/overseas/README.md b/config/nacos/overseas/README.md index 95d89c508..71866504e 100644 --- a/config/nacos/overseas/README.md +++ b/config/nacos/overseas/README.md @@ -1,5 +1,19 @@ # 海外 Nacos 配置 -将本目录文件按原文件名作为 Data ID 发布到 `prod-overseas` 命名空间和 `BAIYUE` 分组。配置只引用环境变量,不保存密码或访问密钥。Gateway 必须同时启用 `overseas` Profile。 +本目录是海外远程部署模板,由 dev/development、test 和 production 共用。配置只引用环境变量,不保存密码或访问密钥;三个环境必须发布到各自独立的 Nacos namespace,Gateway 必须同时启用 `overseas` Profile。 海外入口与仓储 Data ID 分别为 `gateway-overseas.yml`、`warehouse-overseas-service.yml`。`print-service.yml` 对应头程仓库保留的共用打印服务,不包含尾程旧 Print 模块。 + +发布示例: + +```powershell +# 开发环境 +powershell -ExecutionPolicy Bypass -File D:\baiyue-firstleg\scripts\publish-nacos-config.ps1 ` + -Environment development -Region overseas -ServerAddr -Namespace + +# 测试环境 +powershell -ExecutionPolicy Bypass -File D:\baiyue-firstleg\scripts\publish-nacos-config.ps1 ` + -Environment test -Region overseas -ServerAddr -Namespace +``` + +远程环境禁止使用 `public` namespace。数据库、Redis、JWKS、内部服务令牌和跨区服务地址继续由对应 Jenkins Secret file 提供;不要把真实值写入本目录。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 7d593825a..1525162f2 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -16459,6 +16459,10 @@ WHERE role.tenant_id=0 AND role.app_code='PLATFORM' CREATE DATABASE IF NOT EXISTS baiyue_lastleg DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; USE baiyue_lastleg; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_warehouse_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN source_warehouse_id VARCHAR(128) NULL COMMENT ''来源国内装柜仓 ID 快照'' AFTER customer_name')); +PREPARE lastleg_order_source_warehouse_stmt FROM @ddl; EXECUTE lastleg_order_source_warehouse_stmt; DEALLOCATE PREPARE lastleg_order_source_warehouse_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_warehouse_name'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN source_warehouse_name VARCHAR(255) NULL COMMENT ''来源国内装柜仓名称快照'' AFTER source_warehouse_id')); +PREPARE lastleg_order_source_warehouse_stmt FROM @ddl; EXECUTE lastleg_order_source_warehouse_stmt; DEALLOCATE PREPARE lastleg_order_source_warehouse_stmt; CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', diff --git a/docs/数据库环境切换说明.md b/docs/数据库环境切换说明.md index 4a985c6f7..7828dadcf 100644 --- a/docs/数据库环境切换说明.md +++ b/docs/数据库环境切换说明.md @@ -1,66 +1,62 @@ -# 数据库环境切换说明 +# Nacos 环境切换说明 -## 环境定义 +数据库、Redis 和服务地址均由所选环境的 Nacos 配置决定。本地启动时只切换 +Nacos 地址与 namespace,不再通过 `application-test.yml` 单独切换数据库。 -- `dev`:本地开发环境,连接 `127.0.0.1:3306`,默认账号为 `root/123456`。 -- `test`:联调测试环境,连接 `8.163.30.128:3306`,各微服务仍使用独立数据库。 +## 环境映射 -所有服务默认使用 `dev`: +| Maven Profile | Nacos 地址 | Namespace | +| --- | --- | --- | +| `local` | `127.0.0.1:8848` | `public` | +| `dev` | `dev-nacos.buggoglobal.com:8848` | `DEV_NACOS_NAMESPACE` | +| `test` | `8.163.30.128:8848` | `d99d7998-5bb0-4ffe-a2a0-1a564ce14463` | -```yaml -spring: - profiles: - active: ${SPRING_PROFILES_ACTIVE:dev} +本地 Nacos 使用 `nacos/nacos`。开发和测试密码不写入 Git,需在启动 IDEA +之前设置一次用户环境变量: + +```powershell +[Environment]::SetEnvironmentVariable('DEV_NACOS_PASSWORD', '<开发 Nacos 密码>', 'User') +[Environment]::SetEnvironmentVariable('TEST_NACOS_PASSWORD', '<测试 Nacos 密码>', 'User') +[Environment]::SetEnvironmentVariable('DEV_NACOS_NAMESPACE', '<开发海外 namespace>', 'User') ``` -建议不要直接修改配置文件。通过 IDEA 的启动配置设置 `Active profiles` 即可切换: +设置后完全退出并重新打开 IDEA,使 Maven 能读取新环境变量。 -- 本地数据库:`dev` -- 测试数据库:`test` +## IDEA 中切换 -也可以使用启动参数: +1. 在 Maven 面板的 `Profiles` 中,`local`、`dev`、`test` 只勾选一个。 +2. 重新加载 Maven 项目。 +3. 在目标服务的 Maven `Plugins > spring-boot > spring-boot:run` 启动。 -```text ---spring.profiles.active=test +例如海外查询链路需要分别启动 `gateway-overseas`、`lastleg-service` 和 +`warehouse-overseas-service`。已经运行的进程不会自动切换环境,切换 Profile +后必须停止并重新启动。 + +IDEA 普通绿色 Run 按钮不执行 Maven,因此不会读取 Maven Profile。需要继续使用 +绿色 Run 时,请在 Run Configuration 的 Environment variables 中设置 +`NACOS_SERVER_ADDR`、`NACOS_NAMESPACE`、`NACOS_USERNAME` 和 `NACOS_PASSWORD`。 +`gateway-overseas` 已默认启用 `overseas` Spring Profile。 + +## PowerShell 中切换 + +也可以在当前终端加载同一套变量,然后从同一终端启动服务: + +```powershell +. .\scripts\use-nacos-environment.ps1 -Environment local -Region overseas +. .\scripts\use-nacos-environment.ps1 -Environment dev -Region overseas +. .\scripts\use-nacos-environment.ps1 -Environment test -Region overseas ``` -## 测试数据库密码 +脚本读取 `DEV_NACOS_PASSWORD`、`TEST_NACOS_PASSWORD`,namespace 优先读取 +`*_OVERSEAS_NACOS_NAMESPACE`,未设置时读取 `*_NACOS_NAMESPACE`。命令前面的点和 +空格不能省略,否则变量只存在于脚本子进程中。 -测试数据库密码不写入 Git。启动 `test` 环境前,在 IDEA 启动配置的环境变量中设置: +## 当前限制 -```text -TEST_DB_PASSWORD=测试数据库密码 -``` - -公共测试连接参数支持覆盖: - -```text -TEST_DB_HOST=8.163.30.128 -TEST_DB_PORT=3306 -TEST_DB_USERNAME=root -TEST_DB_PASSWORD=测试数据库密码 -``` - -每个服务还支持使用自己的 `*_TEST_DB_URL`、`*_TEST_DB_USERNAME` 和 `*_TEST_DB_PASSWORD` 覆盖公共参数。 - -## 分库映射 - -| 服务 | 测试数据库 | -| --- | --- | -| 认证服务 | `baiyue_auth` | -| 权限服务 | `baiyue_iam` | -| 订单服务 | `baiyue_order` | -| 仓库服务 | `baiyue_warehouse` | -| 头程履约服务 | `baiyue_firstleg` | -| 文件服务 | `baiyue_file` | -| 审计服务 | `baiyue_audit` | -| 事件桥接服务 | `baiyue_event_bridge` | - -网关、打印和集成服务没有业务数据库,但同样支持 `dev/test` Profile。网关在两种环境下都会自动加载 `cn` 路由配置。 - -## 注意事项 - -- `test` 只切换数据库,Nacos 和 Redis 默认仍连接本机。 -- 环境变量优先级高于配置文件,可用于临时覆盖单个服务。 -- 修改 IDEA 环境变量后需要重启对应服务。 -- 不要把测试数据库密码提交到配置文件、启动脚本或 Git。 +- 测试 Nacos 的 `8848` 客户端端口已确认可连接。 +- 开发 Nacos 网页控制台可访问,但 `dev-nacos.buggoglobal.com:8848` 当前从本机 + 无法连接。需要开放 Nacos 客户端端口以及 Nacos 2.x/3.x 所需的 gRPC 端口, + 或提供一个客户端可访问的内部地址。 +- 国内和海外存在同名 Data ID,远程环境必须使用各自独立 namespace。开发海外 + namespace 尚未确认,因此通过 `DEV_NACOS_NAMESPACE` 在本机配置。 +- 不要同时勾选多个环境 Profile;Maven 对同名属性的最终取值不直观,容易连错环境。 diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java index f904c7984..d2fa918f5 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java @@ -31,10 +31,14 @@ public class EventInboxService { @Value("${baiyue.region:CN}") private String localRegion; + /** Local single-bridge mode may carry both regional envelopes; production keeps strict routing. */ + @Value("${baiyue.event-bridge.accept-any-region:false}") + private boolean acceptAnyRegion; + @Transactional public EventReceiveResult receive(EventEnvelope envelope) { envelope.requireValid(); - if (!envelope.targetRegion().name().equalsIgnoreCase(localRegion)) { + if (!acceptAnyRegion && !envelope.targetRegion().name().equalsIgnoreCase(localRegion)) { throw new IllegalArgumentException("事件目标区域与当前事件桥区域不一致"); } if (!EventPayloadHash.matches( diff --git a/event-bridge-service/src/main/resources/application.yml b/event-bridge-service/src/main/resources/application.yml index b20743a41..8b2c46a43 100644 --- a/event-bridge-service/src/main/resources/application.yml +++ b/event-bridge-service/src/main/resources/application.yml @@ -29,7 +29,7 @@ spring: namespace: ${NACOS_NAMESPACE:} baiyue: - region: ${REGION_CODE:OVERSEAS} + region: ${REGION_CODE:CN} event-dispatcher: consumer-url: ${EVENT_CONSUMER_URL:${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events}} firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://127.0.0.1:9202/internal/firstleg/v1/container-events} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java index 6789ed520..0497ce713 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java @@ -125,6 +125,15 @@ class EventInboxServiceTest { .hasMessageContaining("目标区域"); } + @Test + void 本地单实例模式允许接收不同目标区域事件() { + EventInboxService localService = service("CN", true); + EventEnvelope envelope = withHash(envelope("sha256:placeholder")); + + assertThat(localService.receive(envelope).result()).isEqualTo("已接收"); + verify(store).insert(any(BridgeInbox.class)); + } + private EventEnvelope envelope(String hash) { return new EventEnvelope("550e8400-e29b-41d4-a716-446655440000", "OrderApproved", 1, Instant.parse("2026-07-29T08:30:00Z"), RegionCode.CN, RegionCode.OVERSEAS, @@ -133,11 +142,16 @@ class EventInboxServiceTest { } private EventInboxService service(String region) { + return service(region, false); + } + + private EventInboxService service(String region, boolean acceptAnyRegion) { EventInboxService service = new EventInboxService(); ReflectionTestUtils.setField(service, "inboxStore", store); ReflectionTestUtils.setField(service, "objectMapper", objectMapper); ReflectionTestUtils.setField(service, "metrics", metrics); ReflectionTestUtils.setField(service, "localRegion", region); + ReflectionTestUtils.setField(service, "acceptAnyRegion", acceptAnyRegion); return service; } diff --git a/gateway-overseas/src/main/resources/application.yml b/gateway-overseas/src/main/resources/application.yml index d61d0ec19..2d28efe8d 100644 --- a/gateway-overseas/src/main/resources/application.yml +++ b/gateway-overseas/src/main/resources/application.yml @@ -39,9 +39,9 @@ spring: baiyue: security: - issuer: ${JWT_ISSUER:https://auth.example.com} + issuer: ${JWT_ISSUER:auth-service} audience: ${JWT_AUDIENCE:lastleg-api} - jwk-set-uri: ${JWT_JWK_SET_URI:https://auth.example.com/.well-known/jwks.json} + jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} pda-session-validation: auth-url: ${AUTH_SESSION_VALIDATION_URL} internal-service-token: ${INTERNAL_SERVICE_TOKEN} diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasSecurityValidationDeploymentContractTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasSecurityValidationDeploymentContractTest.java index 45bd73e1d..e377d6422 100644 --- a/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasSecurityValidationDeploymentContractTest.java +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasSecurityValidationDeploymentContractTest.java @@ -9,6 +9,36 @@ import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; class OverseasSecurityValidationDeploymentContractTest { + @Test + void gatewayLocalJwtDefaultsMatchAuthAndLastleg() throws Exception { + String gatewayApplication = read("gateway-overseas/src/main/resources/application.yml"); + String lastlegApplication = read("lastleg-service/src/main/resources/application.yml"); + String localGateway = read("config/nacos/local/gateway-overseas.yml"); + String localLastleg = read("config/nacos/local/lastleg-service.yml"); + String localWarehouse = read("config/nacos/local/warehouse-overseas-service.yml"); + String remoteGateway = read("config/nacos/overseas/gateway-overseas.yml"); + String remoteLastleg = read("config/nacos/overseas/lastleg-service.yml"); + String remoteWarehouse = read("config/nacos/overseas/warehouse-overseas-service.yml"); + + for (String source : new String[]{gatewayApplication, lastlegApplication, localGateway, localLastleg, localWarehouse}) { + assertThat(source) + .contains("${JWT_ISSUER:auth-service}") + .contains("${JWT_AUDIENCE:lastleg-api}") + .contains("${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}"); + } + assertThat(gatewayApplication).doesNotContain("auth.example.com"); + assertThat(localGateway) + .contains("${AUTH_SESSION_VALIDATION_URL:http://127.0.0.1:9100}") + .contains("${LASTLEG_SECURITY_SERVICE_URL:http://127.0.0.1:9303}"); + assertThat(localLastleg).contains("${WAREHOUSE_OVERSEAS_SERVICE_URL:http://127.0.0.1:9305}"); + assertThat(localWarehouse).contains("${DOMESTIC_WAREHOUSE_URL:http://127.0.0.1:9205}"); + assertThat(remoteGateway + remoteLastleg + remoteWarehouse) + .doesNotContain("127.0.0.1", "root", "123456"); + assertThat(remoteGateway).contains("${LASTLEG_SECURITY_SERVICE_URL:http://lastleg-service:9303}"); + assertThat(remoteLastleg).contains("${LASTLEG_DB_URL}", "${LASTLEG_DB_USERNAME}", "${LASTLEG_DB_PASSWORD}"); + assertThat(remoteWarehouse).contains("${WAREHOUSE_DB_URL}", "${WAREHOUSE_DB_USERNAME}", "${WAREHOUSE_DB_PASSWORD}"); + } + @Test void gatewayAndEventBridgeUseLastlegSecurityEndpointsWithoutTheRetiredService() throws Exception { String client = read("gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java"); diff --git a/iam/src/main/resources/application.yml b/iam/src/main/resources/application.yml index 5faf2522f..1cde658aa 100644 --- a/iam/src/main/resources/application.yml +++ b/iam/src/main/resources/application.yml @@ -24,7 +24,7 @@ spring: encode: UTF-8 baiyue: security-event: - event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312} + event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://127.0.0.1:9312} customer-dimension-initialization: fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000} max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/CustomerVisitMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/CustomerVisitMapperConfiguration.java new file mode 100644 index 000000000..b7749cf47 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/CustomerVisitMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.customervisit; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.customervisit.mapper") +public class CustomerVisitMapperConfiguration { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/ExceptionOrderMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/ExceptionOrderMapperConfiguration.java new file mode 100644 index 000000000..432f87726 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/ExceptionOrderMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.exceptionorder; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.exceptionorder.mapper") +public class ExceptionOrderMapperConfiguration { +} diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index f40b89f94..45c775f1e 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -244,6 +244,10 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE lastleg_order_container_booking_stmt FROM @ddl; EXECUTE lastleg_order_container_booking_stmt; DEALLOCATE PREPARE lastleg_order_container_booking_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER order_type')); PREPARE lastleg_order_destination_type_stmt FROM @ddl; EXECUTE lastleg_order_destination_type_stmt; DEALLOCATE PREPARE lastleg_order_destination_type_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_warehouse_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN source_warehouse_id VARCHAR(128) NULL COMMENT ''来源国内装柜仓 ID 快照'' AFTER customer_name')); +PREPARE lastleg_order_source_warehouse_stmt FROM @ddl; EXECUTE lastleg_order_source_warehouse_stmt; DEALLOCATE PREPARE lastleg_order_source_warehouse_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_warehouse_name'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN source_warehouse_name VARCHAR(255) NULL COMMENT ''来源国内装柜仓名称快照'' AFTER source_warehouse_id')); +PREPARE lastleg_order_source_warehouse_stmt FROM @ddl; EXECUTE lastleg_order_source_warehouse_stmt; DEALLOCATE PREPARE lastleg_order_source_warehouse_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER destination_warehouse_name')); PREPARE lastleg_inbound_destination_type_stmt FROM @ddl; EXECUTE lastleg_inbound_destination_type_stmt; DEALLOCATE PREPARE lastleg_inbound_destination_type_stmt; ALTER TABLE ll_order_detail COMMENT='尾程订单实装商品冻结明细'; @@ -524,7 +528,6 @@ ALTER TABLE ll_inbound_plan_line MODIFY COLUMN plan_id BIGINT NOT NULL COMMENT '入库计划ID', MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '尾程订单ID', MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL COMMENT '头程入仓码快照', - MODIFY COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT 'OVERSEAS' COMMENT '目的类型:FBA按箱数接收、OVERSEAS逐箱扫码', MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', @@ -726,19 +729,17 @@ UPDATE ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan p ON p.id=a.plan ALTER TABLE ll_inbound_plan_pallet_allocation MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识'; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND index_name='idx_ll_inbound_allocation_customer_plan'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD KEY idx_ll_inbound_allocation_customer_plan(customer_id,plan_id)')); PREPARE inbound_customer_scope_stmt FROM @ddl; EXECUTE inbound_customer_scope_stmt; DEALLOCATE PREPARE inbound_customer_scope_stmt; -DROP PROCEDURE IF EXISTS assert_inbound_plan_customer_scope_ready; -DELIMITER $$ -CREATE PROCEDURE assert_inbound_plan_customer_scope_ready() -BEGIN - IF EXISTS (SELECT 1 FROM ll_inbound_plan_line l LEFT JOIN ll_inbound_plan p ON p.id=l.plan_id WHERE l.customer_id IS NULL OR l.customer_id='' OR p.id IS NULL OR l.customer_id<>p.customer_id) - OR EXISTS (SELECT 1 FROM ll_inbound_plan_pallet v LEFT JOIN ll_inbound_plan p ON p.id=v.plan_id WHERE v.customer_id IS NULL OR v.customer_id='' OR p.id IS NULL OR v.customer_id<>p.customer_id) - OR EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation a LEFT JOIN ll_inbound_plan p ON p.id=a.plan_id WHERE a.customer_id IS NULL OR a.customer_id='' OR p.id IS NULL OR a.customer_id<>p.customer_id) THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Inbound plan child customer scope migration is incomplete'; - END IF; -END$$ -DELIMITER ; -CALL assert_inbound_plan_customer_scope_ready(); -DROP PROCEDURE assert_inbound_plan_customer_scope_ready; +DROP TEMPORARY TABLE IF EXISTS inbound_plan_customer_scope_assertion; +CREATE TEMPORARY TABLE inbound_plan_customer_scope_assertion ( + is_valid TINYINT NOT NULL, + CONSTRAINT chk_inbound_plan_customer_scope_ready CHECK (is_valid=1) +); +INSERT INTO inbound_plan_customer_scope_assertion(is_valid) +SELECT 0 +WHERE EXISTS (SELECT 1 FROM ll_inbound_plan_line l LEFT JOIN ll_inbound_plan p ON p.id=l.plan_id WHERE l.customer_id IS NULL OR l.customer_id='' OR p.id IS NULL OR l.customer_id<>p.customer_id) + OR EXISTS (SELECT 1 FROM ll_inbound_plan_pallet v LEFT JOIN ll_inbound_plan p ON p.id=v.plan_id WHERE v.customer_id IS NULL OR v.customer_id='' OR p.id IS NULL OR v.customer_id<>p.customer_id) + OR EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation a LEFT JOIN ll_inbound_plan p ON p.id=a.plan_id WHERE a.customer_id IS NULL OR a.customer_id='' OR p.id IS NULL OR a.customer_id<>p.customer_id); +DROP TEMPORARY TABLE inbound_plan_customer_scope_assertion; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='destination_type'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS'' COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN destination_type VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS'' COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER inbound_code')); PREPARE receiving_container_line_stmt FROM @ddl; EXECUTE receiving_container_line_stmt; DEALLOCATE PREPARE receiving_container_line_stmt; UPDATE ll_inbound_plan_line l JOIN ll_order o ON o.id=l.order_id @@ -1191,7 +1192,7 @@ CREATE TABLE IF NOT EXISTS ll_outbound_pda_pick ( SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='outbound_type'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN outbound_type VARCHAR(32) NOT NULL DEFAULT ''DISPATCH_DELIVERY'' COMMENT ''出库类型'' AFTER status')); PREPARE outbound_upgrade_stmt FROM @ddl; EXECUTE outbound_upgrade_stmt; DEALLOCATE PREPARE outbound_upgrade_stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='sku_code'), 'SELECT 1', 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN sku_code VARCHAR(128) NULL COMMENT ''一件代发SKU'' AFTER status')); +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='sku_code'), 'SELECT 1', 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN sku_code VARCHAR(128) NULL COMMENT ''一件代发SKU''')); PREPARE outbound_upgrade_stmt FROM @ddl; EXECUTE outbound_upgrade_stmt; DEALLOCATE PREPARE outbound_upgrade_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='recipient_info'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN recipient_info VARCHAR(1000) NULL COMMENT ''一件代发收件信息'' AFTER outbound_type')); PREPARE outbound_upgrade_stmt FROM @ddl; EXECUTE outbound_upgrade_stmt; DEALLOCATE PREPARE outbound_upgrade_stmt; diff --git a/pom.xml b/pom.xml index 20134475d..1548f8f1b 100644 --- a/pom.xml +++ b/pom.xml @@ -69,6 +69,10 @@ 4.9.8.5 1.5.0 2.3.3 + 127.0.0.1:8848 + public + nacos + nacos @@ -128,7 +132,19 @@ - org.springframework.bootspring-boot-maven-plugin + + org.springframework.boot + spring-boot-maven-plugin + + + ${nacos.server-addr} + ${nacos.namespace} + BAIYUE + ${nacos.username} + ${nacos.password} + + + org.apache.maven.pluginsmaven-compiler-plugin ${maven.compiler.plugin.version} @@ -170,6 +186,33 @@ + + local + + 127.0.0.1:8848 + public + nacos + nacos + + + + dev + + dev-nacos.buggoglobal.com:8848 + ${env.DEV_NACOS_NAMESPACE} + nacos + ${env.DEV_NACOS_PASSWORD} + + + + test + + 8.163.30.128:8848 + d99d7998-5bb0-4ffe-a2a0-1a564ce14463 + nacos + ${env.TEST_NACOS_PASSWORD} + + static-analysis-report diff --git a/scripts/overseas-deployment-contract.test.mjs b/scripts/overseas-deployment-contract.test.mjs index 72155a2ea..3f4ddc974 100644 --- a/scripts/overseas-deployment-contract.test.mjs +++ b/scripts/overseas-deployment-contract.test.mjs @@ -5,6 +5,29 @@ import test from 'node:test' const read = async (path) => (await readFile(new URL(`../${path}`, import.meta.url), 'utf8')) .replaceAll('\r\n', '\n') +test('local, development and test Nacos publishing remain environment-isolated', async () => { + const publisher = await read('scripts/publish-nacos-config.ps1') + const local = [ + await read('config/nacos/local/gateway-overseas.yml'), + await read('config/nacos/local/lastleg-service.yml'), + await read('config/nacos/local/warehouse-overseas-service.yml'), + ].join('\n') + const remote = [ + await read('config/nacos/overseas/gateway-overseas.yml'), + await read('config/nacos/overseas/lastleg-service.yml'), + await read('config/nacos/overseas/warehouse-overseas-service.yml'), + ].join('\n') + + assert.match(publisher, /ValidateSet\('local', 'dev', 'development', 'test', 'production'\)/) + assert.match(publisher, /必须显式指定非 public 的 Nacos namespace/) + assert.match(local, /127\.0\.0\.1:9100/) + assert.match(local, /127\.0\.0\.1:9303/) + assert.doesNotMatch(remote, /127\.0\.0\.1|password:\s*123456/) + assert.match(remote, /http:\/\/lastleg-service:9303/) + assert.match(remote, /\$\{LASTLEG_DB_URL\}/) + assert.match(remote, /\$\{WAREHOUSE_DB_URL\}/) +}) + test('Jenkins pipelines recognize all overseas deployable modules', async () => { const controller = await read('Jenkinsfile') const service = await read('Jenkinsfile.service') diff --git a/scripts/publish-nacos-config.ps1 b/scripts/publish-nacos-config.ps1 new file mode 100644 index 000000000..0231e4130 --- /dev/null +++ b/scripts/publish-nacos-config.ps1 @@ -0,0 +1,68 @@ +param( + [ValidateSet('local', 'dev', 'development', 'test', 'production')] + [string]$Environment, + [ValidateSet('cn', 'overseas')] + [string]$Region = 'cn', + [string]$ServerAddr = '127.0.0.1:8848', + [string]$Group = 'BAIYUE', + [string]$Namespace +) + +$ErrorActionPreference = 'Stop' +$projectRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) + +if ($Environment -eq 'local') { + if ($Namespace -and $Namespace -ne 'public') { + throw 'local 环境只能发布到 public namespace' + } + $Namespace = 'public' + $configProfile = 'local' +} else { + if (-not $Namespace -or $Namespace -eq 'public') { + throw "$Environment/$Region 必须显式指定非 public 的 Nacos namespace" + } + $configProfile = $Region +} + +$configRoot = (Resolve-Path (Join-Path $projectRoot "config\nacos\$configProfile")).Path +$healthUrl = "http://$ServerAddr/nacos/v1/console/health/readiness" +$publishUrl = "http://$ServerAddr/nacos/v1/cs/configs" +$health = Invoke-WebRequest -UseBasicParsing -Uri $healthUrl -TimeoutSec 5 +if ($health.StatusCode -ne 200 -or $health.Content.Trim() -ne 'OK') { + throw "Nacos not ready: $healthUrl" +} + +$files = Get-ChildItem -LiteralPath $configRoot -File -Filter '*.yml' | Sort-Object Name +if ($files.Count -eq 0) { + throw "No Nacos configuration found: $configRoot" +} + +$published = 0 +foreach ($file in $files) { + $resolvedFile = (Resolve-Path -LiteralPath $file.FullName).Path + if (-not $resolvedFile.StartsWith($configRoot, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Unexpected config path: $resolvedFile" + } + $body = @{ + dataId = $file.Name + group = $Group + content = Get-Content -LiteralPath $resolvedFile -Raw -Encoding UTF8 + type = 'yaml' + } + if ($Namespace -ne 'public') { + $body.tenant = $Namespace + } + $pairs = foreach ($entry in $body.GetEnumerator()) { + [uri]::EscapeDataString([string]$entry.Key) + '=' + [uri]::EscapeDataString([string]$entry.Value) + } + $result = Invoke-RestMethod -Method Post -Uri $publishUrl ` + -ContentType 'application/x-www-form-urlencoded; charset=UTF-8' ` + -Body ($pairs -join '&') -TimeoutSec 10 + if ([string]$result -ne 'true') { + throw "Publish failed: $($file.Name), Nacos response: $result" + } + $published++ + Write-Host "Published $($file.Name) -> $Namespace / $Group" +} + +Write-Host "Published $Environment/$Region Nacos configuration: $published items." diff --git a/scripts/use-nacos-environment.ps1 b/scripts/use-nacos-environment.ps1 new file mode 100644 index 000000000..ee5f64734 --- /dev/null +++ b/scripts/use-nacos-environment.ps1 @@ -0,0 +1,74 @@ +param( + [ValidateSet('local', 'dev', 'development', 'test')] + [string]$Environment, + [ValidateSet('cn', 'overseas')] + [string]$Region = 'cn', + [string]$NacosServerAddr, + [string]$NacosNamespace, + [string]$NacosUsername, + [string]$NacosPassword +) + +$ErrorActionPreference = 'Stop' +$target = if ($Environment -eq 'development') { 'dev' } else { $Environment } + +$defaults = @{ + local = @{ Server = '127.0.0.1:8848'; CnNamespace = 'public' } + dev = @{ Server = '8.163.49.32:8848'; CnNamespace = $env:DEV_NACOS_NAMESPACE } + test = @{ Server = '8.163.30.128:8848'; CnNamespace = 'd99d7998-5bb0-4ffe-a2a0-1a564ce14463' } +} +$selected = $defaults[$target] +$serverAddr = if ($NacosServerAddr) { $NacosServerAddr } else { $selected.Server } +$namespace = if ($NacosNamespace) { + $NacosNamespace +} elseif ($target -eq 'local') { + 'public' +} elseif ($Region -eq 'overseas') { + $regionNamespace = [Environment]::GetEnvironmentVariable("$($target.ToUpperInvariant())_OVERSEAS_NACOS_NAMESPACE") + if ($regionNamespace) { + $regionNamespace + } else { + [Environment]::GetEnvironmentVariable("$($target.ToUpperInvariant())_NACOS_NAMESPACE") + } +} else { + $selected.CnNamespace +} +$username = if ($NacosUsername) { + $NacosUsername +} elseif ([Environment]::GetEnvironmentVariable("$($target.ToUpperInvariant())_NACOS_USERNAME")) { + [Environment]::GetEnvironmentVariable("$($target.ToUpperInvariant())_NACOS_USERNAME") +} else { + 'nacos' +} +$password = if ($NacosPassword) { + $NacosPassword +} elseif ([Environment]::GetEnvironmentVariable("$($target.ToUpperInvariant())_NACOS_PASSWORD")) { + [Environment]::GetEnvironmentVariable("$($target.ToUpperInvariant())_NACOS_PASSWORD") +} elseif ($target -eq 'local') { + 'nacos' +} else { + $null +} + +if (-not $namespace) { + throw "$target/$Region 必须通过 -NacosNamespace 或对应的 *_NACOS_NAMESPACE 环境变量指定 namespace" +} +if (-not $password) { + throw "$target 必须通过 -NacosPassword 或 $($target.ToUpperInvariant())_NACOS_PASSWORD 提供 Nacos 密码" +} + +$env:NACOS_SERVER_ADDR = $serverAddr +$env:NACOS_NAMESPACE = $namespace +$env:NACOS_GROUP = 'BAIYUE' +$env:NACOS_USERNAME = $username +$env:NACOS_PASSWORD = $password +$env:SPRING_CLOUD_NACOS_CONFIG_NAMESPACE = $namespace +$env:SPRING_CLOUD_NACOS_DISCOVERY_NAMESPACE = $namespace +$env:SPRING_PROFILES_ACTIVE = if ($Region -eq 'overseas') { 'overseas' } elseif ($target -eq 'local') { 'dev' } else { 'cn' } +$env:BAIYUE_RUNTIME_ENVIRONMENT = $target +$env:BAIYUE_RUNTIME_REGION = $Region + +Write-Host "Runtime environment: $target / $Region" +Write-Host "Nacos: $serverAddr; namespace: $namespace; group: BAIYUE" +Write-Host "Spring profiles: $env:SPRING_PROFILES_ACTIVE" +Write-Host 'Nacos password is loaded into the current process and is not printed.' diff --git a/warehouse-overseas-service/pom.xml b/warehouse-overseas-service/pom.xml index acce7f683..6217a9f92 100644 --- a/warehouse-overseas-service/pom.xml +++ b/warehouse-overseas-service/pom.xml @@ -44,6 +44,10 @@ org.springframework.cloud spring-cloud-starter-openfeign + + org.springframework.cloud + spring-cloud-starter-loadbalancer + com.alibaba.cloud spring-cloud-starter-alibaba-nacos-config From a79999d97c319df67d3bc00eb7fb276a6192ed86 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Thu, 27 Aug 2026 13:35:52 +0800 Subject: [PATCH 12/24] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/LoadingDeliveryService.java | 3 +- .../phase3-container-loading.mjs | 1 + .../admin-test-env-modules/phase3-loading.mjs | 1 + settings-central.xml | 12 +++ .../PlatformWarehouseDirectoryController.java | 82 +++++++++++++++++++ .../WarehouseResourceRepository.java | 12 +++ .../service/WarehouseResourceService.java | 5 ++ ...tformWarehouseDirectoryControllerTest.java | 33 ++++++++ ...rehouseResourcePermissionContractTest.java | 7 ++ 9 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 settings-central.xml create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseDirectoryController.java create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseDirectoryControllerTest.java diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java index 52467a049..dba2585a6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java @@ -182,7 +182,8 @@ public class LoadingDeliveryService { } loadingModelBridge.requireCompleted(tenantId, target.sourceInboundId()); completionGate.requireReleasedBooking(tenantId, target.sourceInboundId()); - requireCompletedPicking(tenantId, target.sourceInboundId()); + // The cabinet PDA task is the source of truth while settling. The inbound-level + // picking review is completed after this transition and must not gate it here. requireCompletionEvidence(tenantId, evidenceInboundId); if (repository.completeLoadedChild(tenantId, target.sourceInboundId(), operatorId, request.expectedVersion()) != 1) throw concurrent(); diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs index 5a09b0a54..e45f3519c 100644 --- a/scripts/admin-test-env-modules/phase3-container-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs @@ -160,6 +160,7 @@ export async function run(context) { channelCode, warehouseId, billOfLadingNo: `BL-${suffix}`.slice(0, 128), shippingCompanyId: id(bookingAgent.id), shippingCompanyName: bookingAgent.name, + carrierCode: 'MATSON', bookingAgentId: id(bookingAgent.id), bookingAgentName: bookingAgent.name, vesselName: `验收船-${suffix}`.slice(0, 128), voyageNo: `V-${suffix}`.slice(0, 64), departurePortCode: 'CNSZX', departurePortName: '深圳', diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 060f643d0..988f70afe 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -176,6 +176,7 @@ export async function run(context) { channelCode: initial?.summary?.channelCode, billOfLadingNo: `P3BL-${suffix}`.slice(0, 128), shippingCompanyId: id(shippingCarrier.id), shippingCompanyName: shippingCarrier.name, + carrierCode: 'MATSON', bookingAgentId: id(supplier.id), bookingAgentName: supplier.name, vesselName: `P3VESSEL-${suffix}`.slice(0, 128), voyageNo: `P3V-${suffix}`.slice(0, 64), departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', diff --git a/settings-central.xml b/settings-central.xml new file mode 100644 index 000000000..d89b6314e --- /dev/null +++ b/settings-central.xml @@ -0,0 +1,12 @@ + + + D:/firstlegRepo + + + central-direct + Maven Central + https://repo.maven.apache.org/maven2 + external:* + + + diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseDirectoryController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseDirectoryController.java new file mode 100644 index 000000000..b3f271267 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseDirectoryController.java @@ -0,0 +1,82 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.warehouse.security.OverseasWarehousePermissions; +import com.baiyue.warehouse.dto.PalletRequests; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.service.WarehouseResourceService; +import com.baiyue.warehouse.service.DomesticWarehouseDirectory; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import jakarta.validation.Valid; + +import java.util.List; + +/** Exposes the domestic authoritative directory as read-only overseas resource options. */ +@RestController +@RequestMapping("/api/platform/v1/warehouse-admin") +@Tag(name = "Platform overseas warehouse directory") +public class PlatformWarehouseDirectoryController { + private final DomesticWarehouseDirectory directory; + private final WarehouseResourceService resources; + + public PlatformWarehouseDirectoryController(DomesticWarehouseDirectory directory, + WarehouseResourceService resources) { + this.directory = directory; + this.resources = resources; + } + + @GetMapping("/pallets") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.PALLET_LIST + "')") + @Operation(summary = "Page public pallets across warehouses", + description = "Required authority: " + OverseasWarehousePermissions.PALLET_LIST) + public ApiResponse> pallets( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid PalletRequests.QueryRequest query) { + requirePlatform(user); + return ApiResponse.success(resources.publicPallets(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, query)); + } + + @GetMapping + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.WAREHOUSE_LIST + "')") + @Operation(summary = "Page shared overseas receiving warehouses", + description = "Required authority: " + OverseasWarehousePermissions.WAREHOUSE_LIST) + public ApiResponse> warehouses( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(defaultValue = "1") long page, + @RequestParam(defaultValue = "200") long size) { + requirePlatform(user); + if (page < 1 || size < 1 || size > 200) { + throw new BizException(ResultCode.BAD_REQUEST, "分页参数无效"); + } + List all = directory.options().stream() + .map(value -> new WarehouseOption(value.warehouseId(), 0L, value.warehouseCode(), + value.warehouseName(), "OVERSEAS", value.status())) + .toList(); + long from = Math.min((page - 1) * size, all.size()); + long to = Math.min(from + size, all.size()); + long pages = all.isEmpty() ? 0 : (all.size() + size - 1) / size; + return ApiResponse.success(new PageResult<>(all.subList((int) from, (int) to), + page, size, all.size(), pages)); + } + + private static void requirePlatform(AuthenticatedUser user) { + if (user == null || !"PLATFORM".equals(user.appCode()) + || user.tenantId() == null || user.tenantId() != 0L) { + throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问"); + } + } + + public record WarehouseOption(long id, long customerId, String warehouseCode, + String warehouseName, String warehouseType, String status) { } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java index 32b854ea8..9ebed969c 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java @@ -146,6 +146,18 @@ public class WarehouseResourceRepository { return PageResult.from(pallets.selectPage(new Page<>(query.page(), query.size()), wrapper)); } + public PageResult pagePublicPallets(long customerId, + PalletRequests.QueryRequest query) { + var wrapper = new LambdaQueryWrapper() + .eq(WarehousePalletEntity::getCustomerId, customerId) + .eq(StringUtils.hasText(query.palletType()), WarehousePalletEntity::getPalletType, query.palletType()) + .eq(StringUtils.hasText(query.status()), WarehousePalletEntity::getStatus, query.status()) + .eq(query.locationId() != null, WarehousePalletEntity::getLocationId, query.locationId()) + .like(StringUtils.hasText(query.keyword()), WarehousePalletEntity::getPalletCode, query.keyword()) + .orderByDesc(WarehousePalletEntity::getCreatedAt); + return PageResult.from(pallets.selectPage(new Page<>(query.page(), query.size()), wrapper)); + } + public void deletePallet(long id) { pallets.deleteById(id); } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java index d44e16759..f4c47176f 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java @@ -218,6 +218,11 @@ public class WarehouseResourceService { return repository.pagePallets(customerId, warehouseId, query); } + public PageResult publicPallets(long customerId, + PalletRequests.QueryRequest query) { + return repository.pagePublicPallets(customerId, query); + } + @Transactional public void deletePallet(long customerId, long warehouseId, long id) { WarehousePalletEntity pallet = requirePallet(customerId, warehouseId, id); diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseDirectoryControllerTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseDirectoryControllerTest.java new file mode 100644 index 000000000..ae3f2b09e --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseDirectoryControllerTest.java @@ -0,0 +1,33 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.warehouse.dto.SharedWarehouseReference; +import com.baiyue.warehouse.service.DomesticWarehouseDirectory; +import com.baiyue.warehouse.service.WarehouseResourceService; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class PlatformWarehouseDirectoryControllerTest { + @Test + void pagesPublicSharedWarehouseOptions() { + DomesticWarehouseDirectory directory = mock(DomesticWarehouseDirectory.class); + when(directory.options()).thenReturn(List.of( + new SharedWarehouseReference(21L, "US-LAX", "洛杉矶仓", "ACTIVE"))); + var controller = new PlatformWarehouseDirectoryController(directory, mock(WarehouseResourceService.class)); + var user = new AuthenticatedUser(1L, 0L, 0L, 2L, + "tester", "PLATFORM", "PLATFORM", Set.of()); + + var page = controller.warehouses(user, 1, 200).data(); + + assertEquals(1, page.total()); + assertEquals(21L, page.records().get(0).id()); + assertEquals(0L, page.records().get(0).customerId()); + assertEquals("OVERSEAS", page.records().get(0).warehouseType()); + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java index 65588f077..c147dad87 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java @@ -32,6 +32,13 @@ class WarehouseResourcePermissionContractTest { assertPermission(methods.get("deletePallet"), PREFIX + "pallet:delete"); } + @Test + void warehouseDirectoryUsesCanonicalListAuthority() throws Exception { + assertPermission(PlatformWarehouseDirectoryController.class.getDeclaredMethod( + "warehouses", com.baiyue.common.security.AuthenticatedUser.class, long.class, long.class), + PREFIX + "warehouse:list"); + } + private static void assertPermission(Method method, String authority) { PreAuthorize preAuthorize = method.getAnnotation(PreAuthorize.class); Operation operation = method.getAnnotation(Operation.class); From 8d39c618636de7713954561eed1d82d1ffb00e1a Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Thu, 27 Aug 2026 16:54:03 +0800 Subject: [PATCH 13/24] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .runtime-tailleg/SchemaQuery.class | Bin 0 -> 3162 bytes deploy/sql/头程数据库统一增量-MySQL8.sql | 10 ++++ .../service/DeliveryTransferService.java | 4 +- .../DispatchPickOrderAttributionService.java | 1 + .../mapper/InventoryCommandMapper.java | 9 ++++ .../service/InventoryCommandService.java | 34 ++++++++++++- .../service/DeliveryTransferServiceTest.java | 4 +- ...spatchPickOrderAttributionServiceTest.java | 4 +- .../command/InventoryCommandServiceTest.java | 46 ++++++++++++++++++ scripts/db/incremental.sql | 14 ++++++ 10 files changed, 119 insertions(+), 7 deletions(-) create mode 100644 .runtime-tailleg/SchemaQuery.class diff --git a/.runtime-tailleg/SchemaQuery.class b/.runtime-tailleg/SchemaQuery.class new file mode 100644 index 0000000000000000000000000000000000000000..1d07fe14d380f3d98b83bd564c59773a5d9a0453 GIT binary patch literal 3162 zcma)8T~iy^8Ga5Rt+XtL$bh|W63dQ*K^PR{#&v~>ivcGz!gef78oOz`NXM|S+LiYM zoAkqJlh8J4>hwG5v^Q;Ld|P*>#5gnYO()aIRXfwG{)7I4wwW~Ub0iR8)J|tW=j^-B zdEW2$>_?y8{Q$rSexaiY%^F$^#1I$gSg@9?tYf)z*>kfCvKR`)Pui{>J|)oH+c%>j zA#iXLH0d~U&T>Zmxu`7NaN>GVRzllz6VL_5qrjSzxpbj8FU!_*k@T0-C+EE&d@6mi zWQEqO6-aKQK>7pHUE;Q41;I7XFV!9|2UndJb!ae@b!`G&z4=Ymh0wR%x#7MU9qs7Q z@Q{HNb_=v_QO74=UX5&DmL}XKfu2SLJ2?~R5=ay#@)Kjz>7yw|YL9`vFa`FMtwlNJ zxkW2{-VW!v3qs2c1A!9_sCP2=?G)+w3?A0-h=E73U!c3{AwAEI`}UIbr!1F2^aa}H zWJrB3lfnQCO#NR9cj{X=L21~e8*aq`9MtfbfkSv)V0Yb$O^iT$(U(>z3n710x@m3i z7P`8eY=_aSq0c}D{Q{{vTOA^x$?LKhh4R0o^->Z87}St8@C1%-tIWc3K>aL8FQN%u zH5Y9!d!7XmvMi)ku3B)+z-RHKz%EAaVucmQ%tgv-R`O&D0-sY^J)sPkY?zoFhBcft z*e5K$T1d95p%)dU)Nz%^rwn{SHBqeSc!5+AJ*C8B2F5WVV9<;UGKie8ARi1}T~5QF z#xoiw4SW&LZVg=>!lEYAlnkwLHj%)iTXRrHZM-Mp6wYcmXW+aF#=d$kc0g;98uOf} z>{cU{zy$%3)RDuqfr}~%v8um{^LYbbQo&Qvt)0nRv(i!EODbe><;j-~yoj#|w7Bwm zE%29=_-h6%%nBrVc4N*wkc66eSVsv`!xaN_nBQIn)&6G&mMkZd=dL`^-RtKYGaMggX=1-hH zahfT^Lj#fWUbBPK)Uuf3u)U^gjI8}#lB zjbaZK7pJUB6|do2^rHb(K(9~ZK*P7Ud0T%&RKbhHj_cVN8`SQBJp=wQ4<7m4}-lELbhBIde2jv;77<&G)z}s8Hw+^h4 zK4ZBhM+S%Tp0^lPhIcx?MY8SS^m0WufNzaQgF;xR7_%IwV25%zfgcFGvQU~W=E}<) zjJa%9@f z5_n5sf7uQK>PoY)Y~OR$@J%mSzO70omlhl@le`)HF^3nZWV(MM@c53TG>Wx`pK*fx zZzyZJmKYr6Rolet5NPK3n*>pVzk^B9k| z67cKH2ikAY_CB;1QpxwwdM^_jx`SQ$Ok6EfeETkv8ExnmVyVuvndYHP%g|kP1GyHn zrT@qs?90W>*c#FTxwsi0SVK<}j%!`oFERLg95Lf-7!tUP-GARqe29!9vjuJ;ol7b~cU@rQ zXq>69!ZDNglv|l~w-i=Wg6_J&pj(=mP+0Arayzr`w!+4hpt~+G)L~Oo*!aEMNZ_W} zD-L5p42mbk2@b_P%A&w^MU1OeToRTj@p%l(;zm_oQL9)HZ;H40+*B=r6&zp*Bv}eN zI$&TA+W4Kbi+6iFFNzdT?t6AJ7Q|t*~JsU5q9zbvoctXQW8y{sPE}g8()5>3Vj8ya_!{vYgF|{_4m!{ S?@j!OZ*)q3jGyA?F#iLmXdVav literal 0 HcmV?d00001 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index f6c0d9e3b..aa7b31e27 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -16629,6 +16629,16 @@ CREATE TABLE IF NOT EXISTS iam_customer_dimension_initialization_task ( KEY idx_customer_dimension_task_diagnosis (status,updated_at) ) ENGINE=InnoDB COMMENT='IAM客户默认尺寸可靠后台初始化任务及诊断状态'; +-- 兼容已存在的旧表:CREATE TABLE IF NOT EXISTS 不会为旧表补充新字段。 +SET @customer_dimension_lease_ddl = (SELECT IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='iam_customer_dimension_initialization_task' AND column_name='lease_until'), + 'SELECT 1', + 'ALTER TABLE iam_customer_dimension_initialization_task ADD COLUMN lease_until DATETIME(3) NULL COMMENT ''当前任务租约截止时间'' AFTER lease_owner')); +PREPARE customer_dimension_lease_stmt FROM @customer_dimension_lease_ddl; +EXECUTE customer_dimension_lease_stmt; +DEALLOCATE PREPARE customer_dimension_lease_stmt; + CALL baiyue_firstleg.assert_unified_final_schema(); DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema; SELECT 'PASS' AS migration_status, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index 4394ab6bd..e54443cdc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -290,7 +290,7 @@ public class DeliveryTransferService { if (delivery != null) { for (LastlegOrder order : tailOrders.selectList(new LambdaQueryWrapper().eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getOutboundOrderNo, outboundNo))) { String from = order.getCurrentStatus(); order.setCurrentStatus("RECEIVED"); order.setSubOrderStatus(null); order.setOutboundOrderNo(null); order.setDeliveryOrderNo(null); order.setUpdatedAt(now); tailOrders.updateById(order); - LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setOrderId(order.getId()); history.setFromStatus(from); history.setToStatus("RECEIVED"); history.setEventId("CANCEL_OUTBOUND:" + outboundNo); history.setRemark(request.reason().trim()); history.setCreatedAt(now); histories.insert(history); + LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setCustomerId(customerId); history.setOrderId(order.getId()); history.setFromStatus(from); history.setToStatus("RECEIVED"); history.setEventId("CANCEL_OUTBOUND:" + outboundNo); history.setRemark(request.reason().trim()); history.setCreatedAt(now); histories.insert(history); } } } @@ -375,7 +375,7 @@ public class DeliveryTransferService { order.setDeliveryOrderNo(deliveryNo); order.setCurrentStatus("WAIT_OUTBOUND"); order.setUpdatedAt(now); order.setSubOrderStatus("OUTBOUND:WAIT_OUTBOUND;DELIVERY:WAIT_DELIVERY"); tailOrders.updateById(order); - LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setOrderId(order.getId()); + LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setCustomerId(customerId); history.setOrderId(order.getId()); history.setFromStatus(from); history.setToStatus("WAIT_OUTBOUND"); history.setEventId("TRANSFER_DELIVERY:" + deliveryNo); history.setRemark(StringUtils.hasText(remark) ? remark.trim() : "Transferred to outbound and delivery documents"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java index 441d30d56..c435f098e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java @@ -84,6 +84,7 @@ public class DispatchPickOrderAttributionService { long shipped = value(before.getShippedPackageCount()) + entry.getValue(); String to = shipped == value(before.getPackageCount()) ? "COMPLETED" : "PARTIALLY_SHIPPED"; LastlegOrderStatusHistory row = new LastlegOrderStatusHistory(); + row.setCustomerId(customerId); row.setOrderId(entry.getKey()); row.setFromStatus(before.getCurrentStatus()); row.setToStatus(to); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java index 0fd568123..e292046d9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java @@ -6,6 +6,8 @@ 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 InventoryCommandMapper { @Select("SELECT * FROM ll_inventory_position WHERE id=#{positionId} LIMIT 1") @@ -22,4 +24,11 @@ public interface InventoryCommandMapper { @Select("SELECT COALESCE(SUM(available_packages+hold_packages+allocated_packages+picked_packages+receiving_packages),0) " + "FROM ll_inventory_position WHERE customer_id=#{customerId} AND pallet_id=#{palletId}") long palletPackages(@Param("customerId") String customerId, @Param("palletId") Long palletId); + + @Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} " + + "AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} " + + "AND in_transit_packages>0 ORDER BY id FOR UPDATE") + List lockInboundTransitBalances(@Param("customerId") String customerId, + @Param("inboundOrderId") Long inboundOrderId, + @Param("inboundCode") String inboundCode); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java index f42015986..9481a2c11 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java @@ -27,6 +27,7 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.time.LocalDateTime; import java.util.HexFormat; +import java.util.List; @Service public class InventoryCommandService { @@ -60,6 +61,8 @@ public class InventoryCommandService { public InventoryCommandResponse stockIn(String customerId, String operatorId, InventoryCommandRequests.StockIn request) { return execute(customerId, operatorId, "STOCK_IN", request.idempotencyKey(), request, operation -> { + List transitBalances = commands.lockInboundTransitBalances(customerId, + request.inboundOrderId(), request.inboundCode()); InventoryBalance balance = balance(customerId, request); InventoryPosition position = position(customerId, balance, request); long before = value(position.getAvailablePackages()); @@ -69,8 +72,8 @@ public class InventoryCommandService { position.setUpdatedAt(LocalDateTime.now()); positions.updateById(position); - long transferred = Math.min(value(balance.getInTransitPackages()), request.packages()); - balance.setInTransitPackages(value(balance.getInTransitPackages()) - transferred); + long transferred = transferInTransit(customerId, operatorId, operation.getId(), balance, + transitBalances, request); balance.setAvailablePackages(value(balance.getAvailablePackages()) + request.packages()); touch(balance); balances.updateById(balance); @@ -83,6 +86,33 @@ public class InventoryCommandService { }); } + private long transferInTransit(String customerId, String operatorId, Long operationId, + InventoryBalance target, List sources, + InventoryCommandRequests.StockIn request) { + long pending = request.packages(); + long transferred = 0; + for (InventoryBalance source : sources) { + if (pending == 0) break; + boolean targetDimension = source.getId().equals(target.getId()); + long sourceBefore = value(targetDimension ? target.getInTransitPackages() : source.getInTransitPackages()); + long moved = Math.min(sourceBefore, pending); + if (moved == 0) continue; + if (targetDimension) target.setInTransitPackages(sourceBefore - moved); + else source.setInTransitPackages(sourceBefore - moved); + transferred += moved; + pending -= moved; + if (targetDimension) continue; + touch(source); + balances.updateById(source); + ledger(customerId, operatorId, operationId, source, null, sku(request.skuCode()), + "STOCK_IN_TRANSIT_RELEASE", "IN_TRANSIT", "STOCK_IN", sourceBefore, -moved, + sourceBefore - moved, request.businessOrderNo(), request.inboundOrderNo(), null, + request.sourceEventId(), request.idempotencyKey() + ":TRANSIT:" + source.getId(), + "In-transit inventory transferred to overseas warehouse " + target.getOverseasWarehouseId()); + } + return transferred; + } + @Transactional public InventoryCommandResponse hold(String customerId, String operatorId, InventoryCommandRequests.Move request) { return move(customerId, operatorId, "HOLD", Bucket.AVAILABLE, Bucket.HOLD, request); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java index c23a505a6..91e56a48d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -192,7 +192,9 @@ class DeliveryTransferServiceTest { assertNotNull(tailOrder.getOutboundOrderNo()); assertNotNull(tailOrder.getDeliveryOrderNo()); verify(tailOrders).updateById(tailOrder); - verify(histories).insert(any(LastlegOrderStatusHistory.class)); + ArgumentCaptor history = ArgumentCaptor.forClass(LastlegOrderStatusHistory.class); + verify(histories).insert(history.capture()); + assertEquals("TENANT-A", history.getValue().getCustomerId()); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java index ad9dcee13..dd7339d81 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java @@ -75,8 +75,8 @@ class DispatchPickOrderAttributionServiceTest { "t1", "OUT1", "D1", List.of(pick), LocalDateTime.of(2026, 8, 17, 15, 0)); assertEquals(List.of(new DispatchPickOrderAttributionService.OrderPackages(101L, 4L)), result); - verify(history).insert(argThat((LastlegOrderStatusHistory value) -> "PARTIALLY_SHIPPED".equals(value.getToStatus()) - && value.getRemark().contains("6/10"))); + verify(history).insert(argThat((LastlegOrderStatusHistory value) -> "t1".equals(value.getCustomerId()) + && "PARTIALLY_SHIPPED".equals(value.getToStatus()) && value.getRemark().contains("6/10"))); } private DispatchPickOrderAttributionService service() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java index 5722fc1e3..d0659e077 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java @@ -24,6 +24,8 @@ import org.springframework.test.util.ReflectionTestUtils; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; +import java.util.List; + import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -57,6 +59,7 @@ class InventoryCommandServiceTest { ReflectionTestUtils.setField(service, "warehouseGateway", warehouseGateway); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); lenient().when(operations.findIdempotency(any(), any())).thenReturn(null); + lenient().when(commands.lockInboundTransitBalances(any(), any(), any())).thenReturn(List.of()); } @Test @@ -114,6 +117,7 @@ class InventoryCommandServiceTest { balance.setSkuCode("SKU-1"); balance.setInTransitPackages(12L); when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(balance); + when(commands.lockInboundTransitBalances("100", 101L, "IN-CODE-1")).thenReturn(List.of(balance)); InventoryCommandResponse result = service.stockIn("100", "7", stockIn("IN-1", "stock-in-transit", 12, 24)); @@ -122,6 +126,48 @@ class InventoryCommandServiceTest { assertEquals(12L, balance.getAvailablePackages()); } + @Test + void stockInTransfersInTransitFromTheOriginalOverseasWarehouse() { + InventoryBalance target = balance(200L, 0); + target.setSkuCode("SKU-1"); + target.setInTransitPackages(0L); + InventoryBalance source = balance(201L, 0); + source.setOverseasWarehouseId("WH-OLD"); + source.setSkuCode("SKU-1"); + source.setInTransitPackages(12L); + when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(target); + when(commands.lockInboundTransitBalances("100", 101L, "IN-CODE-1")).thenReturn(List.of(source)); + + InventoryCommandResponse result = service.stockIn("100", "7", + stockIn("IN-1", "stock-in-cross-warehouse", 12, 24)); + + assertEquals(12, result.availablePackages()); + assertEquals(0L, source.getInTransitPackages()); + ArgumentCaptor written = ArgumentCaptor.forClass(InventoryLedger.class); + verify(ledgers, times(2)).insert(written.capture()); + assertEquals("STOCK_IN_TRANSIT_RELEASE", written.getAllValues().get(0).getBusinessType()); + assertEquals(-12L, written.getAllValues().get(0).getDeltaPackages()); + assertEquals("STOCK_IN", written.getAllValues().get(1).getBusinessType()); + assertEquals("IN_TRANSIT", written.getAllValues().get(1).getStatusFrom()); + } + + @Test + void stockInKeepsOutsideSourceWhenInTransitIsInsufficient() { + InventoryBalance target = balance(200L, 0); + target.setSkuCode("SKU-1"); + target.setInTransitPackages(2L); + when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(target); + when(commands.lockInboundTransitBalances("100", 101L, "IN-CODE-1")).thenReturn(List.of(target)); + + service.stockIn("100", "7", stockIn("IN-1", "stock-in-partial-transit", 5, 10)); + + ArgumentCaptor ledger = ArgumentCaptor.forClass(InventoryLedger.class); + verify(ledgers).insert(ledger.capture()); + assertEquals("OUTSIDE", ledger.getValue().getStatusFrom()); + assertEquals(0L, target.getInTransitPackages()); + assertEquals(5L, target.getAvailablePackages()); + } + @Test void differentSkusShareInboundBalanceAndKeepSkuInLedger() { InventoryBalance balance = balance(200L, 4); diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index 40ffbc876..7430afad5 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -3895,3 +3895,17 @@ CREATE TABLE IF NOT EXISTS ll_customer_visit ( KEY idx_ll_customer_visit_list(customer_id,status,delivery_completed_at), KEY idx_ll_customer_visit_delivery_no(customer_id,delivery_no) ) ENGINE=InnoDB COMMENT='派送单维度客户回访记录'; + +-- ============================================================================ +-- 2026-08-27 / local legacy inventory-ledger cleanup +-- ============================================================================ +USE baiyue_lastleg; + +-- These quantity columns are absent from the current ledger contract. Legacy +-- NOT NULL definitions without defaults block package-only ledger writes. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='before_quantity'), 'ALTER TABLE ll_inventory_ledger DROP COLUMN before_quantity', 'SELECT 1')); +PREPARE lastleg_ledger_legacy_001 FROM @ddl; EXECUTE lastleg_ledger_legacy_001; DEALLOCATE PREPARE lastleg_ledger_legacy_001; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='delta_quantity'), 'ALTER TABLE ll_inventory_ledger DROP COLUMN delta_quantity', 'SELECT 1')); +PREPARE lastleg_ledger_legacy_002 FROM @ddl; EXECUTE lastleg_ledger_legacy_002; DEALLOCATE PREPARE lastleg_ledger_legacy_002; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='after_quantity'), 'ALTER TABLE ll_inventory_ledger DROP COLUMN after_quantity', 'SELECT 1')); +PREPARE lastleg_ledger_legacy_003 FROM @ddl; EXECUTE lastleg_ledger_legacy_003; DEALLOCATE PREPARE lastleg_ledger_legacy_003; From 5f341d6fb5b1b90543b3afcaf6aa4967d4e3adfa Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Thu, 27 Aug 2026 17:23:18 +0800 Subject: [PATCH 14/24] =?UTF-8?q?=E4=B8=80=E4=BB=B6=E4=BB=A3=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 60 +++++ .../PlatformLoadingPdaController.java | 48 ++++ .../firstleg/dto/LoadingPalletDtos.java | 41 ++++ .../entity/LoadingWorkPalletCartonEntity.java | 37 ++++ .../entity/LoadingWorkPalletEntity.java | 36 +++ .../mapper/LoadingCompletedEventMapper.java | 26 ++- .../mapper/LoadingWorkPalletCartonMapper.java | 90 ++++++++ .../mapper/LoadingWorkPalletMapper.java | 77 +++++++ .../LoadingCompletedOutboxService.java | 63 +++++- .../service/LoadingWorkPalletService.java | 206 ++++++++++++++++++ .../db/phase3-smart-loading-schema.sql | 60 +++++ .../openapi/phase3-pda-convergence-v1.yaml | 93 ++++++++ ...tformLoadingPdaControllerContractTest.java | 7 + .../LoadingCompletedOutboxServiceTest.java | 31 +++ .../service/LoadingWorkPalletServiceTest.java | 86 ++++++++ 15 files changed, 959 insertions(+), 2 deletions(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPalletDtos.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingWorkPalletCartonEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingWorkPalletEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingWorkPalletCartonMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingWorkPalletMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/service/LoadingWorkPalletService.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/LoadingWorkPalletServiceTest.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index aa7b31e27..5b985324d 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -5422,6 +5422,66 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_history ( CONSTRAINT fk_loading_pick_history_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id) ) ENGINE=InnoDB COMMENT='PDA拣货幂等和操作审计历史'; +CREATE TABLE IF NOT EXISTS fl_loading_work_pallet ( + id BIGINT UNSIGNED NOT NULL COMMENT '一件代发现场作业托盘ID', + pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA装柜拣货任务ID', + loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID快照', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID', + warehouse_pallet_id BIGINT UNSIGNED NULL COMMENT '仓库实物托盘ID;未绑定仓库资源时为空', + pallet_code VARCHAR(64) NOT NULL COMMENT '现场托盘号', + open_operation_id VARCHAR(128) NOT NULL COMMENT '开托幂等操作号', + seal_operation_id VARCHAR(128) NULL COMMENT '封托幂等操作号', + pallet_status VARCHAR(16) NOT NULL DEFAULT 'OPEN' COMMENT '托盘状态:OPEN打托中、SEALED已封托、CANCELLED已取消', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID', + created_by_name VARCHAR(64) NULL COMMENT '创建操作人名称快照', + sealed_by BIGINT UNSIGNED NULL COMMENT '封托操作人ID', + sealed_by_name VARCHAR(64) NULL COMMENT '封托操作人名称快照', + sealed_at DATETIME(3) NULL COMMENT '封托时间', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_loading_work_pallet_code (pick_task_id, pallet_code), + UNIQUE KEY uk_loading_work_pallet_open_operation (open_operation_id), + UNIQUE KEY uk_loading_work_pallet_seal_operation (seal_operation_id), + KEY idx_loading_work_pallet_plan (loading_plan_id, pallet_status, id), + CONSTRAINT fk_loading_work_pallet_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id), + CONSTRAINT fk_loading_work_pallet_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id), + CONSTRAINT chk_loading_work_pallet_status CHECK (pallet_status IN ('OPEN','SEALED','CANCELLED')) +) ENGINE=InnoDB COMMENT='一件代发PDA现场实际托盘;允许同托盘承载多个客户订单'; + +CREATE TABLE IF NOT EXISTS fl_loading_work_pallet_carton ( + id BIGINT UNSIGNED NOT NULL COMMENT '一件代发现场托盘箱唛绑定ID', + pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA装柜拣货任务ID', + work_pallet_id BIGINT UNSIGNED NOT NULL COMMENT '现场作业托盘ID', + loading_plan_item_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划货物明细ID', + pick_item_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '箱唛所属客户ID', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '来源入仓单ID', + inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '来源入仓货物明细ID', + carton_label_id BIGINT UNSIGNED NOT NULL COMMENT '稳定箱唛记录ID', + box_code VARCHAR(80) NOT NULL COMMENT '稳定箱唛箱码快照', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照', + units_per_carton INT UNSIGNED NULL COMMENT '每箱产品数量快照', + bound_by BIGINT UNSIGNED NOT NULL COMMENT '最后绑定操作人ID', + bound_by_name VARCHAR(64) NULL COMMENT '最后绑定操作人名称快照', + operation_id VARCHAR(128) NOT NULL COMMENT '最近一次绑定或移托幂等操作号', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次上托时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后移托时间', + PRIMARY KEY (id), + UNIQUE KEY uk_loading_work_carton_task_label (pick_task_id, carton_label_id), + UNIQUE KEY uk_loading_work_carton_operation (operation_id), + KEY idx_loading_work_carton_pallet (work_pallet_id, sku_code, id), + KEY idx_loading_work_carton_plan_item (loading_plan_item_id, id), + CONSTRAINT fk_loading_work_carton_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id), + CONSTRAINT fk_loading_work_carton_pallet FOREIGN KEY (work_pallet_id) REFERENCES fl_loading_work_pallet (id), + CONSTRAINT fk_loading_work_carton_plan_item FOREIGN KEY (loading_plan_item_id) REFERENCES fl_loading_plan_item (id), + CONSTRAINT fk_loading_work_carton_pick_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id), + CONSTRAINT fk_loading_work_carton_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label (id), + CONSTRAINT chk_loading_work_carton_units CHECK (units_per_carton IS NULL OR units_per_carton > 0) +) ENGINE=InnoDB COMMENT='一件代发PDA逐箱上托事实;客户订单不合并但实际托盘可混托'; + DROP PROCEDURE IF EXISTS repair_phase3_loading_pick_task_foreign_keys; DELIMITER $$ CREATE PROCEDURE repair_phase3_loading_pick_task_foreign_keys() diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java index 269c7c457..07a11095b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java @@ -17,6 +17,11 @@ import java.util.List; import com.baiyue.firstleg.dto.LoadingPickDtos.Task; import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.service.LoadingPickService; +import com.baiyue.firstleg.service.LoadingWorkPalletService; +import com.baiyue.firstleg.dto.LoadingPalletDtos.OpenRequest; +import com.baiyue.firstleg.dto.LoadingPalletDtos.ScanRequest; +import com.baiyue.firstleg.dto.LoadingPalletDtos.SealRequest; +import com.baiyue.firstleg.dto.LoadingPalletDtos.Pallet; import jakarta.annotation.Resource; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; @@ -41,6 +46,8 @@ import org.springframework.web.bind.annotation.RestController; public class PlatformLoadingPdaController { @Resource private LoadingPickService service; + @Resource + private LoadingWorkPalletService palletService; @GetMapping("/tasks") @PreAuthorize("hasAuthority('platform:firstleg:pda:view')") @@ -164,4 +171,45 @@ public class PlatformLoadingPdaController { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.operations(taskId)); } + + @GetMapping("/tasks/{taskId}/pallets") + @PreAuthorize("hasAuthority('platform:firstleg:pda:view')") + public ApiResponse> pallets(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long taskId, + @RequestParam @Positive long warehouseId) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(palletService.list(taskId, warehouseId)); + } + + @PostMapping("/tasks/{taskId}/pallets/open") + @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") + public ApiResponse openPallet(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long taskId, + @Valid @RequestBody OpenRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(palletService.open(taskId, request, user.userId(), user.username())); + } + + /** 扫到其他未封托托盘上的箱唛时,同时完成按箱移托。 */ + @PostMapping("/tasks/{taskId}/pallets/{palletId}/cartons/scan") + @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") + public ApiResponse scanCarton(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long taskId, + @PathVariable @Positive long palletId, + @Valid @RequestBody ScanRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(palletService.scan(taskId, palletId, request, + user.userId(), user.username())); + } + + @PostMapping("/tasks/{taskId}/pallets/{palletId}/seal") + @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") + public ApiResponse sealPallet(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long taskId, + @PathVariable @Positive long palletId, + @Valid @RequestBody SealRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(palletService.seal(taskId, palletId, request, + user.userId(), user.username())); + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPalletDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPalletDtos.java new file mode 100644 index 000000000..3bb836838 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPalletDtos.java @@ -0,0 +1,41 @@ +package com.baiyue.firstleg.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.util.List; + +/** 一件代发PDA现场打托接口模型。 */ +public final class LoadingPalletDtos { + private LoadingPalletDtos() {} + + public record OpenRequest( + @NotNull @Min(1) Long warehouseId, + @Min(1) Long warehousePalletId, + @NotBlank @Size(max = 64) String palletCode, + @NotBlank @Size(max = 128) String operationId + ) {} + + public record ScanRequest( + @NotNull @Min(1) Long warehouseId, + @NotBlank @Size(max = 80) String boxCode, + @NotBlank @Size(max = 128) String operationId + ) {} + + public record SealRequest( + @NotNull @Min(1) Long warehouseId, + @NotNull @Min(0) Integer expectedVersion, + @NotBlank @Size(max = 128) String operationId + ) {} + + public record Carton(Long id, String boxCode, String skuCode, Long tenantId, + Long inboundId, Long inboundDetailId, Integer unitsPerCarton) {} + + public record SkuSummary(String skuCode, Integer cartonCount, Long productQuantity) {} + + public record Pallet(Long id, String palletCode, Long warehousePalletId, String status, + Integer version, Integer cartonCount, Integer skuCount, + Integer customerCount, List skus, List cartons) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingWorkPalletCartonEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingWorkPalletCartonEntity.java new file mode 100644 index 000000000..a0072c7bc --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingWorkPalletCartonEntity.java @@ -0,0 +1,37 @@ +package com.baiyue.firstleg.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.annotation.Version; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** 一件代发箱唛与现场实际托盘的当前绑定事实。 */ +@Getter +@Setter +@TableName("fl_loading_work_pallet_carton") +public class LoadingWorkPalletCartonEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long pickTaskId; + private Long workPalletId; + private Long loadingPlanItemId; + private Long pickItemId; + private Long tenantId; + private Long inboundId; + private Long inboundDetailId; + private Long cartonLabelId; + private String boxCode; + private String skuCode; + private Integer unitsPerCarton; + private Long boundBy; + private String boundByName; + private String operationId; + @Version + private Integer version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingWorkPalletEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingWorkPalletEntity.java new file mode 100644 index 000000000..169f984e2 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingWorkPalletEntity.java @@ -0,0 +1,36 @@ +package com.baiyue.firstleg.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.annotation.Version; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** 一件代发装柜现场的实际托盘。 */ +@Getter +@Setter +@TableName("fl_loading_work_pallet") +public class LoadingWorkPalletEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long pickTaskId; + private Long loadingPlanId; + private Long warehouseId; + private Long warehousePalletId; + private String palletCode; + private String openOperationId; + private String sealOperationId; + private String palletStatus; + private Long createdBy; + private String createdByName; + private Long sealedBy; + private String sealedByName; + private LocalDateTime sealedAt; + @Version + private Integer version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} 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 691cd03d3..45a4cf79c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java @@ -48,7 +48,7 @@ public interface LoadingCompletedEventMapper { /** 读取头程托盘及箱唛快照;普通订单向尾程交接打印编号,一键代发同时冻结托盘库存。 */ @Select(""" - SELECT i.account_id, i.inbound_id AS source_inbound_id, + SELECT i.account_id, i.inbound_id AS source_inbound_id,0 AS actual_binding, a.pallet_code, a.allocation_no, a.picked_package_count AS allocated_package_count, i.inbound_detail_id AS source_inbound_detail_id, i.sku_code, i.product_name_zh, i.package_count, i.product_quantity, @@ -75,6 +75,29 @@ public interface LoadingCompletedEventMapper { """) List palletRows(@Param("planId") long planId); + /** 一件代发现场逐箱扫描结果;同一实物托盘可以包含多个客户,但行仍保留客户与入仓单归属。 */ + @Select(""" + SELECT carton.tenant_id AS account_id,carton.inbound_id AS source_inbound_id,1 AS actual_binding, + pallet.pallet_code,NULL AS allocation_no, + COUNT(*) OVER (PARTITION BY pallet.id,carton.tenant_id,carton.inbound_id) AS allocated_package_count, + carton.inbound_detail_id AS source_inbound_detail_id,carton.sku_code, + item.product_name_zh,item.package_count,item.product_quantity,carton.box_code + FROM fl_loading_work_pallet pallet + JOIN fl_loading_work_pallet_carton carton ON carton.work_pallet_id=pallet.id + JOIN fl_loading_plan_item item ON item.id=carton.loading_plan_item_id AND item.deleted=0 + JOIN fl_loading_pick_task task ON task.id=pallet.pick_task_id + AND task.loading_plan_id=pallet.loading_plan_id + WHERE pallet.loading_plan_id=#{planId} AND pallet.pallet_status='SEALED' + ORDER BY carton.tenant_id,carton.inbound_id,pallet.id,item.inbound_detail_id,carton.id + """) + List actualPalletRows(@Param("planId") long planId); + + @Select("SELECT COUNT(*) FROM fl_loading_work_pallet WHERE loading_plan_id=#{planId} AND pallet_status<>'CANCELLED'") + int workPalletCount(@Param("planId") long planId); + + @Select("SELECT COUNT(*) FROM fl_loading_work_pallet WHERE loading_plan_id=#{planId} AND pallet_status='OPEN'") + int openWorkPalletCount(@Param("planId") long planId); + @Getter @Setter class Row { private Long loadingPlanId; private String planNo; private Long containerBookingId; @@ -96,6 +119,7 @@ public interface LoadingCompletedEventMapper { @Getter @Setter class PalletRow { private Long accountId; private Long sourceInboundId; private String palletCode; + private Boolean actualBinding; private Integer allocationNo; private Integer allocatedPackageCount; private Long sourceInboundDetailId; private String skuCode; private String productNameZh; private Integer packageCount; private Integer productQuantity; private String boxCode; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingWorkPalletCartonMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingWorkPalletCartonMapper.java new file mode 100644 index 000000000..d8caf8f89 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingWorkPalletCartonMapper.java @@ -0,0 +1,90 @@ +package com.baiyue.firstleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.firstleg.entity.LoadingWorkPalletCartonEntity; +import lombok.Getter; +import lombok.Setter; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.List; + +@Mapper +public interface LoadingWorkPalletCartonMapper extends BaseMapper { + @Select(""" + SELECT label.id carton_label_id,label.tenant_id,label.inbound_id,label.inbound_detail_id, + label.box_code,plan_item.id loading_plan_item_id,pick_item.id pick_item_id, + plan_item.sku_code,plan_item.units_per_carton + FROM fl_inbound_carton_label label + JOIN fl_loading_plan_item plan_item + ON plan_item.account_id=label.tenant_id AND plan_item.inbound_id=label.inbound_id + AND plan_item.inbound_detail_id=label.inbound_detail_id AND plan_item.deleted=0 + JOIN fl_loading_pick_item pick_item ON pick_item.loading_plan_item_id=plan_item.id + JOIN fl_loading_pick_task task ON task.id=pick_item.pick_task_id + AND task.loading_plan_id=plan_item.loading_plan_id + JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id + JOIN fl_container_booking booking ON booking.id=plan.container_booking_id + JOIN fl_inbound_order inbound_order ON inbound_order.id=plan_item.inbound_id + AND inbound_order.tenant_id=plan_item.account_id AND inbound_order.deleted=0 + WHERE task.id=#{taskId} AND task.task_status='LOADING' AND booking.warehouse_id=#{warehouseId} + AND label.box_code=#{boxCode} AND label.batch_status='ACTIVE' + AND inbound_order.fulfillment_type='ONE_CLICK_DROPSHIP' + AND pick_item.item_status<>'SKIPPED' + ORDER BY label.tenant_id,label.id + """) + List selectCandidates(@Param("taskId") long taskId, + @Param("warehouseId") long warehouseId, + @Param("boxCode") String boxCode); + + @Select("SELECT * FROM fl_loading_work_pallet_carton WHERE pick_task_id=#{taskId} AND carton_label_id=#{labelId}") + LoadingWorkPalletCartonEntity selectBinding(@Param("taskId") long taskId, + @Param("labelId") long labelId); + + @Select("SELECT * FROM fl_loading_work_pallet_carton WHERE operation_id=#{operationId}") + LoadingWorkPalletCartonEntity selectByOperation(@Param("operationId") String operationId); + + @Select("SELECT * FROM fl_loading_work_pallet_carton WHERE work_pallet_id=#{palletId} ORDER BY id") + List selectPalletCartons(@Param("palletId") long palletId); + + @Select("SELECT COUNT(*) FROM fl_loading_work_pallet_carton WHERE work_pallet_id=#{palletId}") + int countPalletCartons(@Param("palletId") long palletId); + + @Select(""" + SELECT COUNT(*) FROM ( + SELECT carton.pick_item_id + FROM fl_loading_work_pallet_carton carton + JOIN fl_loading_pick_item pick_item ON pick_item.id=carton.pick_item_id + WHERE carton.pick_task_id=#{taskId} + GROUP BY carton.pick_item_id,pick_item.picked_package_count + HAVING COUNT(*)>pick_item.picked_package_count + ) unreconciled + """) + int countUnreconciled(@Param("taskId") long taskId); + + @Update(""" + UPDATE fl_loading_work_pallet_carton + SET work_pallet_id=#{targetPalletId},bound_by=#{operatorId},bound_by_name=#{operatorName}, + operation_id=#{operationId},version=version+1 + WHERE id=#{id} AND version=#{version} + """) + int move(@Param("id") long id, @Param("version") int version, + @Param("targetPalletId") long targetPalletId, + @Param("operatorId") long operatorId, @Param("operatorName") String operatorName, + @Param("operationId") String operationId); + + @Getter + @Setter + class CartonCandidate { + private Long cartonLabelId; + private Long tenantId; + private Long inboundId; + private Long inboundDetailId; + private String boxCode; + private Long loadingPlanItemId; + private Long pickItemId; + private String skuCode; + private Integer unitsPerCarton; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingWorkPalletMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingWorkPalletMapper.java new file mode 100644 index 000000000..e1b6ba90c --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingWorkPalletMapper.java @@ -0,0 +1,77 @@ +package com.baiyue.firstleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.firstleg.entity.LoadingWorkPalletEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.List; + +@Mapper +public interface LoadingWorkPalletMapper extends BaseMapper { + @Select(""" + SELECT task.id task_id,task.loading_plan_id,task.task_status,booking.warehouse_id, + assignment.assignee_id, + SUM(CASE WHEN inbound_order.fulfillment_type='ONE_CLICK_DROPSHIP' THEN 1 ELSE 0 END) dropship_item_count + FROM fl_loading_pick_task task + JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id + JOIN fl_container_booking booking ON booking.id=plan.container_booking_id + JOIN fl_loading_pick_item pick_item ON pick_item.pick_task_id=task.id + JOIN fl_loading_plan_item plan_item ON plan_item.id=pick_item.loading_plan_item_id AND plan_item.deleted=0 + JOIN fl_inbound_order inbound_order ON inbound_order.id=plan_item.inbound_id + AND inbound_order.tenant_id=plan_item.account_id AND inbound_order.deleted=0 + LEFT JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id=task.id + WHERE task.id=#{taskId} AND booking.warehouse_id=#{warehouseId} + GROUP BY task.id,task.loading_plan_id,task.task_status,booking.warehouse_id,assignment.assignee_id + """) + TaskContext selectTaskContext(@Param("taskId") long taskId, @Param("warehouseId") long warehouseId); + + @Select("SELECT * FROM fl_loading_work_pallet WHERE open_operation_id=#{operationId}") + LoadingWorkPalletEntity selectByOpenOperation(@Param("operationId") String operationId); + + @Select("SELECT * FROM fl_loading_work_pallet WHERE seal_operation_id=#{operationId}") + LoadingWorkPalletEntity selectBySealOperation(@Param("operationId") String operationId); + + @Select(""" + SELECT pallet.* + FROM fl_loading_work_pallet pallet + JOIN fl_loading_pick_task task ON task.id=pallet.pick_task_id + JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id + JOIN fl_container_booking booking ON booking.id=plan.container_booking_id + WHERE pallet.pick_task_id=#{taskId} AND pallet.id=#{palletId} + AND booking.warehouse_id=#{warehouseId} + """) + LoadingWorkPalletEntity selectScoped(@Param("taskId") long taskId, + @Param("palletId") long palletId, + @Param("warehouseId") long warehouseId); + + @Select(""" + SELECT pallet.* + FROM fl_loading_work_pallet pallet + JOIN fl_loading_pick_task task ON task.id=pallet.pick_task_id + JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id + JOIN fl_container_booking booking ON booking.id=plan.container_booking_id + WHERE pallet.pick_task_id=#{taskId} AND booking.warehouse_id=#{warehouseId} + AND pallet.pallet_status<>'CANCELLED' + ORDER BY pallet.id + """) + List selectTaskPallets(@Param("taskId") long taskId, + @Param("warehouseId") long warehouseId); + + @Update(""" + UPDATE fl_loading_work_pallet + SET pallet_status='SEALED',seal_operation_id=#{operationId},sealed_by=#{operatorId}, + sealed_by_name=#{operatorName},sealed_at=CURRENT_TIMESTAMP(3),version=version+1 + WHERE id=#{palletId} AND pick_task_id=#{taskId} AND warehouse_id=#{warehouseId} + AND pallet_status='OPEN' AND version=#{expectedVersion} + """) + int seal(@Param("taskId") long taskId, @Param("palletId") long palletId, + @Param("warehouseId") long warehouseId, @Param("expectedVersion") int expectedVersion, + @Param("operationId") String operationId, @Param("operatorId") long operatorId, + @Param("operatorName") String operatorName); + + record TaskContext(Long taskId, Long loadingPlanId, String taskStatus, Long warehouseId, + Long assigneeId, Integer dropshipItemCount) {} +} 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 d2c706779..7131ab519 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -50,13 +50,33 @@ public class LoadingCompletedOutboxService { List rows = snapshotMapper.rows(planId); if (rows.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜计划没有最终实装入仓单,不能生成尾程交接事件"); Map> cartonMarks = cartonLabelPools(rows); - List palletRows = snapshotMapper.palletRows(planId); + List palletRows = 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(), cartonMarks))); } + /** 已开展逐箱打托的入仓单使用现场事实;未开展的存量入仓单继续兼容原托盘分配快照。 */ + private List palletRows(long planId) { + int workPalletCount = snapshotMapper.workPalletCount(planId); + if (snapshotMapper.openWorkPalletCount(planId) > 0) { + throw new BizException(ResultCode.CONFLICT, "一件代发存在未封托的现场托盘,不能完成装柜交接"); + } + List actual = snapshotMapper.actualPalletRows(planId); + if (workPalletCount > 0 && actual.isEmpty()) { + throw new BizException(ResultCode.CONFLICT, "一件代发现场托盘没有有效箱唛,不能完成装柜交接"); + } + if (actual.isEmpty()) return snapshotMapper.palletRows(planId); + Set actualInbounds = actual.stream().map(row -> + new InboundPalletKey(row.getAccountId(), row.getSourceInboundId())).collect(java.util.stream.Collectors.toSet()); + List result = new ArrayList<>(actual); + snapshotMapper.palletRows(planId).stream() + .filter(row -> !actualInbounds.contains(new InboundPalletKey(row.getAccountId(), row.getSourceInboundId()))) + .forEach(result::add); + return result; + } + private EventEnvelope envelope(long accountId, List rows, List palletRows, Map> cartonMarks) { Row first = rows.get(0); @@ -182,9 +202,13 @@ public class LoadingCompletedOutboxService { } private record LabelPoolKey(long accountId, long inboundId) { } + private record InboundPalletKey(long accountId, long inboundId) { } private List palletSnapshots(List values, List source) { if (source.isEmpty()) return List.of(); + if (source.stream().anyMatch(item -> Boolean.TRUE.equals(item.getActualBinding()))) { + return actualPalletSnapshots(values, source); + } Map> byPallet = source.stream().collect(java.util.stream.Collectors.groupingBy( p -> (p.getAllocationNo() == null ? "" : p.getAllocationNo()) + "|" + p.getPalletCode(), LinkedHashMap::new, java.util.stream.Collectors.toList())); @@ -235,6 +259,43 @@ public class LoadingCompletedOutboxService { return result; } + /** 现场逐箱扫描已给出精确箱到托关系,不再使用旧的按托盘容量顺序推算。 */ + private List actualPalletSnapshots( + List values, List source) { + Map details = values.stream().collect(java.util.stream.Collectors.toMap( + Row::getSourceInboundDetailId, value -> value, (left, right) -> left, LinkedHashMap::new)); + Map> byPallet = source.stream().collect(java.util.stream.Collectors.groupingBy( + PalletRow::getPalletCode, LinkedHashMap::new, java.util.stream.Collectors.toList())); + List result = new ArrayList<>(); + int actualPackages = 0; + for (Map.Entry> pallet : byPallet.entrySet()) { + Map> byDetail = pallet.getValue().stream().collect( + java.util.stream.Collectors.groupingBy(PalletRow::getSourceInboundDetailId, + LinkedHashMap::new, java.util.stream.Collectors.toList())); + List skus = new ArrayList<>(); + for (Map.Entry> detailRows : byDetail.entrySet()) { + Row detail = details.get(detailRows.getKey()); + if (detail == null) throw new BizException(ResultCode.CONFLICT, "现场托盘箱唛无法匹配装柜货物明细"); + List marks = detailRows.getValue().stream().map(PalletRow::getBoxCode) + .filter(Objects::nonNull).distinct().toList(); + if (marks.size() != detailRows.getValue().size()) { + throw new BizException(ResultCode.CONFLICT, "现场托盘包含空箱唛或重复箱唛"); + } + int quantity = detail.getProductQuantity() == null ? marks.size() + : (int) Math.round((double) detail.getProductQuantity() * marks.size() + / Math.max(1, detail.getPackageCount())); + skus.add(new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(detail.getSkuCode(), + detail.getProductNameZh(), marks.size(), quantity, marks)); + actualPackages += marks.size(); + } + result.add(new FirstLegLoadingPlanCompletedV2.PalletSnapshot(pallet.getKey(), skus)); + } + if (actualPackages != sumInt(values, Row::getPackageCount)) { + throw new BizException(ResultCode.CONFLICT, "一件代发现场托盘箱唛数与实装箱数不一致"); + } + return result; + } + private static boolean dropship(String value) { return "ONE_CLICK_DROPSHIP".equals(value); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingWorkPalletService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingWorkPalletService.java new file mode 100644 index 000000000..4557f92ce --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingWorkPalletService.java @@ -0,0 +1,206 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.LoadingPalletDtos.Carton; +import com.baiyue.firstleg.dto.LoadingPalletDtos.OpenRequest; +import com.baiyue.firstleg.dto.LoadingPalletDtos.Pallet; +import com.baiyue.firstleg.dto.LoadingPalletDtos.ScanRequest; +import com.baiyue.firstleg.dto.LoadingPalletDtos.SealRequest; +import com.baiyue.firstleg.dto.LoadingPalletDtos.SkuSummary; +import com.baiyue.firstleg.entity.LoadingWorkPalletCartonEntity; +import com.baiyue.firstleg.entity.LoadingWorkPalletEntity; +import com.baiyue.firstleg.mapper.LoadingWorkPalletCartonMapper; +import com.baiyue.firstleg.mapper.LoadingWorkPalletCartonMapper.CartonCandidate; +import com.baiyue.firstleg.mapper.LoadingWorkPalletMapper; +import com.baiyue.firstleg.mapper.LoadingWorkPalletMapper.TaskContext; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** 一件代发现场打托旁路;不改变既有拣货数量和仓库资源结算。 */ +@Service +public class LoadingWorkPalletService { + @Resource private LoadingWorkPalletMapper pallets; + @Resource private LoadingWorkPalletCartonMapper cartons; + + @Transactional + public Pallet open(long taskId, OpenRequest request, long operatorId, String operatorName) { + TaskContext context = requireWorkTask(taskId, request.warehouseId(), operatorId); + String code = request.palletCode().trim(); + String operationId = request.operationId().trim(); + LoadingWorkPalletEntity replay = pallets.selectByOpenOperation(operationId); + if (replay != null) { + if (replay.getPickTaskId() != taskId || !replay.getPalletCode().equals(code)) { + throw conflict("开托幂等号已用于其他请求"); + } + return view(replay); + } + LoadingWorkPalletEntity value = new LoadingWorkPalletEntity(); + value.setPickTaskId(taskId); + value.setLoadingPlanId(context.loadingPlanId()); + value.setWarehouseId(request.warehouseId()); + value.setWarehousePalletId(request.warehousePalletId()); + value.setPalletCode(code); + value.setOpenOperationId(operationId); + value.setPalletStatus("OPEN"); + value.setCreatedBy(operatorId); + value.setCreatedByName(operatorName); + value.setVersion(0); + try { + pallets.insert(value); + } catch (DuplicateKeyException exception) { + replay = pallets.selectByOpenOperation(operationId); + if (replay == null || replay.getPickTaskId() != taskId || !replay.getPalletCode().equals(code)) { + throw conflict("托盘号已存在或开托操作重复"); + } + value = replay; + } + return view(value); + } + + @Transactional + public Pallet scan(long taskId, long palletId, ScanRequest request, + long operatorId, String operatorName) { + requireWorkTask(taskId, request.warehouseId(), operatorId); + LoadingWorkPalletEntity target = requireOpenPallet(taskId, palletId, request.warehouseId()); + String operationId = request.operationId().trim(); + LoadingWorkPalletCartonEntity replay = cartons.selectByOperation(operationId); + if (replay != null) { + if (replay.getPickTaskId() != taskId || replay.getWorkPalletId() != palletId + || !replay.getBoxCode().equals(request.boxCode().trim())) { + throw conflict("箱唛操作幂等号已用于其他请求"); + } + return view(target); + } + List candidates = cartons.selectCandidates(taskId, request.warehouseId(), + request.boxCode().trim()); + if (candidates.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "箱唛不属于当前任务的一件代发货物"); + if (candidates.size() > 1) throw conflict("箱唛在当前多客户任务中不唯一,请检查箱唛数据"); + CartonCandidate candidate = candidates.get(0); + if (candidate.getSkuCode() == null || candidate.getSkuCode().isBlank()) { + throw conflict("一件代发箱唛缺少SKU,不能上托"); + } + LoadingWorkPalletCartonEntity current = cartons.selectBinding(taskId, candidate.getCartonLabelId()); + if (current == null) { + current = binding(taskId, palletId, candidate, operationId, operatorId, operatorName); + try { + cartons.insert(current); + } catch (DuplicateKeyException exception) { + LoadingWorkPalletCartonEntity concurrent = cartons.selectBinding(taskId, candidate.getCartonLabelId()); + if (concurrent == null || concurrent.getWorkPalletId() != palletId) { + throw conflict("箱唛已被其他托盘绑定"); + } + } + return view(target); + } + if (current.getWorkPalletId() == palletId) return view(target); + LoadingWorkPalletEntity source = pallets.selectScoped(taskId, current.getWorkPalletId(), request.warehouseId()); + if (source == null || !"OPEN".equals(source.getPalletStatus())) { + throw conflict("来源托盘已封托,不能直接移箱"); + } + if (cartons.move(current.getId(), current.getVersion(), palletId, operatorId, operatorName, operationId) != 1) { + throw conflict("箱唛绑定已变化,请刷新后重试"); + } + return view(target); + } + + @Transactional + public Pallet seal(long taskId, long palletId, SealRequest request, + long operatorId, String operatorName) { + requireWorkTask(taskId, request.warehouseId(), operatorId); + LoadingWorkPalletEntity replay = pallets.selectBySealOperation(request.operationId().trim()); + if (replay != null) { + if (replay.getId() != palletId || replay.getPickTaskId() != taskId) { + throw conflict("封托幂等号已用于其他请求"); + } + return view(replay); + } + requireOpenPallet(taskId, palletId, request.warehouseId()); + if (cartons.countPalletCartons(palletId) == 0) throw conflict("空托盘不能封托"); + if (cartons.countUnreconciled(taskId) > 0) { + throw conflict("现场上托箱数超过已提交拣货箱数,请先完成原PDA拣货提交"); + } + if (pallets.seal(taskId, palletId, request.warehouseId(), request.expectedVersion(), + request.operationId().trim(), operatorId, operatorName) != 1) { + throw conflict("托盘状态或版本已变化,请刷新后重试"); + } + return view(pallets.selectScoped(taskId, palletId, request.warehouseId())); + } + + @Transactional(readOnly = true) + public List list(long taskId, long warehouseId) { + TaskContext context = pallets.selectTaskContext(taskId, warehouseId); + if (context == null) throw new BizException(ResultCode.NOT_FOUND, "未找到当前仓库的PDA任务"); + return pallets.selectTaskPallets(taskId, warehouseId).stream().map(this::view).toList(); + } + + private TaskContext requireWorkTask(long taskId, long warehouseId, long operatorId) { + TaskContext context = pallets.selectTaskContext(taskId, warehouseId); + if (context == null) throw new BizException(ResultCode.NOT_FOUND, "未找到当前仓库的PDA任务"); + if (!"LOADING".equals(context.taskStatus())) throw conflict("PDA任务已完成,不能继续打托"); + if (context.dropshipItemCount() == null || context.dropshipItemCount() == 0) { + throw conflict("当前任务没有一件代发货物"); + } + if (context.assigneeId() == null || context.assigneeId() != operatorId) { + throw conflict("请先领取PDA任务后再执行打托"); + } + return context; + } + + private LoadingWorkPalletEntity requireOpenPallet(long taskId, long palletId, long warehouseId) { + LoadingWorkPalletEntity pallet = pallets.selectScoped(taskId, palletId, warehouseId); + if (pallet == null) throw new BizException(ResultCode.NOT_FOUND, "现场托盘不存在"); + if (!"OPEN".equals(pallet.getPalletStatus())) throw conflict("托盘不是打托中状态"); + return pallet; + } + + private static LoadingWorkPalletCartonEntity binding(long taskId, long palletId, + CartonCandidate source, String operationId, + long operatorId, String operatorName) { + LoadingWorkPalletCartonEntity value = new LoadingWorkPalletCartonEntity(); + value.setPickTaskId(taskId); + value.setWorkPalletId(palletId); + value.setLoadingPlanItemId(source.getLoadingPlanItemId()); + value.setPickItemId(source.getPickItemId()); + value.setTenantId(source.getTenantId()); + value.setInboundId(source.getInboundId()); + value.setInboundDetailId(source.getInboundDetailId()); + value.setCartonLabelId(source.getCartonLabelId()); + value.setBoxCode(source.getBoxCode()); + value.setSkuCode(source.getSkuCode()); + value.setUnitsPerCarton(source.getUnitsPerCarton()); + value.setBoundBy(operatorId); + value.setBoundByName(operatorName); + value.setOperationId(operationId); + value.setVersion(0); + return value; + } + + private Pallet view(LoadingWorkPalletEntity pallet) { + List values = cartons.selectPalletCartons(pallet.getId()); + Map> bySku = values.stream().collect( + java.util.stream.Collectors.groupingBy(LoadingWorkPalletCartonEntity::getSkuCode, + LinkedHashMap::new, java.util.stream.Collectors.toList())); + List skus = bySku.entrySet().stream().map(entry -> new SkuSummary(entry.getKey(), + entry.getValue().size(), entry.getValue().stream().map(LoadingWorkPalletCartonEntity::getUnitsPerCarton) + .filter(Objects::nonNull).mapToLong(Integer::longValue).sum())).toList(); + List cartonViews = values.stream().map(value -> new Carton(value.getId(), value.getBoxCode(), + value.getSkuCode(), value.getTenantId(), value.getInboundId(), value.getInboundDetailId(), + value.getUnitsPerCarton())).toList(); + return new Pallet(pallet.getId(), pallet.getPalletCode(), pallet.getWarehousePalletId(), + pallet.getPalletStatus(), pallet.getVersion(), values.size(), bySku.size(), + (int) values.stream().map(LoadingWorkPalletCartonEntity::getTenantId).distinct().count(), + skus, cartonViews); + } + + private static BizException conflict(String message) { + return new BizException(ResultCode.CONFLICT, message); + } +} 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 5033d5924..ff90ac11e 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -623,6 +623,66 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_history ( CONSTRAINT fk_loading_pick_history_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id) ) ENGINE=InnoDB COMMENT='PDA拣货幂等和操作审计历史'; +CREATE TABLE IF NOT EXISTS fl_loading_work_pallet ( + id BIGINT UNSIGNED NOT NULL COMMENT '一件代发现场作业托盘ID', + pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA装柜拣货任务ID', + loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID快照', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID', + warehouse_pallet_id BIGINT UNSIGNED NULL COMMENT '仓库实物托盘ID;未绑定仓库资源时为空', + pallet_code VARCHAR(64) NOT NULL COMMENT '现场托盘号', + open_operation_id VARCHAR(128) NOT NULL COMMENT '开托幂等操作号', + seal_operation_id VARCHAR(128) NULL COMMENT '封托幂等操作号', + pallet_status VARCHAR(16) NOT NULL DEFAULT 'OPEN' COMMENT '托盘状态:OPEN打托中、SEALED已封托、CANCELLED已取消', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID', + created_by_name VARCHAR(64) NULL COMMENT '创建操作人名称快照', + sealed_by BIGINT UNSIGNED NULL COMMENT '封托操作人ID', + sealed_by_name VARCHAR(64) NULL COMMENT '封托操作人名称快照', + sealed_at DATETIME(3) NULL COMMENT '封托时间', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_loading_work_pallet_code (pick_task_id, pallet_code), + UNIQUE KEY uk_loading_work_pallet_open_operation (open_operation_id), + UNIQUE KEY uk_loading_work_pallet_seal_operation (seal_operation_id), + KEY idx_loading_work_pallet_plan (loading_plan_id, pallet_status, id), + CONSTRAINT fk_loading_work_pallet_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id), + CONSTRAINT fk_loading_work_pallet_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id), + CONSTRAINT chk_loading_work_pallet_status CHECK (pallet_status IN ('OPEN','SEALED','CANCELLED')) +) ENGINE=InnoDB COMMENT='一件代发PDA现场实际托盘;允许同托盘承载多个客户订单'; + +CREATE TABLE IF NOT EXISTS fl_loading_work_pallet_carton ( + id BIGINT UNSIGNED NOT NULL COMMENT '一件代发现场托盘箱唛绑定ID', + pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA装柜拣货任务ID', + work_pallet_id BIGINT UNSIGNED NOT NULL COMMENT '现场作业托盘ID', + loading_plan_item_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划货物明细ID', + pick_item_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '箱唛所属客户ID', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '来源入仓单ID', + inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '来源入仓货物明细ID', + carton_label_id BIGINT UNSIGNED NOT NULL COMMENT '稳定箱唛记录ID', + box_code VARCHAR(80) NOT NULL COMMENT '稳定箱唛箱码快照', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照', + units_per_carton INT UNSIGNED NULL COMMENT '每箱产品数量快照', + bound_by BIGINT UNSIGNED NOT NULL COMMENT '最后绑定操作人ID', + bound_by_name VARCHAR(64) NULL COMMENT '最后绑定操作人名称快照', + operation_id VARCHAR(128) NOT NULL COMMENT '最近一次绑定或移托幂等操作号', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次上托时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后移托时间', + PRIMARY KEY (id), + UNIQUE KEY uk_loading_work_carton_task_label (pick_task_id, carton_label_id), + UNIQUE KEY uk_loading_work_carton_operation (operation_id), + KEY idx_loading_work_carton_pallet (work_pallet_id, sku_code, id), + KEY idx_loading_work_carton_plan_item (loading_plan_item_id, id), + CONSTRAINT fk_loading_work_carton_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id), + CONSTRAINT fk_loading_work_carton_pallet FOREIGN KEY (work_pallet_id) REFERENCES fl_loading_work_pallet (id), + CONSTRAINT fk_loading_work_carton_plan_item FOREIGN KEY (loading_plan_item_id) REFERENCES fl_loading_plan_item (id), + CONSTRAINT fk_loading_work_carton_pick_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id), + CONSTRAINT fk_loading_work_carton_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label (id), + CONSTRAINT chk_loading_work_carton_units CHECK (units_per_carton IS NULL OR units_per_carton > 0) +) ENGINE=InnoDB COMMENT='一件代发PDA逐箱上托事实;客户订单不合并但实际托盘可混托'; + DROP PROCEDURE IF EXISTS repair_phase3_loading_pick_task_foreign_keys; DELIMITER $$ CREATE PROCEDURE repair_phase3_loading_pick_task_foreign_keys() diff --git a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml index 165d36799..995e50d79 100644 --- a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml @@ -119,11 +119,104 @@ paths: parameters: [{$ref: '#/components/parameters/TaskId'}] responses: '200': {description: Ordered operation history} + /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/pallets: + get: + summary: 查询一件代发现场实际托盘、SKU汇总和逐箱明细 + parameters: + - {$ref: '#/components/parameters/TaskId'} + - {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} + responses: + '200': + description: 当前任务未取消的现场托盘 + content: {application/json: {schema: {type: array, items: {$ref: '#/components/schemas/WorkPallet'}}}} + /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/pallets/open: + post: + summary: 为已领取任务开启一个一件代发现场托盘 + parameters: [{$ref: '#/components/parameters/TaskId'}] + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/OpenPalletRequest'}}} + responses: + '200': {description: 已开启托盘或相同operationId的幂等结果} + /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/pallets/{palletId}/cartons/scan: + post: + summary: 扫描一件代发箱唛上托;箱唛已在其他未封托托盘时按箱移托 + description: 本操作只记录实际箱到托关系,不推进既有拣货数量、订单状态或仓库资源结算。 + parameters: [{$ref: '#/components/parameters/TaskId'}, {$ref: '#/components/parameters/PalletId'}] + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/ScanCartonRequest'}}} + responses: + '200': {description: 包含最新SKU与箱唛汇总的目标托盘} + /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/pallets/{palletId}/seal: + post: + summary: 封托并校验现场上托箱数未超过既有PDA已提交拣货箱数 + parameters: [{$ref: '#/components/parameters/TaskId'}, {$ref: '#/components/parameters/PalletId'}] + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/SealPalletRequest'}}} + responses: + '200': {description: 已封托托盘或A0409数量/版本冲突} components: parameters: TaskId: {name: taskId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} ItemId: {name: itemId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} + PalletId: {name: palletId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} schemas: + OpenPalletRequest: + type: object + required: [warehouseId, palletCode, operationId] + properties: + warehouseId: {type: integer, format: int64, minimum: 1} + warehousePalletId: {type: integer, format: int64, minimum: 1, nullable: true} + palletCode: {type: string, maxLength: 64} + operationId: {type: string, maxLength: 128} + ScanCartonRequest: + type: object + required: [warehouseId, boxCode, operationId] + properties: + warehouseId: {type: integer, format: int64, minimum: 1} + boxCode: {type: string, maxLength: 80} + operationId: {type: string, maxLength: 128} + SealPalletRequest: + type: object + required: [warehouseId, expectedVersion, operationId] + properties: + warehouseId: {type: integer, format: int64, minimum: 1} + expectedVersion: {type: integer, minimum: 0} + operationId: {type: string, maxLength: 128} + WorkPallet: + type: object + required: [id, palletCode, status, version, cartonCount, skuCount, customerCount, skus, cartons] + properties: + id: {type: integer, format: int64} + palletCode: {type: string} + warehousePalletId: {type: integer, format: int64, nullable: true} + status: {type: string, enum: [OPEN, SEALED]} + version: {type: integer, minimum: 0} + cartonCount: {type: integer, minimum: 0} + skuCount: {type: integer, minimum: 0} + customerCount: {type: integer, minimum: 0} + skus: {type: array, items: {$ref: '#/components/schemas/WorkPalletSku'}} + cartons: {type: array, items: {$ref: '#/components/schemas/WorkPalletCarton'}} + WorkPalletSku: + type: object + required: [skuCode, cartonCount, productQuantity] + properties: + skuCode: {type: string} + cartonCount: {type: integer, minimum: 1} + productQuantity: {type: integer, format: int64, minimum: 0} + WorkPalletCarton: + type: object + required: [id, boxCode, skuCode, tenantId, inboundId, inboundDetailId] + properties: + id: {type: integer, format: int64} + boxCode: {type: string} + skuCode: {type: string} + tenantId: {type: integer, format: int64} + inboundId: {type: integer, format: int64} + inboundDetailId: {type: integer, format: int64} + unitsPerCarton: {type: integer, minimum: 1, nullable: true} Task: type: object required: [id, loadingPlanId, containerBookingId, inboundId, inboundVersion, loadingVersion, cabinetNo, sourceType, recordStatus, taskStatus, workflowStatus, version, items] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java index 553b49f95..6d477e85e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java @@ -24,6 +24,13 @@ class PlatformLoadingPdaControllerContractTest { authority("review", "platform:firstleg:pda:review", AuthenticatedUser.class, long.class, ReviewRequest.class); authority("complete", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, CompleteLoadingRequest.class); authority("operations", "platform:firstleg:pda:view", AuthenticatedUser.class, long.class); + authority("pallets", "platform:firstleg:pda:view", AuthenticatedUser.class, long.class, long.class); + authority("openPallet", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, + com.baiyue.firstleg.dto.LoadingPalletDtos.OpenRequest.class); + authority("scanCarton", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, long.class, + com.baiyue.firstleg.dto.LoadingPalletDtos.ScanRequest.class); + authority("sealPallet", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, long.class, + com.baiyue.firstleg.dto.LoadingPalletDtos.SealRequest.class); } private void authority(String name, String expected, Class... parameterTypes) 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 50ff2452d..f68b252fc 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -187,6 +187,30 @@ class LoadingCompletedOutboxServiceTest { assertThat(event.payload().at("/orders/0/palletCount").asInt()).isEqualTo(2); } + @Test void publishesExactPdaCartonToPalletBindingsWithoutSequentialRedistribution() throws Exception { + Row first = row(11, 100, 101); first.setFulfillmentType("ONE_CLICK_DROPSHIP"); + first.setPackageCount(2); first.setProductQuantity(20); first.setSkuCode("SKU-A"); + Row second = row(11, 100, 102); second.setFulfillmentType("ONE_CLICK_DROPSHIP"); + second.setPackageCount(2); second.setProductQuantity(10); second.setSkuCode("SKU-B"); + when(snapshots.rows(90L)).thenReturn(List.of(first, second)); + when(snapshots.workPalletCount(90L)).thenReturn(2); + when(snapshots.actualPalletRows(90L)).thenReturn(List.of( + actualPallet(11,100,"PLT-01",101,"SKU-A","BOX-A2"), + actualPallet(11,100,"PLT-01",102,"SKU-B","BOX-B1"), + actualPallet(11,100,"PLT-02",101,"SKU-A","BOX-A1"), + actualPallet(11,100,"PLT-02",102,"SKU-B","BOX-B2"))); + + 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/skuPackages/0/cartonMarks/0").asText()) + .isEqualTo("BOX-A2"); + assertThat(event.payload().at("/orders/0/pallets/1/skuPackages/0/cartonMarks/0").asText()) + .isEqualTo("BOX-A1"); + } + @Test void pricingBusinessTypeCannotTurnAStandardOrderIntoDropship() throws Exception { Row value = row(11, 100, 101); @@ -238,6 +262,13 @@ class LoadingCompletedOutboxServiceTest { row.setPackageCount(packages); row.setProductQuantity(quantity); row.setBoxCode(boxCode); return row; } + private PalletRow actualPallet(long account,long inbound,String palletNo,long detail, + String sku,String boxCode) { + PalletRow row = pallet(account,inbound,0,palletNo,1,detail,sku,1,1,boxCode); + row.setActualBinding(true); + return row; + } + private LabelRow label(long detailId, String boxCode) { LabelRow row = new LabelRow(); row.setInboundDetailId(detailId); row.setBoxCode(boxCode); return row; } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingWorkPalletServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingWorkPalletServiceTest.java new file mode 100644 index 000000000..c48acc2e2 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingWorkPalletServiceTest.java @@ -0,0 +1,86 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.firstleg.dto.LoadingPalletDtos.ScanRequest; +import com.baiyue.firstleg.dto.LoadingPalletDtos.SealRequest; +import com.baiyue.firstleg.entity.LoadingWorkPalletCartonEntity; +import com.baiyue.firstleg.entity.LoadingWorkPalletEntity; +import com.baiyue.firstleg.mapper.LoadingWorkPalletCartonMapper; +import com.baiyue.firstleg.mapper.LoadingWorkPalletCartonMapper.CartonCandidate; +import com.baiyue.firstleg.mapper.LoadingWorkPalletMapper; +import com.baiyue.firstleg.mapper.LoadingWorkPalletMapper.TaskContext; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(LoadingWorkPalletService.class) +class LoadingWorkPalletServiceTest { + @MockBean private LoadingWorkPalletMapper pallets; + @MockBean private LoadingWorkPalletCartonMapper cartons; + @Resource private LoadingWorkPalletService service; + + @Test + void scansDropshipCartonOntoMixedCustomerWorkPalletWithoutChangingPickWorkflow() { + when(pallets.selectTaskContext(10L, 20L)).thenReturn(context()); + LoadingWorkPalletEntity pallet = pallet(30L, "OPEN"); + when(pallets.selectScoped(10L, 30L, 20L)).thenReturn(pallet); + CartonCandidate candidate = candidate(); + when(cartons.selectCandidates(10L, 20L, "BOX-A")).thenReturn(List.of(candidate)); + doAnswer(invocation -> { + LoadingWorkPalletCartonEntity value = invocation.getArgument(0); + value.setId(50L); + return 1; + }).when(cartons).insert(any(LoadingWorkPalletCartonEntity.class)); + when(cartons.selectPalletCartons(30L)).thenAnswer(invocation -> List.of()); + + service.scan(10L, 30L, new ScanRequest(20L, "BOX-A", "SCAN-1"), 99L, "作业员"); + + var captor = org.mockito.ArgumentCaptor.forClass(LoadingWorkPalletCartonEntity.class); + verify(cartons).insert(captor.capture()); + assertThat(captor.getValue().getTenantId()).isEqualTo(88L); + assertThat(captor.getValue().getLoadingPlanItemId()).isEqualTo(41L); + assertThat(captor.getValue().getSkuCode()).isEqualTo("SKU-A"); + } + + @Test + void refusesToSealUntilScannedCartonsAreReconciledByExistingPickSubmission() { + when(pallets.selectTaskContext(10L, 20L)).thenReturn(context()); + when(pallets.selectScoped(10L, 30L, 20L)).thenReturn(pallet(30L, "OPEN")); + when(cartons.countPalletCartons(30L)).thenReturn(2); + when(cartons.countUnreconciled(10L)).thenReturn(1); + + assertThatThrownBy(() -> service.seal(10L, 30L, + new SealRequest(20L, 0, "SEAL-1"), 99L, "作业员")) + .hasMessageContaining("原PDA拣货提交"); + } + + private static TaskContext context() { + return new TaskContext(10L, 11L, "LOADING", 20L, 99L, 2); + } + + private static LoadingWorkPalletEntity pallet(long id, String status) { + LoadingWorkPalletEntity value = new LoadingWorkPalletEntity(); + value.setId(id); value.setPickTaskId(10L); value.setLoadingPlanId(11L); + value.setWarehouseId(20L); value.setPalletCode("PLT-01"); + value.setPalletStatus(status); value.setVersion(0); + return value; + } + + private static CartonCandidate candidate() { + CartonCandidate value = new CartonCandidate(); + value.setCartonLabelId(40L); value.setTenantId(88L); value.setInboundId(81L); + value.setInboundDetailId(82L); value.setBoxCode("BOX-A"); + value.setLoadingPlanItemId(41L); value.setPickItemId(42L); + value.setSkuCode("SKU-A"); value.setUnitsPerCarton(20); + return value; + } +} From 6a83008ba62b67d946cff708e466a9655b5d341f Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Fri, 28 Aug 2026 09:59:17 +0800 Subject: [PATCH 15/24] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 11 +++++++++++ iam/src/main/resources/db/lastleg-permissions.sql | 12 ++++++++++++ .../lastleg/delivery/dto/DeliveryRequests.java | 3 --- .../service/TaillegRealConsumerAcceptanceTest.java | 6 ++++++ 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 5b985324d..d8e2f65e0 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -16350,6 +16350,8 @@ VALUES (5107061,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:create','创建海外库位',NULL,'POST',761,'ACTIVE',1,0), (5107062,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:edit','编辑海外库位',NULL,'PUT',762,'ACTIVE',1,0), (5107063,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:delete','删除海外库位',NULL,'DELETE',763,'ACTIVE',1,0), + (5107090,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:outbound:list','海外出库单','/overseas-warehouse/outbounds','GET',765,'ACTIVE',1,0), + (5107091,0,'PLATFORM','BUTTON',5107090,'platform:overseas-warehouse:outbound:create','创建海外出库单',NULL,'POST',766,'ACTIVE',1,0), (5107100,0,'PLATFORM','MENU',0,'platform:overseas-business:purchase:list','海外采购订单','/overseas-business/purchases','GET',800,'ACTIVE',1,0), (5107101,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:edit','编辑海外采购订单',NULL,'PATCH',801,'ACTIVE',1,0), (5107102,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:review','审核海外采购订单',NULL,'POST',802,'ACTIVE',1,0), @@ -16524,6 +16526,15 @@ WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND UPPER(role.role_code) IN ('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN') AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; +INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, role.id, permission.id, NOW(3) +FROM baiyue_iam.iam_role role +JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code='platform:overseas-warehouse:outbound:list' +WHERE role.tenant_id=0 AND role.app_code='PLATFORM' + AND UPPER(role.role_code)='PLATFORM_CUSTOM_ADMIN' + AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; + -- 海外智能排车草稿锁:拖入即锁定,移除或关闭草稿即释放。 CREATE DATABASE IF NOT EXISTS baiyue_lastleg DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql index dc8e69d70..bea660488 100644 --- a/iam/src/main/resources/db/lastleg-permissions.sql +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -39,6 +39,8 @@ VALUES (5107061,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:create','创建海外库位',NULL,'POST',761,'ACTIVE',1,0), (5107062,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:edit','编辑海外库位',NULL,'PUT',762,'ACTIVE',1,0), (5107063,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:delete','删除海外库位',NULL,'DELETE',763,'ACTIVE',1,0), + (5107090,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:outbound:list','海外出库单','/overseas-warehouse/outbounds','GET',765,'ACTIVE',1,0), + (5107091,0,'PLATFORM','BUTTON',5107090,'platform:overseas-warehouse:outbound:create','创建海外出库单',NULL,'POST',766,'ACTIVE',1,0), (5107100,0,'PLATFORM','MENU',0,'platform:overseas-business:purchase:list','海外采购订单','/overseas-business/purchases','GET',800,'ACTIVE',1,0), (5107101,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:edit','编辑海外采购订单',NULL,'PATCH',801,'ACTIVE',1,0), (5107102,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:review','审核海外采购订单',NULL,'POST',802,'ACTIVE',1,0), @@ -212,3 +214,13 @@ JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND UPPER(role.role_code) IN ('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN') AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; + +-- PLATFORM_CUSTOM_ADMIN needs access to the overseas outbound list shown in the admin navigation. +INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, role.id, permission.id, NOW(3) +FROM iam_role role +JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code IN ('platform:overseas-warehouse:outbound:list') +WHERE role.tenant_id=0 AND role.app_code='PLATFORM' + AND UPPER(role.role_code)='PLATFORM_CUSTOM_ADMIN' + AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java index d3058a005..cc75ed53e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java @@ -41,9 +41,6 @@ public final class DeliveryRequests { String driverName, String deliveryMethod, LocalDateTime plannedFrom, LocalDateTime plannedTo) { public Query { if (page == 0) page = 1; if (size == 0) size = 20; } - public Query(long page, long size, String documentNo, String warehouseId, String status) { - this(page, size, documentNo, warehouseId, status, null, null, null, null, null, null, null); - } } public record PalletQuery(@Min(1) long page, @Min(1) @Max(200) long size, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java index c3df43abc..4680db2b1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java @@ -22,6 +22,8 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; import org.springframework.jdbc.core.JdbcTemplate; +import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper; +import org.mockito.Mockito; import java.math.BigDecimal; import java.time.LocalDate; @@ -64,6 +66,10 @@ class TaillegRealConsumerAcceptanceTest { "com.baiyue.lastleg.inventory.command.mapper", "com.baiyue.lastleg.relabel.mapper", "com.baiyue.lastleg.transfer.mapper", "com.baiyue.lastleg.looseboard.mapper"}) static class AcceptanceApplication { + @Bean ContainerShippingGateMapper containerShippingGateMapper() { + return Mockito.mock(ContainerShippingGateMapper.class); + } + @Bean WarehouseResourceGateway warehouseResourceGateway() { return new WarehouseResourceGateway() { public WarehousePalletReference ensureDropshipPallet(String customerId, long warehouseId, String businessId) { From 66b9c3aa777f2a914105f47eb0e3d176fa87c6cd Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Fri, 28 Aug 2026 14:20:48 +0800 Subject: [PATCH 16/24] =?UTF-8?q?=E6=9A=82=E6=89=A3=20=E6=A0=87=E8=AE=B0?= =?UTF-8?q?=E5=90=88=E6=89=98=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/LastlegOrderController.java | 25 +++++++ .../service/DeliveryTransferService.java | 12 ++-- .../discard/service/CargoDiscardService.java | 3 +- .../dispatch/service/DispatchService.java | 4 +- .../lastleg/dto/LastlegOrderRequests.java | 11 +++ .../service/ExceptionOrderService.java | 3 +- .../pda/dto/ReceivingContainerResponses.java | 28 +++++++- .../service/InboundStockReceiptService.java | 8 ++- .../service/ReceivingContainerService.java | 22 ++++-- .../service/InboundPlanService.java | 3 +- .../mapper/InventoryCommandMapper.java | 6 ++ .../service/InventoryCommandService.java | 32 +++++++-- .../service/LocalLooseboardService.java | 4 +- .../lastleg/mapper/LastlegOrderMapper.java | 6 ++ .../service/OverseasProcurementService.java | 3 +- .../service/RelabelApplicationService.java | 4 +- .../service/LastlegOrderNoGenerator.java | 55 +++++++++++++-- .../lastleg/service/LastlegOrderService.java | 68 +++++++++++++++++++ .../service/LoadingCompletedConsumer.java | 2 +- .../service/WarehouseTransferService.java | 6 +- .../trucking/service/UsTruckOrderService.java | 3 +- .../openapi/overseas-business-v1.yaml | 27 +++++++- .../service/DeliveryTransferServiceTest.java | 6 +- .../service/CargoDiscardServiceTest.java | 2 + .../dispatch/service/DispatchServiceTest.java | 1 + .../InboundStockReceiptServiceTest.java | 15 ++++ ...ReceivingContainerServiceBehaviorTest.java | 14 ++++ .../service/InboundPlanServiceTest.java | 1 + .../service/LocalLooseboardServiceTest.java | 3 +- .../OverseasProcurementServiceTest.java | 1 + .../RelabelApplicationServiceTest.java | 8 +-- .../service/LastlegOrderNoGeneratorTest.java | 8 ++- .../service/LastlegOrderServiceTest.java | 68 +++++++++++++++++++ .../service/WarehouseTransferServiceTest.java | 5 +- 34 files changed, 416 insertions(+), 51 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java index 9386f9c91..c02100a12 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java @@ -93,6 +93,31 @@ public class LastlegOrderController { return ApiResponse.success(orderService.createFee(platformScope(user, customerId), id, request, operatorName(user))); } + @PutMapping("/api/platform/v1/last-leg/orders/{id}/detained") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") + @Operation(summary = "Set tail-leg order hold status", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT) + public ApiResponse setDetained( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable long id, + @Valid @RequestBody LastlegOrderRequests.SetDetained request) { + orderService.setDetained(platformScope(user, customerId), id, request, operatorName(user)); + return ApiResponse.success(null); + } + + @PutMapping("/api/platform/v1/last-leg/orders/{id}/pallet-merge-required") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") + @Operation(summary = "Set the PDA pallet-merge reminder for one tail-leg order", + description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT) + public ApiResponse setPalletMergeRequired( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable long id, + @Valid @RequestBody LastlegOrderRequests.SetPalletMergeRequired request) { + orderService.setPalletMergeRequired(platformScope(user, customerId), id, request, operatorName(user)); + return ApiResponse.success(null); + } + @GetMapping(value = "/api/platform/v1/last-leg/orders/export", produces = "text/csv") @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EXPORT + "')") @Operation(summary = "Export filtered tail-leg orders as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EXPORT) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index e54443cdc..6249a645f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -53,6 +53,7 @@ import org.springframework.web.server.ResponseStatusException; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; import java.math.BigDecimal; import java.util.*; @@ -77,6 +78,7 @@ public class DeliveryTransferService { @Resource private LastlegOrderMapper tailOrders; @Resource private LastlegOrderStatusHistoryMapper histories; @Resource private LastlegOrderDetailMapper tailOrderDetails; + @Resource private LastlegOrderNoGenerator orderNoGenerator; @Transactional public DeliveryResponses.TransferResult transfer(String customerId, String dispatchNo, String operatorId, @@ -105,7 +107,7 @@ public class DeliveryTransferService { LocalDateTime now = LocalDateTime.now(); OutboundOrder outbound = new OutboundOrder(); outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId); - outbound.setOutboundNo("OUT" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + outbound.getId()); + outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", dispatch.getOverseasWarehouseId(), outbound.getId())); outbound.setDispatchOrderId(dispatch.getId()); outbound.setDispatchNo(dispatch.getDispatchNo()); outbound.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); outbound.setStatus("WAIT_OUTBOUND"); outbound.setOutboundType("DISPATCH_DELIVERY"); outbound.setPackageCount(items.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); @@ -141,7 +143,7 @@ public class DeliveryTransferService { DeliveryOrder delivery = new DeliveryOrder(); delivery.setId(IdWorker.getId()); delivery.setCustomerId(customerId); - delivery.setDeliveryNo("DEL" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + delivery.getId()); + delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY", dispatch.getOverseasWarehouseId(), delivery.getId())); delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo()); delivery.setDispatchOrderId(dispatch.getId()); delivery.setDispatchNo(dispatch.getDispatchNo()); delivery.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); delivery.setOverseasWarehouseName(dispatch.getOverseasWarehouseName()); @@ -197,14 +199,16 @@ public class DeliveryTransferService { OutboundOrder existing = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim()); if (existing != null) return taskResult(existing, pdaTasks.lockByOutbound(customerId, existing.getId())); LocalDateTime now = LocalDateTime.now(); + InventoryPosition firstPosition = inventoryPositions.selectById(request.items().get(0).inventoryPositionId()); + if (firstPosition == null || !customerId.equals(firstPosition.getCustomerId())) throw notFound("Inventory position not found"); + String selectedWarehouseId = firstPosition.getOverseasWarehouseId(); OutboundOrder outbound = new OutboundOrder(); outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId); - outbound.setOutboundNo("FBOO" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + outbound.getId()); + outbound.setOutboundNo(orderNoGenerator.next("ONE_CLICK_DROPSHIP", selectedWarehouseId, outbound.getId())); outbound.setOutboundType("ONE_CLICK_DROPSHIP"); outbound.setStatus("WAIT_PICK"); outbound.setRecipientInfo(request.recipient().trim()); outbound.setPackageCount(request.items().stream().mapToInt(i -> i.packages().intValue()).sum()); outbound.setPalletCount(request.items().size()); outbound.setIdempotencyKey(request.idempotencyKey().trim()); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); outboundOrders.insert(outbound); - String selectedWarehouseId = null; for (DeliveryRequests.OneClickItem input : request.items()) { InventoryPosition position = inventoryPositions.selectById(input.inventoryPositionId()); if (position == null || !customerId.equals(position.getCustomerId())) throw notFound("Inventory position not found"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java index 2f366c605..60d0f580a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java @@ -44,6 +44,7 @@ import java.util.Set; @Service public class CargoDiscardService { + @Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator; public static final String APPROVAL_PERMISSION = "lastleg:cargo-discard:approve"; public static final String PENDING_REVIEW = "PENDING_REVIEW"; public static final String APPROVED = "APPROVED"; @@ -105,7 +106,7 @@ public class CargoDiscardService { LocalDateTime now = LocalDateTime.now(); CargoDiscardApplication application = new CargoDiscardApplication(); application.setId(IdWorker.getId()); application.setCustomerId(customerId); - application.setDiscardNo("FH" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + application.getId()); + application.setDiscardNo(orderNoGenerator.next("DISCARD", warehouseId, application.getId())); application.setOverseasWarehouseId(warehouseId); application.setOverseasWarehouseName(warehouseName); application.setInboundCodes(orderCache.values().stream().map(LastlegOrder::getInboundCode) .distinct().sorted().collect(java.util.stream.Collectors.joining(","))); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index 54a7aa03e..9fcd08079 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -31,6 +31,7 @@ import org.springframework.web.server.ResponseStatusException; import java.math.BigDecimal; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; import java.util.HashSet; import java.util.List; import java.util.Objects; @@ -48,6 +49,7 @@ public class DispatchService { @Resource private DispatchDraftMapper drafts; @Resource private DispatchDraftContainerMapper draftContainers; @Resource private DispatchCandidateMapper candidates; + @Resource private LastlegOrderNoGenerator orderNoGenerator; public String resolveCustomerScopeForPlans(List planIds) { return requireSingleCustomerScope(candidates.findCustomerScopesByPlanIds(normalizeIds(planIds, "Plan IDs")), @@ -239,7 +241,7 @@ public class DispatchService { DispatchOrder dispatch = new DispatchOrder(); dispatch.setId(IdWorker.getId()); dispatch.setCustomerId(customerId); - dispatch.setDispatchNo("PC" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + dispatch.getId()); + dispatch.setDispatchNo(orderNoGenerator.next("DISPATCH", selected.get(0).overseasWarehouseId(), dispatch.getId())); dispatch.setOverseasWarehouseId(selected.get(0).overseasWarehouseId()); dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName()); dispatch.setRoute(StringUtils.hasText(request.route()) ? request.route().trim() : route(selected)); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java index 01b53a403..122ae8520 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java @@ -68,4 +68,15 @@ public final class LastlegOrderRequests { @Size(max = 500) String supplierDescription ) { } + + public record SetDetained( + @NotNull Boolean detained, + @NotBlank @Size(max = 500) String remark + ) { + } + + public record SetPalletMergeRequired( + @NotNull Boolean palletMergeRequired + ) { + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java index c2c1ca972..61122525e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java @@ -31,6 +31,7 @@ import java.util.Objects; @Service public class ExceptionOrderService { + @Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator; @Resource private ExceptionOrderMapper exceptionOrders; @Resource private LastlegOrderMapper lastMileOrders; @Resource private LastlegInboundOrderMapper inboundOrders; @@ -158,7 +159,7 @@ public class ExceptionOrderService { LocalDateTime now = LocalDateTime.now(); ExceptionOrder value = new ExceptionOrder(); value.setId(IdWorker.getId()); value.setCustomerId(relation.customerId()); - value.setExceptionNo("EXC" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + value.getId()); + value.setExceptionNo(orderNoGenerator.next("EXCEPTION", relation.overseasWarehouseId(), value.getId())); value.setExceptionType(type); value.setStatus("PENDING"); value.setReason(trim(reason)); value.setLastMileOrderId(relation.lastMileOrderId()); value.setLastMileOrderNo(relation.lastMileOrderNo()); value.setInboundOrderId(relation.inboundOrderId()); value.setInboundCode(relation.inboundCode()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java index 6afe67cba..f5f6056b2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java @@ -26,14 +26,28 @@ public final class ReceivingContainerResponses { String destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String fbaWarehouseCode, int plannedPalletCount, int totalPackages, - int receivedPackages, int pendingPackages) { } + int receivedPackages, int pendingPackages, + boolean palletMergeRequired, String palletMergeHint) { } public record CartonResolved(String cartonMark, String inboundCode, String destinationType, String overseasWarehouseId, String overseasWarehouseName, String destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String fbaWarehouseCode, int plannedPalletCount, int totalPackages, - int receivedPackages, int pendingPackages) { } + int receivedPackages, int pendingPackages, + boolean palletMergeRequired, String palletMergeHint) { + public CartonResolved(String cartonMark, String inboundCode, String destinationType, + String overseasWarehouseId, String overseasWarehouseName, + String destinationWarehouseId, String destinationWarehouseCode, + String destinationWarehouseName, String fbaWarehouseCode, + int plannedPalletCount, int totalPackages, int receivedPackages, + int pendingPackages) { + this(cartonMark, inboundCode, destinationType, overseasWarehouseId, overseasWarehouseName, + destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName, + fbaWarehouseCode, plannedPalletCount, totalPackages, receivedPackages, + pendingPackages, false, null); + } + } public record PalletResolved(String palletCode, long warehouseId, String status, String fbaWarehouseCode, String inboundCode, @@ -41,7 +55,15 @@ public final class ReceivingContainerResponses { public record Receipt(String result, String containerNo, String inboundCode, String destinationType, String palletCode, int acceptedPackages, - int receivedPackages, int pendingPackages, String sessionStatus) { } + int receivedPackages, int pendingPackages, String sessionStatus, + boolean palletMergeRequired, String palletMergeHint) { + public Receipt(String result, String containerNo, String inboundCode, + String destinationType, String palletCode, int acceptedPackages, + int receivedPackages, int pendingPackages, String sessionStatus) { + this(result, containerNo, inboundCode, destinationType, palletCode, acceptedPackages, + receivedPackages, pendingPackages, sessionStatus, false, null); + } + } public record Photo(String fileId, String fileName, String contentType, long sizeBytes) { } } 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 61f29be57..c0ea09d00 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 @@ -224,15 +224,17 @@ public class InboundStockReceiptService { String operationKey = "PDA_STOCK_IN:" + task.getId() + ":" + allocation.getId() + ":" + sku.skuCode(); String overseasWarehouseId = required(order.getOverseasWarehouseId(), "Actual overseas warehouse is required for stock-in"); - InventoryCommandResponse result = inventory.stockIn(customerId, task.getOperatorId(), - new InventoryCommandRequests.StockIn(null, order.getCustomerName(), overseasWarehouseId, + InventoryCommandRequests.StockIn stockIn = new InventoryCommandRequests.StockIn(null, order.getCustomerName(), overseasWarehouseId, null, order.getOverseasWarehouseName(), line.getDestinationWarehouseId(), destinationCode, line.getDestinationWarehouseName(), order.getId(), plan.getPlanNo(), line.getInboundCode(), sku.skuCode(), sku.productNameZh(), sku.productNameEn(), pallet.getWarehousePalletId(), pallet.getPalletNo(), plan.getPlanNo(), pallet.getWarehouseLocationId(), null, packages, order.getOrderNo(), order.getSourceEventId(), operationKey, - "PDA inbound completion for " + line.getInboundCode())); + "PDA inbound completion for " + line.getInboundCode()); + InventoryCommandResponse result = Boolean.TRUE.equals(order.getDetained()) + ? inventory.stockInHeld(customerId, task.getOperatorId(), stockIn) + : inventory.stockIn(customerId, task.getOperatorId(), stockIn); InboundStockReceipt receipt = new InboundStockReceipt(); receipt.setId(IdWorker.getId()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index ad9ca9d54..169f08ffa 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -61,6 +61,7 @@ import java.util.stream.Collectors; public class ReceivingContainerService { private static final String PHOTO_DOWNLOAD_PATH = "/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/{fileId}/download"; + private static final String PALLET_MERGE_HINT = "该入仓单已标记合托,请按合托要求拣货"; @Resource private InboundPlanMapper plans; @Resource private InboundPlanLineMapper lines; @@ -245,7 +246,8 @@ public class ReceivingContainerService { inbound.inboundCode(), inbound.destinationType(), inbound.overseasWarehouseId(), inbound.overseasWarehouseName(), inbound.destinationWarehouseId(), inbound.destinationWarehouseCode(), inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(), - inbound.plannedPalletCount(), inbound.totalPackages(), inbound.receivedPackages(), inbound.pendingPackages()); + inbound.plannedPalletCount(), inbound.totalPackages(), inbound.receivedPackages(), inbound.pendingPackages(), + inbound.palletMergeRequired(), inbound.palletMergeHint()); } public ReceivingContainerResponses.PalletResolved resolvePallet(String operatorKey, @@ -290,7 +292,8 @@ public class ReceivingContainerService { int total = fbaPlanRefs.stream().mapToInt(refValue -> value(refValue.line().getRequiredPackageCount())).sum(); ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED", session.getContainerNo(), ref.line().getInboundCode(), "FBA", resource.palletNo(), accepted, - received, Math.max(0, total - received), session.getStatus()); + received, Math.max(0, total - received), session.getStatus(), palletMergeRequired(ref.order()), + palletMergeHint(ref.order())); save(session, "BULK_RECEIVE", request.idempotencyKey(), request, result); return result; } catch (RuntimeException failure) { @@ -328,7 +331,8 @@ public class ReceivingContainerService { ReceivingContainerResponses.Inbound inbound = inbound(ref); ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED", session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), resource.palletNo(), 1, - inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus()); + inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus(), + inbound.palletMergeRequired(), inbound.palletMergeHint()); save(session, "CARTON_RECEIVE", request.idempotencyKey(), request, result); return result; } catch (RuntimeException failure) { @@ -539,7 +543,17 @@ public class ReceivingContainerService { : String.valueOf(ref.plan().getOverseasWarehouseId()), ref.order().getOverseasWarehouseName(), ref.line().getDestinationWarehouseId(), ref.line().getDestinationWarehouseCode(), ref.line().getDestinationWarehouseName(), ref.line().getFbaWarehouseCode(), - value(ref.line().getPlannedPalletCount()), total, received, Math.max(0, total - received)); + value(ref.line().getPlannedPalletCount()), total, received, Math.max(0, total - received), + palletMergeRequired(ref.order()), palletMergeHint(ref.order())); + } + + private boolean palletMergeRequired(LastlegOrder order) { + return order != null && ("REQUIRED".equals(order.getPalletMergeFlag()) + || "MERGED".equals(order.getPalletMergeFlag())); + } + + private String palletMergeHint(LastlegOrder order) { + return palletMergeRequired(order) ? PALLET_MERGE_HINT : null; } private Totals totals(List containerPlans) { 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 90e8c7df0..3201c9d1f 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 @@ -36,6 +36,7 @@ import java.util.stream.Collectors; @Service public class InboundPlanService { + @Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator; @Resource private InboundPlanMapper planMapper; @Resource private InboundPlanLineMapper lineMapper; @Resource private InboundPlanPalletMapper palletMapper; @@ -106,7 +107,7 @@ public class InboundPlanService { LocalDateTime now = LocalDateTime.now(); InboundPlan plan = new InboundPlan(); plan.setId(IdWorker.getId()); - plan.setPlanNo("LIP" + plan.getId()); + plan.setPlanNo(orderNoGenerator.next("INBOUND_PLAN", orders.get(0).destinationWarehouseCode(), plan.getId())); plan.setCustomerId(customerId); plan.setSourceOrderId(selectedOrderId); plan.setContainerNo(containerNo); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java index e292046d9..343491524 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java @@ -31,4 +31,10 @@ public interface InventoryCommandMapper { List lockInboundTransitBalances(@Param("customerId") String customerId, @Param("inboundOrderId") Long inboundOrderId, @Param("inboundCode") String inboundCode); + + @Select("SELECT p.* FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id " + + "WHERE p.customer_id=#{customerId} AND b.inbound_order_id=#{inboundOrderId} " + + "AND p.hold_packages>0 ORDER BY p.id FOR UPDATE") + List lockInboundHeldPositions(@Param("customerId") String customerId, + @Param("inboundOrderId") Long inboundOrderId); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java index 9481a2c11..6d2bef118 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java @@ -60,13 +60,25 @@ public class InventoryCommandService { @Transactional public InventoryCommandResponse stockIn(String customerId, String operatorId, InventoryCommandRequests.StockIn request) { + return stockIn(customerId, operatorId, request, false); + } + + @Transactional + public InventoryCommandResponse stockInHeld(String customerId, String operatorId, + InventoryCommandRequests.StockIn request) { + return stockIn(customerId, operatorId, request, true); + } + + private InventoryCommandResponse stockIn(String customerId, String operatorId, + InventoryCommandRequests.StockIn request, boolean held) { return execute(customerId, operatorId, "STOCK_IN", request.idempotencyKey(), request, operation -> { List transitBalances = commands.lockInboundTransitBalances(customerId, request.inboundOrderId(), request.inboundCode()); InventoryBalance balance = balance(customerId, request); InventoryPosition position = position(customerId, balance, request); - long before = value(position.getAvailablePackages()); - position.setAvailablePackages(before + request.packages()); + long before = held ? value(position.getHoldPackages()) : value(position.getAvailablePackages()); + if (held) position.setHoldPackages(before + request.packages()); + else position.setAvailablePackages(before + request.packages()); position.setStatus("OCCUPIED"); position.setVersion(value(position.getVersion()) + 1); position.setUpdatedAt(LocalDateTime.now()); @@ -74,11 +86,12 @@ public class InventoryCommandService { long transferred = transferInTransit(customerId, operatorId, operation.getId(), balance, transitBalances, request); - balance.setAvailablePackages(value(balance.getAvailablePackages()) + request.packages()); + if (held) balance.setHoldPackages(value(balance.getHoldPackages()) + request.packages()); + else balance.setAvailablePackages(value(balance.getAvailablePackages()) + request.packages()); touch(balance); balances.updateById(balance); ledger(customerId, operatorId, operation.getId(), balance, position, sku(request.skuCode()), "STOCK_IN", - transferred == request.packages() ? "IN_TRANSIT" : "OUTSIDE", "AVAILABLE", + transferred == request.packages() ? "IN_TRANSIT" : "OUTSIDE", held ? "HOLD" : "AVAILABLE", before, request.packages(), before + request.packages(), request.businessOrderNo(), request.inboundOrderNo(), null, request.sourceEventId(), request.idempotencyKey(), request.remark()); @@ -86,6 +99,17 @@ public class InventoryCommandService { }); } + @Transactional + public void releaseInboundOrderHold(String customerId, String operatorId, long inboundOrderId, + String businessOrderNo, String remark) { + for (InventoryPosition position : commands.lockInboundHeldPositions(customerId, inboundOrderId)) { + long packages = value(position.getHoldPackages()); + if (packages == 0) continue; + release(customerId, operatorId, new InventoryCommandRequests.Move(position.getId(), packages, + businessOrderNo, null, null, "ORDER_HOLD_RELEASE:" + inboundOrderId + ":" + position.getId(), remark)); + } + } + private long transferInTransit(String customerId, String operatorId, Long operationId, InventoryBalance target, List sources, InventoryCommandRequests.StockIn request) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java index b50c883ba..9dfa094fd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java @@ -70,7 +70,7 @@ public class LocalLooseboardService { LocalLooseboardOrder order = new LocalLooseboardOrder(); order.setId(IdWorker.getId()); order.setCustomerId(customerId); - order.setOrderNo("LB" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + order.getId()); + order.setOrderNo(lastlegOrderNoGenerator.next("LOOSEBOARD", request.destination(), order.getId())); apply(order, request.transportMode(), request.palletRequired(), request.transportType(), request.origin(), request.originAddress(), request.destination(), request.destinationAddress(), request.containerType(), request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(), @@ -257,7 +257,7 @@ public class LocalLooseboardService { private LastlegOrder createLastlegOrder(LocalLooseboardOrder source, String requestHash, LocalDateTime now) { LastlegOrder order = new LastlegOrder(); order.setId(IdWorker.getId()); - order.setOrderNo(lastlegOrderNoGenerator.next()); + order.setOrderNo(lastlegOrderNoGenerator.next("LOOSEBOARD", source.getDestination(), order.getId())); order.setCustomerId(source.getCustomerId()); order.setOrderType("LOCAL_LOOSEBOARD"); order.setInboundCode(source.getOrderNo()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java index 9973bdca2..b07c332a9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java @@ -42,4 +42,10 @@ public interface LastlegOrderMapper extends BaseMapper { int claimReceivingWarehouse(@Param("customerId") String customerId, @Param("orderId") long orderId, @Param("warehouseId") String warehouseId, @Param("warehouseName") String warehouseName); + + @Update("UPDATE ll_order SET pallet_merge_flag=#{flag},updated_at=#{updatedAt} " + + "WHERE customer_id=#{customerId} AND id=#{id}") + int updatePalletMergeFlag(@Param("customerId") String customerId, @Param("id") long id, + @Param("flag") String flag, + @Param("updatedAt") java.time.LocalDateTime updatedAt); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java index 6a08ae5aa..44eafd56e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java @@ -37,6 +37,7 @@ import java.util.Set; @Service public class OverseasProcurementService { + @Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator; public static final String REVIEW_PERMISSION = OverseasPermissions.PURCHASE_REVIEW; public static final String MANAGE_PERMISSION = OverseasPermissions.PURCHASE_EDIT; public static final String PENDING_REVIEW = "PENDING_REVIEW"; @@ -62,7 +63,7 @@ public class OverseasProcurementService { order.setId(IdWorker.getId()); order.setCustomerId(customerId); order.setCustomerName(trim(request.customerName())); - order.setOrderNo("HC" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + order.getId()); + order.setOrderNo(orderNoGenerator.next("PROCUREMENT", request.receivingAddress(), order.getId())); order.setPurchaseCategory(request.purchaseCategory().trim()); order.setProductName(request.productName().trim()); order.setProductSpecification(trim(request.productSpecification())); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java index c9bce8008..99cc8d7cc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java @@ -67,10 +67,10 @@ public class RelabelApplicationService { } LocalDateTime now = LocalDateTime.now(); long applicationId = IdWorker.getId(); - String relabelNo = "FBR" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + applicationId; + String relabelNo = orderNoGenerator.next("RELABEL", request.overseasWarehouseId(), applicationId); LastlegOrder order = new LastlegOrder(); order.setId(IdWorker.getId()); - order.setOrderNo(orderNoGenerator.next()); + order.setOrderNo(orderNoGenerator.next("RELABEL", request.overseasWarehouseId(), order.getId())); order.setCustomerId(customerId); order.setOrderType("RELABEL_APPLICATION"); order.setInboundCode(relabelNo); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java index 86b89766f..bb76b6e76 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java @@ -3,15 +3,59 @@ package com.baiyue.lastleg.service; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import org.springframework.stereotype.Component; -import java.math.BigInteger; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Locale; @Component public class LastlegOrderNoGenerator { public String next() { - return "LMO" + IdWorker.getIdStr(); + long id = IdWorker.getId(); + return next("LMO", null, id); + } + + public String next(String businessType, String warehouse, long uniqueId) { + String type = normalizeType(businessType); + return type + "-" + warehouseCode(warehouse) + "-" + + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-" + + String.format(Locale.ROOT, "%06d", Math.floorMod(uniqueId, 1_000_000)); + } + + private String normalizeType(String value) { + String code = value == null ? "LMO" : value.trim().toUpperCase(Locale.ROOT); + return switch (code) { + case "FIRST_LEG" -> "FL"; + case "ONE_CLICK_DROPSHIP" -> "OD"; + case "RELABEL_APPLICATION", "RELABEL" -> "RL"; + case "WAREHOUSE_TRANSFER", "TRANSFER" -> "WT"; + case "LOCAL_LOOSEBOARD", "LOOSEBOARD" -> "LB"; + case "DISPATCH", "PICKUP_CAR" -> "DS"; + case "OUTBOUND" -> "OB"; + case "DELIVERY" -> "DL"; + case "TRUCK" -> "TR"; + case "EXCEPTION" -> "EX"; + case "DISCARD" -> "CD"; + case "PROCUREMENT" -> "PR"; + case "INBOUND_PLAN" -> "IP"; + default -> { + String normalized = code.replaceAll("[^A-Z0-9]", ""); + yield normalized.isEmpty() ? "LMO" : normalized.substring(0, Math.min(4, normalized.length())); + } + }; + } + + public String warehouseCode(String value) { + String code = value == null ? "" : value.trim().toUpperCase(Locale.ROOT); + if (code.isEmpty()) return "WH"; + if (code.matches(".*(LAX|ONT|NYC|JFK|LGB|EWR|SFO|SEA|DFW|ORD|ATL|MIA|PHX|LAS|YYZ|YVR|AMS|LHR|FRA|CDG|HKG|NRT|SYD).*")) { + var match = java.util.regex.Pattern.compile("(LAX|ONT|NYC|JFK|LGB|EWR|SFO|SEA|DFW|ORD|ATL|MIA|PHX|LAS|YYZ|YVR|AMS|LHR|FRA|CDG|HKG|NRT|SYD)").matcher(code); + if (match.find()) return match.group(1); + } + if (code.matches("[A-Z0-9]{2,8}")) return code; + return "WH"; } /** The physical container number is the tail-leg business order grouping boundary. */ @@ -20,14 +64,11 @@ public class LastlegOrderNoGenerator { throw new IllegalArgumentException("containerNo is required"); } String normalized = containerNo.trim(); - if (normalized.matches("[0-9]{1,29}")) { - return "LMO" + normalized; - } try { byte[] digest = MessageDigest.getInstance("SHA-256") .digest(normalized.getBytes(StandardCharsets.UTF_8)); - String digits = new BigInteger(1, digest).toString(); - return "LMO" + digits.substring(0, Math.min(29, digits.length())); + long suffix = java.nio.ByteBuffer.wrap(digest, 0, 8).getLong(); + return next("FIRST_LEG", null, suffix); } catch (NoSuchAlgorithmException exception) { throw new IllegalStateException("SHA-256 is unavailable", exception); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java index a9ddd69c9..b8a887139 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -14,6 +14,7 @@ import com.baiyue.lastleg.entity.LastlegOrderFee; import com.baiyue.lastleg.entity.LastlegOrderOperationLog; import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; @@ -38,6 +39,8 @@ public class LastlegOrderService { public static final String PENDING_ASSIGNMENT = "PENDING_ASSIGNMENT"; public static final String DESTINATION_ASSIGNED = "DESTINATION_ASSIGNED"; public static final String INBOUND_PLANNED = "INBOUND_PLANNED"; + public static final String PALLET_MERGE_REQUIRED = "REQUIRED"; + public static final String PALLET_MERGED = "MERGED"; @Resource private LastlegOrderMapper orderMapper; @Resource private LastlegOrderDetailMapper detailMapper; @@ -45,6 +48,7 @@ public class LastlegOrderService { @Resource private LastlegOrderFeeMapper feeMapper; @Resource private LastlegOrderOperationLogMapper operationLogMapper; @Resource private InboundPlanCartonMapper cartonMapper; + @Resource private InventoryCommandService inventory; public PageResult page(String customerId, LastlegOrderRequests.Query query) { Page page = orderMapper.selectPage( @@ -110,6 +114,70 @@ public class LastlegOrderService { return fee; } + @Transactional + public void setDetained(String customerId, long orderId, LastlegOrderRequests.SetDetained request, + String operatorName) { + LastlegOrder current = findOwned(customerId, orderId); + LastlegOrder order = orderMapper.lockById(current.getCustomerId(), orderId); + if (order == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Tail-leg order not found"); + boolean detained = request.detained(); + if (Boolean.TRUE.equals(order.getDetained()) == detained) { + throw new ResponseStatusException(HttpStatus.CONFLICT, detained ? "Order is already held" : "Order is not held"); + } + if (detained && "INBOUND_COMPLETED".equals(order.getCurrentStatus())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Completed inbound orders cannot be newly held"); + } + LocalDateTime now = LocalDateTime.now(); + order.setDetained(detained); + order.setUpdatedAt(now); + orderMapper.updateById(order); + if (!detained && "INBOUND_COMPLETED".equals(order.getCurrentStatus())) { + inventory.releaseInboundOrderHold(order.getCustomerId(), operatorName, orderId, order.getOrderNo(), request.remark().trim()); + } + LastlegOrderOperationLog operation = new LastlegOrderOperationLog(); + operation.setId(IdWorker.getId()); + operation.setCustomerId(order.getCustomerId()); + operation.setOrderId(orderId); + operation.setOperationNode(detained ? "暂扣" : "解除暂扣"); + operation.setOperatorName(operatorName); + operation.setResult(request.remark().trim()); + operation.setCreatedAt(now); + operationLogMapper.insert(operation); + } + + @Transactional + public void setPalletMergeRequired(String customerId, long orderId, + LastlegOrderRequests.SetPalletMergeRequired request, + String operatorName) { + LastlegOrder current = findOwned(customerId, orderId); + LastlegOrder order = orderMapper.lockById(current.getCustomerId(), orderId); + if (order == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Tail-leg order not found"); + if ("INBOUND_COMPLETED".equals(order.getCurrentStatus())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Completed inbound orders cannot change the pallet merge mark"); + } + if (PALLET_MERGED.equals(order.getPalletMergeFlag())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "A completed pallet merge cannot be unmarked"); + } + String target = request.palletMergeRequired() ? PALLET_MERGE_REQUIRED : null; + if (Objects.equals(target, order.getPalletMergeFlag())) return; + LocalDateTime now = LocalDateTime.now(); + if (orderMapper.updatePalletMergeFlag(order.getCustomerId(), orderId, target, now) != 1) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Pallet merge mark was not updated"); + } + LastlegOrderOperationLog operation = new LastlegOrderOperationLog(); + operation.setId(IdWorker.getId()); + operation.setCustomerId(order.getCustomerId()); + operation.setOrderId(orderId); + operation.setOperationNode(request.palletMergeRequired() ? "标记合托" : "取消合托标记"); + operation.setOperatorName(operatorName); + operation.setResult(request.palletMergeRequired() + ? "PDA扫描本单箱唛时提示需要合托" : "已取消PDA合托提示"); + operation.setCreatedAt(now); + operationLogMapper.insert(operation); + } + @Transactional public int transferDestination(String customerId, LastlegOrderRequests.TransferDestination request) { List ids = request.orderIds().stream().filter(Objects::nonNull).distinct().toList(); 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 f904547a6..0a0deca35 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 @@ -83,8 +83,8 @@ public class LoadingCompletedConsumer { shippingGates.initialize(payload.containerBookingId(), envelope.tenantId(), "", payload.containerNo(), 0, envelope.handoverVersion()); - String orderNo = orderNoGenerator.forContainerNo(payload.containerNo()); JsonNode rawOrders = envelope.payload().path("orders"); + String orderNo = orderNoGenerator.forContainerNo(payload.containerNo()); for (int index = 0; index < payload.orders().size(); index++) { var source = payload.orders().get(index); JsonNode rawSource = rawOrders.isArray() && index < rawOrders.size() ? rawOrders.get(index) : objectMapper.createObjectNode(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java index 8995b02bd..3abe2459e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -117,7 +117,7 @@ public class WarehouseTransferService { LocalDateTime now = LocalDateTime.now(); long transferId = IdWorker.getId(); - String transferNo = "WTR" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + transferId; + String transferNo = orderNoGenerator.next("TRANSFER", request.operatingWarehouseId(), transferId); LastlegOrder order = createTailOrder(customerId, inboundOrderId, transferNo, request, requestHash, now); WarehouseTransferOrder transfer = new WarehouseTransferOrder(); @@ -464,7 +464,7 @@ public class WarehouseTransferService { private LastlegOrder createTailOrder(String customerId, Long inboundOrderId, String transferNo, WarehouseTransferRequests.Create request, String requestHash, LocalDateTime now) { LastlegOrder order = new LastlegOrder(); - order.setId(IdWorker.getId()); order.setOrderNo(orderNoGenerator.next()); order.setCustomerId(customerId); + order.setId(IdWorker.getId()); order.setOrderNo(orderNoGenerator.next("TRANSFER", request.operatingWarehouseId(), order.getId())); order.setCustomerId(customerId); order.setOrderType(request.needRelabel() ? RETURN_RELABEL_ORDER_TYPE : ORDER_TYPE); order.setInboundCode(request.inboundCode().trim()); order.setSourceRegion("CUSTOMER"); order.setSourceInboundId("WAREHOUSE_TRANSFER:" + transferNo); order.setRootInboundId(String.valueOf(inboundOrderId)); @@ -505,7 +505,7 @@ public class WarehouseTransferService { private void createRelabel(String customerId, String operatorId, WarehouseTransferOrder transfer, LastlegOrder order, WarehouseTransferRequests.Create request, LocalDateTime now) { long applicationId = IdWorker.getId(); - String relabelNo = "FBR" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + applicationId; + String relabelNo = orderNoGenerator.next("RELABEL", request.operatingWarehouseId(), applicationId); RelabelApplication application = new RelabelApplication(); application.setId(applicationId); application.setCustomerId(customerId); application.setRelabelNo(relabelNo); application.setLastlegOrderId(order.getId()); application.setCustomerOrderNo(transfer.getTransferNo()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java index fe586c4da..f6aae16a0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java @@ -39,6 +39,7 @@ import java.util.function.Consumer; @Service public class UsTruckOrderService { + @Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator; public static final String MANAGE_PERMISSION = OverseasPermissions.TRUCK_STATUS; public static final String PENDING_REVIEW = "PENDING_REVIEW"; /** Kept for source compatibility; new orders use PENDING_REVIEW. */ @@ -79,7 +80,7 @@ public class UsTruckOrderService { UsTruckOrder order = new UsTruckOrder(); order.setId(IdWorker.getId()); order.setCustomerId(customerId); - order.setOrderNo("TK" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + order.getId()); + order.setOrderNo(orderNoGenerator.next("TRUCK", request.origin(), order.getId())); order.setTransportType(StringUtils.hasText(request.transportType()) ? norm(request.transportType()) : null); order.setOrigin(trim(request.origin())); order.setOriginAddress(trim(request.originAddress())); order.setDestination(trim(request.destination())); order.setDestinationAddress(trim(request.destinationAddress())); diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 3d99adc59..884f6ac5e 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -179,6 +179,25 @@ paths: responses: '200': { description: 新增后的费用明细, content: { application/json: { schema: { $ref: '#/components/schemas/LastMileFeeApiResponse' } } } } '404': { description: 尾程订单不存在或不属于操作客户 } + /api/platform/v1/last-leg/orders/{id}/pallet-merge-required: + put: + summary: 设置尾程订单合托标记 + description: 权限 platform:overseas-warehouse:last-mile-order:edit;标记后,PDA扫描本单箱唛并解析入仓单时返回合托提示;实际完成合托后不可取消。 + security: [{ bearerAuth: [] }] + parameters: + - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [palletMergeRequired] + properties: { palletMergeRequired: { type: boolean } } + responses: + '200': { description: 合托标记已更新 } + '404': { description: 尾程订单不存在或不属于操作客户 } + '409': { description: 入仓已完成或实际合托已完成,不能修改标记 } /api/platform/v1/last-leg/dispatches: get: summary: 分页查询排车单 @@ -729,7 +748,7 @@ components: timestamp: { type: string, format: date-time } ReceivingInbound: type: object - required: [inboundCode, destinationType, plannedPalletCount, totalPackages, receivedPackages, pendingPackages] + required: [inboundCode, destinationType, plannedPalletCount, totalPackages, receivedPackages, pendingPackages, palletMergeRequired] properties: inboundCode: { type: string } destinationType: { type: string, enum: [FBA, OVERSEAS] } @@ -743,6 +762,8 @@ components: totalPackages: { type: integer } receivedPackages: { type: integer } pendingPackages: { type: integer } + palletMergeRequired: { type: boolean, description: 尾程订单是否要求PDA合托提醒 } + palletMergeHint: { type: string, nullable: true, description: 标记合托时供PDA直接展示的中文提示 } ReceivingWarehouseProfile: type: object required: [warehouseId, warehouseCode, warehouseName] @@ -810,7 +831,7 @@ components: compatible: { type: boolean } ReceivingReceipt: type: object - required: [result, containerNo, inboundCode, destinationType, palletCode, acceptedPackages, receivedPackages, pendingPackages, sessionStatus] + required: [result, containerNo, inboundCode, destinationType, palletCode, acceptedPackages, receivedPackages, pendingPackages, sessionStatus, palletMergeRequired] properties: result: { type: string } containerNo: { type: string } @@ -821,6 +842,8 @@ components: receivedPackages: { type: integer } pendingPackages: { type: integer } sessionStatus: { type: string, enum: [SIGNED, RECEIVING] } + palletMergeRequired: { type: boolean } + palletMergeHint: { type: string, nullable: true } ReceivingCandidateListApiResponse: allOf: - { $ref: '#/components/schemas/ReceivingApiResponseBase' } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java index 91e56a48d..17703c2da 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -22,6 +22,7 @@ import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -73,6 +74,7 @@ class DeliveryTransferServiceTest { ReflectionTestUtils.setField(service, "deliveryOperations", deliveryOperations); ReflectionTestUtils.setField(service, "planAllocations", planAllocations); ReflectionTestUtils.setField(service, "planLines", planLines); + ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator()); ReflectionTestUtils.setField(service, "tailOrders", tailOrders); ReflectionTestUtils.setField(service, "histories", histories); ReflectionTestUtils.setField(service, "tailOrderDetails", tailOrderDetails); @@ -102,10 +104,10 @@ class DeliveryTransferServiceTest { assertEquals(2, outbound.getValue().getPalletCount()); assertEquals(20, outbound.getValue().getPackageCount()); assertEquals("transfer-101", outbound.getValue().getIdempotencyKey()); - assertTrue(outbound.getValue().getOutboundNo().startsWith("OUT")); + assertTrue(outbound.getValue().getOutboundNo().matches("OB-WH-\\d{8}-\\d{6}")); assertEquals(outbound.getValue().getId(), delivery.getValue().getOutboundOrderId()); assertEquals("WAIT_DELIVERY", delivery.getValue().getStatus()); - assertTrue(delivery.getValue().getDeliveryNo().startsWith("DEL")); + assertTrue(delivery.getValue().getDeliveryNo().matches("DL-WH-\\d{8}-\\d{6}")); assertEquals(List.of("ONT8", "LAX9"), pallets.getAllValues().stream() .map(OutboundPalletItem::getDestinationWarehouseCode).toList()); assertEquals(List.of("IN-A", "IN-B"), pallets.getAllValues().stream() diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java index ce9707e49..3fe43bd57 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java @@ -14,6 +14,7 @@ import com.baiyue.lastleg.dropship.entity.DropshipCarton; import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -63,6 +64,7 @@ class CargoDiscardServiceTest { ReflectionTestUtils.setField(service, "histories", histories); ReflectionTestUtils.setField(service, "inventory", inventory); ReflectionTestUtils.setField(service, "adjustments", adjustments); + ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator()); ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); ReflectionTestUtils.setField(service, "feePerPackage", new BigDecimal("5.00")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java index 95e14f0e3..46a243918 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java @@ -56,6 +56,7 @@ class DispatchServiceTest { ReflectionTestUtils.setField(service, "candidates", candidates); ReflectionTestUtils.setField(service, "drafts", drafts); ReflectionTestUtils.setField(service, "draftContainers", draftContainers); + ReflectionTestUtils.setField(service, "orderNoGenerator", new com.baiyue.lastleg.service.LastlegOrderNoGenerator()); lenient().when(items.findByDispatchOrder(any(), any())).thenReturn(List.of()); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java index b72218afc..647a6b2ac 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java @@ -155,6 +155,21 @@ class InboundStockReceiptServiceTest { assertEquals(5L, requests.getAllValues().stream().mapToLong(InventoryCommandRequests.StockIn::packages).sum()); } + @Test + void stocksInToHeldInventoryWhenInboundOrderIsDetained() { + Fixture fixture = fixture(5, 5, 5, 10); + fixture.order.setDetained(true); + when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order)); + when(inventory.stockInHeld(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class))) + .thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 5, 0, 0, 0, false, false)); + + service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line), + List.of(fixture.allocation), List.of(fixture.pallet), LocalDateTime.now()); + + verify(inventory).stockInHeld(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class)); + verify(inventory, never()).stockIn(any(), any(), any()); + } + @Test void existingTaskReceiptMakesStockInReplayANoOp() { Fixture fixture = fixture(10, 5, 5, 10); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index 8be1cc079..c1b436fbb 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -422,6 +422,20 @@ class ReceivingContainerServiceBehaviorTest { verifyNoInteractions(workflow, operations); } + @Test + void cartonResolutionReturnsInboundAndPalletMergeReminderFromTailLegOrder() { + stubCurrent("FBA", "CONT-A"); + LastlegOrder marked = order("FBA"); + marked.setPalletMergeFlag("REQUIRED"); + when(orders.selectById(1L)).thenReturn(marked); + + var resolved = service.resolveCarton("7", new ReceivingContainerRequests.CartonResolve("IN-1#2-1")); + + assertThat(resolved.inboundCode()).isEqualTo("IN-1"); + assertThat(resolved.palletMergeRequired()).isTrue(); + assertThat(resolved.palletMergeHint()).contains("合托"); + } + @Test void fbaCartonResolutionRejectsCrossContainerAndInvalidPrefixesAsConflicts() { stubCurrent("FBA", "CONT-A"); 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 5b0395ca4..1d19eeb84 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 @@ -55,6 +55,7 @@ class InboundPlanServiceTest { ReflectionTestUtils.setField(service, "allocationMapper", allocationMapper); ReflectionTestUtils.setField(service, "orderMapper", orderMapper); ReflectionTestUtils.setField(service, "inventoryBalanceMapper", inventoryBalanceMapper); + ReflectionTestUtils.setField(service, "orderNoGenerator", new com.baiyue.lastleg.service.LastlegOrderNoGenerator()); org.mockito.Mockito.lenient().when(lineMapper.selectList(any())).thenReturn(List.of()); org.mockito.Mockito.lenient().when(palletMapper.selectList(any())).thenReturn(List.of()); org.mockito.Mockito.lenient().when(allocationMapper.selectList(any())).thenReturn(List.of()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java index 330c6b2fd..f769cad6e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java @@ -16,6 +16,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; +import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.server.ResponseStatusException; @@ -43,7 +44,7 @@ class LocalLooseboardServiceTest { @Mock LocalLooseboardOperationMapper operations; @Mock LastlegOrderMapper lastlegOrders; @Mock LastlegOrderStatusHistoryMapper lastlegHistories; - @Mock LastlegOrderNoGenerator lastlegOrderNoGenerator; + @Spy LastlegOrderNoGenerator lastlegOrderNoGenerator = new LastlegOrderNoGenerator(); private LocalLooseboardService service; private final Map operationRows = new HashMap<>(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java index a73d2f04a..08ec9fc11 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java @@ -45,6 +45,7 @@ class OverseasProcurementServiceTest { ReflectionTestUtils.setField(service, "histories", histories); ReflectionTestUtils.setField(service, "warehouseDirectory", warehouseDirectory); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); + ReflectionTestUtils.setField(service, "orderNoGenerator", new com.baiyue.lastleg.service.LastlegOrderNoGenerator()); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java index 97a8dad24..a6b93f678 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java @@ -19,6 +19,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; +import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.server.ResponseStatusException; @@ -44,7 +45,7 @@ class RelabelApplicationServiceTest { @Mock RelabelStatusHistoryMapper histories; @Mock LastlegOrderMapper orders; @Mock LastlegOrderStatusHistoryMapper orderHistories; - @Mock LastlegOrderNoGenerator orderNoGenerator; + @Spy LastlegOrderNoGenerator orderNoGenerator = new LastlegOrderNoGenerator(); private RelabelApplicationService service; @@ -58,7 +59,6 @@ class RelabelApplicationServiceTest { ReflectionTestUtils.setField(service, "orderHistories", orderHistories); ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator); ReflectionTestUtils.setField(service, "objectMapper", mapper()); - lenient().when(orderNoGenerator.next()).thenReturn("LMO-RELABEL-1"); } @Test @@ -91,10 +91,10 @@ class RelabelApplicationServiceTest { assertEquals("PENDING_ASSIGNMENT", tailOrder.getValue().getCurrentStatus()); assertEquals("RELABEL:PENDING_PROCESSING", tailOrder.getValue().getSubOrderStatus()); assertEquals("CUSTOMER", tailOrder.getValue().getSourceRegion()); - assertEquals("LMO-RELABEL-1", tailOrder.getValue().getOrderNo()); + assertTrue(tailOrder.getValue().getOrderNo().matches("RL-WH-\\d{8}-\\d{6}")); assertEquals(tailOrder.getValue().getInboundCode(), tailOrder.getValue().getSourceInboundId()); assertEquals(tailOrder.getValue().getInboundCode(), application.getValue().getRelabelNo()); - assertTrue(tailOrder.getValue().getInboundCode().startsWith("FBR")); + assertTrue(tailOrder.getValue().getInboundCode().startsWith("RL-")); assertEquals(10, tailOrder.getValue().getPackageCount()); assertEquals(6, application.getValue().getNotifiedPackageCount()); assertEquals("SKU-A", relabelItem.getValue().getSkuCode()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java index 410b21505..efdb4bd62 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java @@ -7,13 +7,19 @@ import static org.junit.jupiter.api.Assertions.*; class LastlegOrderNoGeneratorTest { @Test void actualContainerNumberIsStableAndShared() { LastlegOrderNoGenerator generator = new LastlegOrderNoGenerator(); - assertEquals("LMO2087379674555461633", generator.forContainerNo("2087379674555461633")); + assertTrue(generator.forContainerNo("2087379674555461633").matches("FL-WH-\\d{8}-\\d{6}")); assertEquals(generator.forContainerNo("MSCU1"), new LastlegOrderNoGenerator().forContainerNo("MSCU1")); assertNotEquals(generator.forContainerNo("MSCU1"), generator.forContainerNo("CAB1")); assertTrue(generator.forContainerNo("MSCU1234567890").length() <= 32); assertThrows(IllegalArgumentException.class, () -> generator.forContainerNo(" ")); } + @Test void formatsBusinessWarehouseAndDate() { + String value = new LastlegOrderNoGenerator().next("OUTBOUND", "warehouse-LAX-01", 123L); + assertTrue(value.matches("OB-LAX-\\d{8}-000123"), value); + assertTrue(value.length() <= 24); + } + @Test void remainsUniqueAcrossInstancesAndConcurrentCalls() throws Exception { LastlegOrderNoGenerator beforeRestart=new LastlegOrderNoGenerator(); String first=beforeRestart.next(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java index 6746ae723..6284fcb56 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -6,6 +6,7 @@ import com.baiyue.lastleg.entity.LastlegOrderDetail; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; @@ -27,6 +28,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -39,6 +42,7 @@ class LastlegOrderServiceTest { @Mock LastlegOrderFeeMapper fees; @Mock LastlegOrderOperationLogMapper operations; @Mock InboundPlanCartonMapper cartons; + @Mock InventoryCommandService inventory; LastlegOrderService service; @BeforeEach @@ -50,6 +54,7 @@ class LastlegOrderServiceTest { ReflectionTestUtils.setField(service, "feeMapper", fees); ReflectionTestUtils.setField(service, "operationLogMapper", operations); ReflectionTestUtils.setField(service, "cartonMapper", cartons); + ReflectionTestUtils.setField(service, "inventory", inventory); } @Test @@ -115,6 +120,37 @@ class LastlegOrderServiceTest { verify(operations).insert(any(com.baiyue.lastleg.entity.LastlegOrderOperationLog.class)); } + @Test + void palletMergeMarkIsPersistedAndAuditedForPdaReminder() { + LastlegOrder order = order(11L, "100", "INBOUND_PLANNED", "IB-1"); + when(orders.selectOne(any())).thenReturn(order); + when(orders.lockById("100", 11L)).thenReturn(order); + when(orders.updatePalletMergeFlag(any(), anyLong(), any(), any())).thenReturn(1); + + service.setPalletMergeRequired("100", 11L, + new LastlegOrderRequests.SetPalletMergeRequired(true), "platform_admin"); + + verify(orders).updatePalletMergeFlag(org.mockito.ArgumentMatchers.eq("100"), + org.mockito.ArgumentMatchers.eq(11L), org.mockito.ArgumentMatchers.eq("REQUIRED"), any()); + ArgumentCaptor operation = + ArgumentCaptor.forClass(com.baiyue.lastleg.entity.LastlegOrderOperationLog.class); + verify(operations).insert(operation.capture()); + assertEquals("标记合托", operation.getValue().getOperationNode()); + assertTrue(operation.getValue().getResult().contains("PDA")); + } + + @Test + void completedPhysicalMergeCannotBeUnmarked() { + LastlegOrder order = order(11L, "100", "INBOUND_PLANNED", "IB-1"); + order.setPalletMergeFlag("MERGED"); + when(orders.selectOne(any())).thenReturn(order); + when(orders.lockById("100", 11L)).thenReturn(order); + + assertThrows(ResponseStatusException.class, () -> service.setPalletMergeRequired("100", 11L, + new LastlegOrderRequests.SetPalletMergeRequired(false), "platform_admin")); + verify(orders, never()).updatePalletMergeFlag(any(), anyLong(), any(), any()); + } + @Test void transferRejectsAnOrderAlreadyBoundToInboundPlan() { LastlegOrder order = order(11L, "100", "DESTINATION_ASSIGNED", "IB-1"); @@ -157,6 +193,38 @@ class LastlegOrderServiceTest { assertEquals(25, page.getValue().getSize()); } + @Test + void holdingBeforeInboundPersistsTheOrderMarkerAndOperationRemark() { + LastlegOrder order = order(11L, "100", "PENDING_INBOUND", null); + when(orders.selectOne(any())).thenReturn(order); + when(orders.lockById("100", 11L)).thenReturn(order); + + service.setDetained("100", 11L, new LastlegOrderRequests.SetDetained(true, "等待质检"), "operator"); + + assertTrue(order.getDetained()); + verify(orders).updateById(order); + ArgumentCaptor operation = + ArgumentCaptor.forClass(com.baiyue.lastleg.entity.LastlegOrderOperationLog.class); + verify(operations).insert(operation.capture()); + assertEquals("暂扣", operation.getValue().getOperationNode()); + assertEquals("等待质检", operation.getValue().getResult()); + verify(inventory, never()).releaseInboundOrderHold(any(), any(), anyLong(), any(), any()); + } + + @Test + void releasingAfterInboundMovesAllOrderHeldInventoryToAvailable() { + LastlegOrder order = order(11L, "100", "INBOUND_COMPLETED", "IP-1"); + order.setOrderNo("LM-1"); + order.setDetained(true); + when(orders.selectOne(any())).thenReturn(order); + when(orders.lockById("100", 11L)).thenReturn(order); + + service.setDetained("100", 11L, new LastlegOrderRequests.SetDetained(false, "质检通过"), "operator"); + + verify(inventory).releaseInboundOrderHold("100", "operator", 11L, "LM-1", "质检通过"); + assertEquals(false, order.getDetained()); + } + private static LastlegOrder order(long id, String customerId, String status, String inboundOrderNo) { LastlegOrder order = new LastlegOrder(); order.setId(id); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java index df28e085b..32f23b8bd 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java @@ -32,6 +32,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; +import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.server.ResponseStatusException; @@ -70,7 +71,7 @@ class WarehouseTransferServiceTest { @Mock RelabelApplicationMapper relabelApplications; @Mock RelabelItemMapper relabelItems; @Mock RelabelStatusHistoryMapper relabelHistories; - @Mock LastlegOrderNoGenerator orderNoGenerator; + @Spy LastlegOrderNoGenerator orderNoGenerator = new LastlegOrderNoGenerator(); @Mock InventoryCommandService inventory; @Mock InventoryCommandMapper inventoryCommands; private WarehouseTransferService service; @@ -116,7 +117,7 @@ class WarehouseTransferServiceTest { assertEquals(WarehouseTransferService.RETURN_RELABEL_ORDER_TYPE, tailOrder.getValue().getOrderType()); assertEquals("PENDING_ASSIGNMENT", tailOrder.getValue().getCurrentStatus()); assertEquals("WAREHOUSE_TRANSFER:PENDING_REVIEW;RELABEL:PENDING_PROCESSING", tailOrder.getValue().getSubOrderStatus()); - assertEquals("LMO-TRANSFER-1", tailOrder.getValue().getOrderNo()); + assertTrue(tailOrder.getValue().getOrderNo().matches("WT-WH-\\d{8}-\\d{6}")); assertNull(tailOrder.getValue().getTransferWarehouseId()); verify(sourceOrders).insert(any(WarehouseTransferSourceOrder.class)); verify(relabelApplications).insert(any(RelabelApplication.class)); From 4f2b670ebfb1476edc0f69656732765656ccf316 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Fri, 28 Aug 2026 14:55:18 +0800 Subject: [PATCH 17/24] =?UTF-8?q?=E6=9A=82=E6=89=A3=20=E6=A0=87=E8=AE=B0?= =?UTF-8?q?=E5=90=88=E6=89=98=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/海外区域统一增量-MySQL8.sql | 4 +++- .../dto/OverseasProcurementRequests.java | 12 +++++++++- .../dto/OverseasProcurementResponses.java | 4 +++- .../entity/OverseasProcurementOrder.java | 1 + .../service/OverseasProcurementService.java | 23 +++++++++++++++++-- .../src/main/resources/db/lastleg.sql | 2 +- .../openapi/overseas-business-v1.yaml | 13 +++++++++++ 7 files changed, 53 insertions(+), 6 deletions(-) diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 61a281a6c..d55f1a6dd 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -195,7 +195,7 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order ( purchase_quantity BIGINT NOT NULL COMMENT '采购数量', budget_unit_price DECIMAL(18,2) NULL COMMENT '预算单价', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', receiving_address VARCHAR(1000) NULL COMMENT '收货地址', contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', contact_phone VARCHAR(64) NOT NULL COMMENT '联系人电话', - purchase_warehouse_id VARCHAR(128) NULL COMMENT '采购入仓仓库ID', purchase_warehouse_name VARCHAR(255) NULL COMMENT '采购入仓仓库名称', + purchase_warehouse_id VARCHAR(128) NULL COMMENT '采购入仓仓库ID', purchase_warehouse_name VARCHAR(255) NULL COMMENT '采购入仓仓库名称', outbound_details JSON NULL COMMENT '出库明细', inbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '采购单据累计入库数量', domestic_carrier VARCHAR(128) NULL COMMENT '境内承运商', domestic_tracking_no VARCHAR(128) NULL COMMENT '境内物流单号', overseas_carrier VARCHAR(128) NULL COMMENT '海外承运商', overseas_tracking_no VARCHAR(128) NULL COMMENT '海外物流单号', estimated_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', @@ -211,6 +211,8 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order ( KEY idx_ll_overseas_procurement_list(customer_id,status,created_at), KEY idx_ll_overseas_procurement_warehouse(customer_id,purchase_warehouse_id,created_at) ) ENGINE=InnoDB COMMENT='海外采购订单'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='outbound_details'), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN outbound_details JSON NULL COMMENT ''出库明细'' AFTER inbound_quantity')); +PREPARE procurement_outbound_stmt FROM @ddl; EXECUTE procurement_outbound_stmt; DEALLOCATE PREPARE procurement_outbound_stmt; SET @ddl = (SELECT IF(EXISTS ( SELECT 1 FROM information_schema.columns diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java index d31cd0672..59581f373 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java @@ -56,5 +56,15 @@ public final class OverseasProcurementRequests { JsonNode overseasCarrier, JsonNode overseasTrackingNo, JsonNode estimatedArrivalAt, - JsonNode remark) {} + JsonNode remark, + JsonNode outboundDetails) { + public Update(Long version, String purchaseCategory, String productName, JsonNode productSpecification, + JsonNode productUrl, Long inboundQuantity, JsonNode purchaseWarehouseId, JsonNode purchaseWarehouseName, + JsonNode domesticCarrier, JsonNode domesticTrackingNo, JsonNode overseasCarrier, JsonNode overseasTrackingNo, + JsonNode estimatedArrivalAt, JsonNode remark) { + this(version, purchaseCategory, productName, productSpecification, productUrl, inboundQuantity, + purchaseWarehouseId, purchaseWarehouseName, domesticCarrier, domesticTrackingNo, overseasCarrier, + overseasTrackingNo, estimatedArrivalAt, remark, null); + } + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java index d0f94de6b..638245829 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java @@ -7,6 +7,8 @@ import java.util.List; public final class OverseasProcurementResponses { private OverseasProcurementResponses() {} + public record OutboundDetail(String id, long quantity, String carrier, String trackingNo, LocalDateTime shippedAt, String remark) {} + public record Summary( Long id, String orderNo, String customerId, String customerName, String purchaseCategory, String productName, String productSpecification, @@ -32,5 +34,5 @@ public final class OverseasProcurementResponses { String overseasCarrier, String overseasTrackingNo, LocalDateTime estimatedArrivalAt, String remark, String createdBy, String reviewedBy, String reviewRemark, LocalDateTime reviewedAt, - List histories) {} + List histories, List outboundDetails) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java index 0a0c52dd9..df14cd381 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java @@ -30,6 +30,7 @@ public class OverseasProcurementOrder { @TableField(updateStrategy = FieldStrategy.ALWAYS) private String purchaseWarehouseId; @TableField(updateStrategy = FieldStrategy.ALWAYS) private String purchaseWarehouseName; private Long inboundQuantity; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String outboundDetails; @TableField(updateStrategy = FieldStrategy.ALWAYS) private String domesticCarrier; @TableField(updateStrategy = FieldStrategy.ALWAYS) private String domesticTrackingNo; @TableField(updateStrategy = FieldStrategy.ALWAYS) private String overseasCarrier; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java index 44eafd56e..9ef5d938c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java @@ -148,7 +148,7 @@ public class OverseasProcurementService { new OverseasProcurementResponses.History(value.getOperationType(), value.getFromStatus(), value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(), value.getChangeDetail(), value.getRemark(), - value.getCreatedAt())).toList()); + value.getCreatedAt())).toList(), outboundDetails(order)); } @Transactional @@ -205,6 +205,7 @@ public class OverseasProcurementService { patchText(request.overseasTrackingNo(), 128, "overseasTrackingNo", order::setOverseasTrackingNo); patchDateTime(request.estimatedArrivalAt(), order::setEstimatedArrivalAt); patchText(request.remark(), 1000, "remark", order::setRemark); + if (request.outboundDetails() != null) order.setOutboundDetails(normalizeOutbound(request.outboundDetails(), order.getInboundQuantity())); Map after = editableSnapshot(order); if (before.equals(after)) return detail(customerId, orderNo); LocalDateTime now = LocalDateTime.now(); @@ -230,10 +231,11 @@ public class OverseasProcurementService { private OverseasProcurementResponses.Summary summary(OverseasProcurementOrder order) { long inbound = value(order.getInboundQuantity()); + long outbound = outboundDetails(order).stream().mapToLong(OverseasProcurementResponses.OutboundDetail::quantity).sum(); return new OverseasProcurementResponses.Summary(order.getId(), order.getOrderNo(), order.getCustomerId(), order.getCustomerName(), order.getPurchaseCategory(), order.getProductName(), order.getProductSpecification(), value(order.getPurchaseQuantity()), order.getBudgetUnitPrice(), - order.getCurrency(), order.getPurchaseWarehouseId(), order.getPurchaseWarehouseName(), inbound, inbound, + order.getCurrency(), order.getPurchaseWarehouseId(), order.getPurchaseWarehouseName(), inbound, Math.max(0, inbound - outbound), order.getStatus(), value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt()); } @@ -336,9 +338,26 @@ public class OverseasProcurementService { result.put("overseasTrackingNo", order.getOverseasTrackingNo()); result.put("estimatedArrivalAt", order.getEstimatedArrivalAt()); result.put("remark", order.getRemark()); + result.put("outboundDetails", order.getOutboundDetails()); return result; } + private List outboundDetails(OverseasProcurementOrder order) { + if (!StringUtils.hasText(order.getOutboundDetails())) return List.of(); + try { return objectMapper.readValue(order.getOutboundDetails(), objectMapper.getTypeFactory().constructCollectionType(List.class, OverseasProcurementResponses.OutboundDetail.class)); } + catch (JsonProcessingException e) { return List.of(); } + } + private String normalizeOutbound(JsonNode node, Long inbound) { + if (!node.isArray()) throw badRequest("outboundDetails must be an array"); + long total = 0; + for (JsonNode item : node) { + if (!item.isObject() || item.path("quantity").asLong(-1) < 0) throw badRequest("outboundDetails quantity must be non-negative"); + total += item.path("quantity").asLong(); + } + if (total > value(inbound)) throw badRequest("outbound quantity cannot exceed inbound quantity"); + try { return objectMapper.writeValueAsString(node); } catch (JsonProcessingException e) { throw badRequest("Invalid outboundDetails"); } + } + private String changeDetail(Map before, Map after) { Map changes = new LinkedHashMap<>(); before.forEach((field, oldValue) -> { diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 45c775f1e..2bd800dca 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1556,7 +1556,7 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order ( purchase_quantity BIGINT NOT NULL COMMENT '采购数量', budget_unit_price DECIMAL(18,2) NULL COMMENT '预算单价', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', receiving_address VARCHAR(1000) NULL COMMENT '收货地址', contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', contact_phone VARCHAR(64) NOT NULL COMMENT '联系人电话', - purchase_warehouse_id VARCHAR(128) NULL COMMENT '采购入仓仓库ID', purchase_warehouse_name VARCHAR(255) NULL COMMENT '采购入仓仓库名称', + purchase_warehouse_id VARCHAR(128) NULL COMMENT '采购入仓仓库ID', purchase_warehouse_name VARCHAR(255) NULL COMMENT '采购入仓仓库名称', outbound_details JSON NULL COMMENT '出库明细', inbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '采购单据累计入库数量', domestic_carrier VARCHAR(128) NULL COMMENT '境内承运商', domestic_tracking_no VARCHAR(128) NULL COMMENT '境内物流单号', overseas_carrier VARCHAR(128) NULL COMMENT '海外承运商', overseas_tracking_no VARCHAR(128) NULL COMMENT '海外物流单号', diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 884f6ac5e..48cf108a2 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -1093,6 +1093,19 @@ components: overseasTrackingNo: { $ref: '#/components/schemas/NullableTextPatch' } estimatedArrivalAt: { type: string, format: date-time, nullable: true, description: 缺失不修改;null 或空串清空 } remark: { $ref: '#/components/schemas/NullableTextPatch' } + outboundDetails: + type: array + description: 出库明细,可一次提交多条;总出库量不得超过入库量 + items: + type: object + required: [quantity] + properties: + id: { type: string } + quantity: { type: integer, format: int64, minimum: 0 } + carrier: { type: string, nullable: true, maxLength: 128 } + trackingNo: { type: string, nullable: true, maxLength: 128 } + shippedAt: { type: string, format: date-time, nullable: true } + remark: { type: string, nullable: true, maxLength: 500 } CustomerOptionResponse: type: object properties: From 8a7c92bb0fc82c575c040c87bc07e4aba8510c19 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Fri, 28 Aug 2026 15:16:36 +0800 Subject: [PATCH 18/24] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MerchantDeliveryController.java | 47 +++++++++++++++++++ ...OverseasProcurementSchemaContractTest.java | 3 ++ scripts/db/incremental.sql | 4 +- 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java new file mode 100644 index 000000000..1738849ce --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java @@ -0,0 +1,47 @@ +package com.baiyue.lastleg.delivery.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.delivery.dto.DeliveryRequests; +import com.baiyue.lastleg.delivery.dto.DeliveryResponses; +import com.baiyue.lastleg.delivery.service.DeliveryTransferService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/merchant/v1/last-leg/delivery-orders") +public class MerchantDeliveryController { + @Resource private DeliveryTransferService service; + + @GetMapping + public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, + @Valid DeliveryRequests.Query query) { + return ApiResponse.success(service.deliveryPage(customer(user), query)); + } + + @PostMapping + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String dispatchNo, + @Valid @RequestBody DeliveryRequests.Transfer request) { + return ApiResponse.success(service.transfer(customer(user), dispatchNo, operator(user), request)); + } + + @GetMapping("/{deliveryNo}") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String deliveryNo, + @Valid DeliveryRequests.PalletQuery query) { + return ApiResponse.success(service.deliveryDetail(customer(user), deliveryNo, query)); + } + + private String customer(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required"); + return String.valueOf(user.tenantId()); + } + private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java index b6c1159a0..07e85bdfd 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java @@ -20,6 +20,7 @@ class OverseasProcurementSchemaContractTest { assertTrue(sql.contains("operation_type VARCHAR(32) NOT NULL")); assertTrue(sql.contains("review_idempotency_key VARCHAR(255) NULL")); assertTrue(sql.contains("review_request_hash CHAR(64) NULL")); + assertTrue(sql.contains("outbound_details JSON NULL COMMENT '出库明细'")); assertTrue(sql.contains("idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键'")); assertTrue(sql.contains("change_detail JSON NULL")); org.junit.jupiter.api.Assertions.assertFalse(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_outbound")); @@ -39,6 +40,8 @@ class OverseasProcurementSchemaContractTest { "ALTER TABLE ll_overseas_procurement_order ADD COLUMN review_idempotency_key VARCHAR(255) NULL")); assertTrue(sql.contains( "ALTER TABLE ll_overseas_procurement_order ADD COLUMN review_request_hash CHAR(64) NULL")); + assertTrue(sql.contains( + "ALTER TABLE ll_overseas_procurement_order ADD COLUMN outbound_details JSON NULL")); assertTrue(sql.contains( "table_name='ll_overseas_procurement_status_history' AND column_name='idempotency_key'")); } diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index 7430afad5..479ca44b8 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -1386,7 +1386,7 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order ( product_specification VARCHAR(500) NULL COMMENT '产品规格', product_url VARCHAR(2000) NULL COMMENT '产品链接', purchase_quantity BIGINT NOT NULL COMMENT '采购数量', budget_unit_price DECIMAL(18,2) NULL COMMENT '预算单价', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', receiving_address VARCHAR(1000) NULL COMMENT '收货地址', contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', contact_phone VARCHAR(64) NOT NULL COMMENT '联系人电话', purchase_warehouse_id VARCHAR(128) NULL COMMENT '采购入仓仓库ID', - purchase_warehouse_name VARCHAR(255) NULL COMMENT '采购入仓仓库名称', inbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计入库数量', outbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计出库数量', + purchase_warehouse_name VARCHAR(255) NULL COMMENT '采购入仓仓库名称', inbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计入库数量', outbound_details JSON NULL COMMENT '出库明细', outbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计出库数量', domestic_carrier VARCHAR(128) NULL COMMENT '境内承运商', domestic_tracking_no VARCHAR(128) NULL COMMENT '境内物流单号', overseas_carrier VARCHAR(128) NULL COMMENT '海外承运商', overseas_tracking_no VARCHAR(128) NULL COMMENT '海外物流单号', estimated_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', remark VARCHAR(1000) NULL COMMENT '备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', @@ -3195,6 +3195,8 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='inbound_quantity' AND COALESCE(column_comment,'') <> '累计入库数量'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN inbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT ''累计入库数量''', 'SELECT 1')); PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='outbound_details'), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN outbound_details JSON NULL COMMENT ''出库明细'' AFTER inbound_quantity')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='outbound_quantity' AND COALESCE(column_comment,'') <> '累计出库数量'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN outbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT ''累计出库数量''', 'SELECT 1')); PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='domestic_carrier' AND COALESCE(column_comment,'') <> '境内承运商'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN domestic_carrier VARCHAR(128) NULL COMMENT ''境内承运商''', 'SELECT 1')); From 5ddaa4d51f69c7f75c1ff3e591a305972192224c Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Fri, 28 Aug 2026 15:25:50 +0800 Subject: [PATCH 19/24] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 6 +++++- .../main/resources/db/lastleg-permissions.sql | 8 ++++++-- ...verseasPhase4PermissionSqlContractTest.java | 18 ++++++++++++++++-- .../controller/DeliveryTransferController.java | 4 ++++ .../dto/OverseasProcurementResponses.java | 2 +- .../service/OverseasProcurementService.java | 8 ++++++-- .../lastleg/security/OverseasPermissions.java | 1 + .../openapi/overseas-business-v1.yaml | 2 +- 8 files changed, 40 insertions(+), 9 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index d8e2f65e0..71f896806 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -16352,6 +16352,7 @@ VALUES (5107063,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:delete','删除海外库位',NULL,'DELETE',763,'ACTIVE',1,0), (5107090,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:outbound:list','海外出库单','/overseas-warehouse/outbounds','GET',765,'ACTIVE',1,0), (5107091,0,'PLATFORM','BUTTON',5107090,'platform:overseas-warehouse:outbound:create','创建海外出库单',NULL,'POST',766,'ACTIVE',1,0), + (5107092,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:delivery:list','海外派送单','/overseas-warehouse/delivery-orders','GET',767,'ACTIVE',1,0), (5107100,0,'PLATFORM','MENU',0,'platform:overseas-business:purchase:list','海外采购订单','/overseas-business/purchases','GET',800,'ACTIVE',1,0), (5107101,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:edit','编辑海外采购订单',NULL,'PATCH',801,'ACTIVE',1,0), (5107102,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:review','审核海外采购订单',NULL,'POST',802,'ACTIVE',1,0), @@ -16530,7 +16531,10 @@ INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permissio SELECT 0, role.id, permission.id, NOW(3) FROM baiyue_iam.iam_role role JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' - AND permission.permission_code='platform:overseas-warehouse:outbound:list' + AND permission.permission_code IN ( + 'platform:overseas-warehouse:outbound:list', + 'platform:overseas-warehouse:delivery:list' + ) WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND UPPER(role.role_code)='PLATFORM_CUSTOM_ADMIN' AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql index bea660488..37f3c274f 100644 --- a/iam/src/main/resources/db/lastleg-permissions.sql +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -41,6 +41,7 @@ VALUES (5107063,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:delete','删除海外库位',NULL,'DELETE',763,'ACTIVE',1,0), (5107090,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:outbound:list','海外出库单','/overseas-warehouse/outbounds','GET',765,'ACTIVE',1,0), (5107091,0,'PLATFORM','BUTTON',5107090,'platform:overseas-warehouse:outbound:create','创建海外出库单',NULL,'POST',766,'ACTIVE',1,0), + (5107092,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:delivery:list','海外派送单','/overseas-warehouse/delivery-orders','GET',767,'ACTIVE',1,0), (5107100,0,'PLATFORM','MENU',0,'platform:overseas-business:purchase:list','海外采购订单','/overseas-business/purchases','GET',800,'ACTIVE',1,0), (5107101,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:edit','编辑海外采购订单',NULL,'PATCH',801,'ACTIVE',1,0), (5107102,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:review','审核海外采购订单',NULL,'POST',802,'ACTIVE',1,0), @@ -215,12 +216,15 @@ WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND UPPER(role.role_code) IN ('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN') AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; --- PLATFORM_CUSTOM_ADMIN needs access to the overseas outbound list shown in the admin navigation. +-- PLATFORM_CUSTOM_ADMIN needs access to the overseas outbound and delivery lists shown in the admin navigation. INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) SELECT 0, role.id, permission.id, NOW(3) FROM iam_role role JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' - AND permission.permission_code IN ('platform:overseas-warehouse:outbound:list') + AND permission.permission_code IN ( + 'platform:overseas-warehouse:outbound:list', + 'platform:overseas-warehouse:delivery:list' + ) WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND UPPER(role.role_code)='PLATFORM_CUSTOM_ADMIN' AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; diff --git a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java index 2182c43f1..3ab88262a 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java @@ -41,10 +41,12 @@ class OverseasPhase4PermissionSqlContractTest { assertThat(sql).contains(REQUIRED.toArray(String[]::new)); assertThat(sql).contains("'lastleg:pda:operate'"); assertPdaPickerGrant(sql); - assertThat(catalog).hasSize(53).noneMatch(code -> code.endsWith(":view")); + assertThat(catalog).hasSize(54).noneMatch(code -> code.endsWith(":view")); assertThat(sql).contains("permission_code LIKE 'platform:lastleg:%'", "status='INACTIVE'", "deleted=1"); - assertThat(sql).doesNotContain(":carton:", ":product:", ":outbound:", ":delivery:"); + assertThat(sql).doesNotContain(":carton:", ":product:"); + assertThat(catalog).contains("platform:overseas-warehouse:delivery:list"); assertCustomAdminGrant(sql); + assertCustomAdminNavigationGrant(sql); } @Test @@ -58,6 +60,7 @@ class OverseasPhase4PermissionSqlContractTest { assertThat(sql).contains("baiyue_iam.iam_permission", "baiyue_iam.iam_role_permission", "'lastleg:pda:operate'"); assertPdaPickerGrant(sql); assertCustomAdminGrant(sql); + assertCustomAdminNavigationGrant(sql); } @Test @@ -138,6 +141,17 @@ class OverseasPhase4PermissionSqlContractTest { assertThat(sql.substring(grantStart, grantEnd)).contains("lastleg:pda:operate"); } + private static void assertCustomAdminNavigationGrant(String sql) { + int permissionIndex = sql.indexOf("'platform:overseas-warehouse:delivery:list'", sql.indexOf("PLATFORM_CUSTOM_ADMIN needs access")); + assertThat(permissionIndex).isGreaterThanOrEqualTo(0); + int grantStart = sql.lastIndexOf("INSERT IGNORE INTO", permissionIndex); + int grantEnd = sql.indexOf(';', permissionIndex); + assertThat(sql.substring(grantStart, grantEnd)).contains( + "UPPER(role.role_code)='PLATFORM_CUSTOM_ADMIN'", + "platform:overseas-warehouse:outbound:list", + "platform:overseas-warehouse:delivery:list"); + } + private static String matrixBlock(String sql) { int start = sql.indexOf("INSERT INTO tmp_phase4_warehouse_role_permission"); int end = sql.indexOf("DELETE grant_row FROM", start); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java index 24d02b3d3..5557f75f8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java @@ -6,6 +6,7 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.delivery.dto.DeliveryRequests; import com.baiyue.lastleg.delivery.dto.DeliveryResponses; import com.baiyue.lastleg.delivery.service.DeliveryTransferService; +import com.baiyue.lastleg.security.OverseasPermissions; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; @@ -13,6 +14,7 @@ import jakarta.validation.Valid; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; @@ -65,6 +67,7 @@ public class DeliveryTransferController { } @GetMapping("/delivery-orders") + @PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_LIST + "')") public ApiResponse> deliveryPage( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid DeliveryRequests.Query query) { @@ -72,6 +75,7 @@ public class DeliveryTransferController { } @GetMapping("/delivery-orders/{deliveryNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_LIST + "')") public ApiResponse deliveryDetail( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @PathVariable String deliveryNo, @Valid DeliveryRequests.PalletQuery query) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java index 638245829..06bd0242f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java @@ -14,7 +14,7 @@ public final class OverseasProcurementResponses { String purchaseCategory, String productName, String productSpecification, long purchaseQuantity, BigDecimal budgetUnitPrice, String currency, String purchaseWarehouseId, String purchaseWarehouseName, - long inboundQuantity, long inventoryQuantity, + long inboundQuantity, long outboundQuantity, long inventoryQuantity, String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt) {} public record CustomerOption(String customerId, String customerName) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java index 9ef5d938c..9d3becfb7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java @@ -232,11 +232,15 @@ public class OverseasProcurementService { private OverseasProcurementResponses.Summary summary(OverseasProcurementOrder order) { long inbound = value(order.getInboundQuantity()); long outbound = outboundDetails(order).stream().mapToLong(OverseasProcurementResponses.OutboundDetail::quantity).sum(); + String displayStatus = order.getStatus(); + if (APPROVED.equals(order.getStatus()) && inbound > 0) { + displayStatus = outbound == 0 ? "PENDING_OUTBOUND" : outbound < inbound ? "PARTIALLY_OUTBOUND" : "OUTBOUNDED"; + } return new OverseasProcurementResponses.Summary(order.getId(), order.getOrderNo(), order.getCustomerId(), order.getCustomerName(), order.getPurchaseCategory(), order.getProductName(), order.getProductSpecification(), value(order.getPurchaseQuantity()), order.getBudgetUnitPrice(), - order.getCurrency(), order.getPurchaseWarehouseId(), order.getPurchaseWarehouseName(), inbound, Math.max(0, inbound - outbound), - order.getStatus(), value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt()); + order.getCurrency(), order.getPurchaseWarehouseId(), order.getPurchaseWarehouseName(), inbound, outbound, Math.max(0, inbound - outbound), + displayStatus, value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt()); } private void history(OverseasProcurementOrder order, String operationType, String from, String to, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java index 5ac51600c..4bd4f198f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java @@ -19,6 +19,7 @@ public final class OverseasPermissions { public static final String INVENTORY_EXPORT = "platform:overseas-warehouse:inventory:export"; public static final String INVENTORY_EDIT = "platform:overseas-warehouse:inventory:edit"; public static final String INVENTORY_ADJUST = "platform:overseas-warehouse:inventory:adjust"; + public static final String DELIVERY_LIST = "platform:overseas-warehouse:delivery:list"; public static final String PURCHASE_LIST = "platform:overseas-business:purchase:list"; public static final String PURCHASE_EDIT = "platform:overseas-business:purchase:edit"; public static final String PURCHASE_REVIEW = "platform:overseas-business:purchase:review"; diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 48cf108a2..e4e46e484 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -580,7 +580,7 @@ components: feeItem: { type: string, minLength: 1, maxLength: 128 } currency: { type: string, pattern: '^[A-Z]{3}$', example: USD } amount: { type: number, format: decimal, minimum: 0.01 } - status: { type: string, enum: [PENDING_REVIEW, APPROVED, REJECTED] } + status: { type: string, enum: [PENDING_REVIEW, APPROVED, REJECTED, PENDING_OUTBOUND, PARTIALLY_OUTBOUND, OUTBOUNDED] } supplierDescription: { type: string, maxLength: 500 } LastMileFee: type: object From a4429f95b7b4134fc9e970777d1bc00d75c0a562 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Fri, 28 Aug 2026 16:30:02 +0800 Subject: [PATCH 20/24] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 1 + ...erseasPhase4PermissionSqlContractTest.java | 2 +- lastleg-service/AGENTS.md | 2 +- .../controller/LastlegOrderController.java | 6 +++-- .../DeliveryTransferController.java | 6 ++--- .../MerchantDeliveryController.java | 5 ++-- .../service/DeliveryTransferService.java | 5 ++-- .../service/DispatchPdaWorkflowService.java | 14 +++++----- .../lastleg/dto/LastlegOrderSummary.java | 3 +++ .../controller/InboundPlanController.java | 10 +++++++ .../dto/UpdateInboundPlanRequest.java | 12 +++++++++ .../service/InboundPlanService.java | 14 ++++++++++ .../controller/InventoryQueryController.java | 10 ++++--- .../lastleg/mapper/LastlegOrderMapper.java | 16 ++++++++++++ .../lastleg/service/LastlegOrderService.java | 26 ++++++++++++++++--- .../openapi/overseas-business-v1.yaml | 2 +- ...rseasProcurementInventoryBoundaryTest.java | 5 +++- .../OverseasProcurementServiceTest.java | 23 ++++++++++++++++ .../service/LastlegOrderServiceTest.java | 20 ++++++++++++++ 19 files changed, 151 insertions(+), 31 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/UpdateInboundPlanRequest.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 71f896806..005c544bd 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -16527,6 +16527,7 @@ WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND UPPER(role.role_code) IN ('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN') AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; +-- PLATFORM_CUSTOM_ADMIN needs access to the overseas outbound and delivery lists shown in the admin navigation. INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permission_id, created_at) SELECT 0, role.id, permission.id, NOW(3) FROM baiyue_iam.iam_role role diff --git a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java index 3ab88262a..8a9e73ef3 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java @@ -41,7 +41,7 @@ class OverseasPhase4PermissionSqlContractTest { assertThat(sql).contains(REQUIRED.toArray(String[]::new)); assertThat(sql).contains("'lastleg:pda:operate'"); assertPdaPickerGrant(sql); - assertThat(catalog).hasSize(54).noneMatch(code -> code.endsWith(":view")); + assertThat(catalog).hasSize(56).noneMatch(code -> code.endsWith(":view")); assertThat(sql).contains("permission_code LIKE 'platform:lastleg:%'", "status='INACTIVE'", "deleted=1"); assertThat(sql).doesNotContain(":carton:", ":product:"); assertThat(catalog).contains("platform:overseas-warehouse:delivery:list"); diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index ee428cfe3..bec72d461 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -93,7 +93,7 @@ - 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。 - 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。 - 货物废弃只使用可核对 SKU 和箱唛的一件代发库存。创建申请只能将箱从 `AVAILABLE` 锁定为 `DISCARD_PENDING`,不得扣减 SKU 箱数;实际扣库存节点固定为具备 `lastleg:cargo-discard:approve` 权限的仓库/PDA 审核人确认废弃时,并且必须在审核事务内调用专用 discard adjustment。该 adjustment 必须将箱改为 `DISCARDED`、写逐箱业务流水及唯一幂等键,并校验 `扣减前箱数 - 废弃箱数 = 扣减后箱数` 且逐箱数等于申请总箱数;任一环节失败须整体回滚。拒绝申请只释放锁箱,不得扣库存。 -- 海外采购客户下单固定进入 `PENDING_REVIEW`;四期采购只维护采购订单、审核、入库数量、候选快照和操作流水,不提供采购出库端点、出库记录或出库数量。采购单据不得写入或冒充 `available_packages`;只有海外仓 PDA 完成实收后,`InboundStockReceiptService` 才能以 `PDA_STOCK_IN:{taskId}:{allocationId}` 幂等键调用库存 `stockIn` 转入可用库存。禁止通过通用平台接口绕过 PDA 入可用库存。 +- 海外采购客户下单固定进入 `PENDING_REVIEW`;采购编辑可维护入库数量和采购出库明细,出库量为明细数量汇总,审核通过且有入库量后按 `PENDING_OUTBOUND`、`PARTIALLY_OUTBOUND`、`OUTBOUNDED` 派生展示状态。采购出库只记录采购单据流转,不得写入或冒充 `available_packages`;只有海外仓 PDA 完成实收后,`InboundStockReceiptService` 才能以 `PDA_STOCK_IN:{taskId}:{allocationId}` 幂等键调用库存 `stockIn` 转入可用库存。禁止通过通用平台接口绕过 PDA 入可用库存。 - 派车状态只允许 PDA 拣货、完成和出库动作推进,普通派车编辑接口不得修改状态。PDA 写操作要求 `lastleg:pda:operate`;后台库存表单操作要求 `lastleg:inventory:manage`,盘点调整另要求 `lastleg:inventory:adjust`,转仓库存操作要求 `lastleg:warehouse-transfer:manage`。 - `ll_order.current_status` 只保存尾程主流程状态;换标、转仓、出库和派送等子单精确状态保存到各自单据并汇总到 `sub_order_status`,不得再用子单状态覆盖主流程状态。PDA 入仓及库存落账完成后,主流程状态固定推进为 `INBOUND_COMPLETED`。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java index c02100a12..d9762c336 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java @@ -32,6 +32,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; import org.springframework.web.server.ResponseStatusException; import java.nio.charset.StandardCharsets; @@ -121,15 +122,16 @@ public class LastlegOrderController { @GetMapping(value = "/api/platform/v1/last-leg/orders/export", produces = "text/csv") @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EXPORT + "')") @Operation(summary = "Export filtered tail-leg orders as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EXPORT) - public ResponseEntity export( + public ResponseEntity export( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid LastlegOrderRequests.Query query) { byte[] body = orderService.exportCsv(platformScope(user, customerId), query); + StreamingResponseBody stream = output -> { output.write(body); output.flush(); }; return ResponseEntity.ok() .contentType(new MediaType("text", "csv", StandardCharsets.UTF_8)) .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=lastleg-orders.csv") - .body(body); + .body(stream); } @PutMapping("/api/platform/v1/last-leg/orders/{id}/destination") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java index 5557f75f8..0e2382d7c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java @@ -13,8 +13,8 @@ import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.http.HttpStatus; -import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; @@ -78,8 +78,8 @@ public class DeliveryTransferController { @PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_LIST + "')") public ApiResponse deliveryDetail( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, - @PathVariable String deliveryNo, @Valid DeliveryRequests.PalletQuery query) { - return ApiResponse.success(service.deliveryDetail(scope(user, customerId), deliveryNo, query)); + @PathVariable String deliveryNo) { + return ApiResponse.success(service.deliveryDetail(scope(user, customerId), deliveryNo)); } private String scope(AuthenticatedUser user, String requestedCustomer) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java index 1738849ce..a2eea0102 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java @@ -33,9 +33,8 @@ public class MerchantDeliveryController { @GetMapping("/{deliveryNo}") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String deliveryNo, - @Valid DeliveryRequests.PalletQuery query) { - return ApiResponse.success(service.deliveryDetail(customer(user), deliveryNo, query)); + @PathVariable String deliveryNo) { + return ApiResponse.success(service.deliveryDetail(customer(user), deliveryNo)); } private String customer(AuthenticatedUser user) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index 6249a645f..35e419205 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -341,8 +341,7 @@ public class DeliveryTransferService { return page(page, page.getRecords().stream().map(this::deliveryRow).toList()); } - public DeliveryResponses.DeliveryDetail deliveryDetail(String customerId, String deliveryNo, - DeliveryRequests.PalletQuery query) { + public DeliveryResponses.DeliveryDetail deliveryDetail(String customerId, String deliveryNo) { DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo); if (delivery == null) throw notFound("Delivery order not found"); List items = dispatchItems.findByDispatchOrder(customerId, delivery.getDispatchOrderId()); @@ -398,7 +397,7 @@ public class DeliveryTransferService { } private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getCreatedAt()); } private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt()); } - private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); } + private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); } private DeliveryResponses.DeliveryFeeRow feeRow(DeliveryFee f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getFeeDirection(), f.getFeeType(), f.getCurrency(), f.getAmount(), f.getRemark()); } private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); } private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java index 96da4a278..19b5979d4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java @@ -119,6 +119,11 @@ public class DispatchPdaWorkflowService { inventory.pick(customerId, operatorId, new InventoryCommandRequests.Move(request.positionId(), request.packages(), order.getDispatchNo(), order.getDispatchNo(), null, keyBase + ":PICK", request.remark())); + // A successful PDA scan is the inventory deduction point. Departure only + // changes outbound/dispatch facts and must not deduct the same quantity again. + inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move(request.positionId(), + request.packages(), order.getDispatchNo(), order.getDispatchNo(), null, + keyBase + ":SHIP", request.remark())); DispatchPdaPick pick = new DispatchPdaPick(); pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); pick.setDispatchItemId(itemId); pick.setInventoryPositionId(request.positionId()); pick.setPackages(request.packages()); @@ -197,14 +202,7 @@ public class DispatchPdaWorkflowService { } List dispatchPicks = picks.listTask(customerId, task.getId()); if (dispatchPicks.isEmpty()) throw conflict("Dispatch has no PDA picks to ship"); - long shipped = 0; - for (DispatchPdaPick pick : dispatchPicks) { - String key = "DISPATCH:" + order.getDispatchNo() + ":PICK:" + pick.getId() + ":SHIP"; - inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move( - pick.getInventoryPositionId(), n(pick.getPackages()), - outbound.getOutboundNo(), outbound.getOutboundNo(), null, key, request.remark())); - shipped += n(pick.getPackages()); - } + long shipped = dispatchPicks.stream().mapToLong(p -> n(p.getPackages())).sum(); LocalDateTime now = LocalDateTime.now(); pickAttribution.depart(customerId, outbound.getOutboundNo(), order.getDispatchNo(), dispatchPicks, now); outbound.setStatus("OUTBOUNDED"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java index fb5b95346..d0a5ca5c7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java @@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.util.List; @Schema(description = "按入仓单编码粒度展示的尾程订单列表行") public record LastlegOrderSummary( @@ -40,6 +41,8 @@ public record LastlegOrderSummary( String relabelOrderNo, String relabelStatus, String palletMergeFlag, + String palletMergeBatchId, + List palletMergeOrderNos, String currentStatus, String subOrderStatus, String privateRemark, 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 3f8c3da74..a27e02867 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 @@ -11,6 +11,7 @@ import com.baiyue.lastleg.inboundplan.dto.InboundPlanListResponse; import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests; import com.baiyue.lastleg.inboundplan.dto.SupplementPalletRequest; import com.baiyue.lastleg.inboundplan.dto.UpdateFbaAppointmentRequest; +import com.baiyue.lastleg.inboundplan.dto.UpdateInboundPlanRequest; import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; import com.baiyue.lastleg.inboundplan.service.InboundPlanService; import com.baiyue.lastleg.security.OverseasPermissions; @@ -62,6 +63,15 @@ public class InboundPlanController { return ApiResponse.success(service.detailByPlanNo(scope(user, customerId), planNo)); } + @PutMapping("/{planNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')") + @Operation(summary = "编辑入仓计划") + public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo, + @Valid @RequestBody UpdateInboundPlanRequest request) { + return ApiResponse.success(service.update(scope(user, request.customerId()), planNo, request)); + } + @PostMapping("/{planNo}/pallets/supplement") @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')") /** 为计划目标仓新增一个可打印的补充托盘。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/UpdateInboundPlanRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/UpdateInboundPlanRequest.java new file mode 100644 index 000000000..77bf05034 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/UpdateInboundPlanRequest.java @@ -0,0 +1,12 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record UpdateInboundPlanRequest( + @NotBlank String customerId, + @NotNull Long overseasWarehouseId, + String warehouseCode, + String warehouseName, + @NotNull Integer version +) {} 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 3201c9d1f..390204228 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 @@ -7,6 +7,7 @@ import com.baiyue.lastleg.inboundplan.dto.InboundPlanListResponse; import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests; import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; import com.baiyue.lastleg.inboundplan.dto.SupplementPalletRequest; +import com.baiyue.lastleg.inboundplan.dto.UpdateInboundPlanRequest; import com.baiyue.lastleg.inboundplan.entity.InboundPlan; import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; @@ -219,6 +220,19 @@ public class InboundPlanService { return detail(customerId, plan); } + @Transactional + public InboundPlanResponse update(String customerId, String planNo, UpdateInboundPlanRequest request) { + InboundPlan plan = planMapper.selectOne(new LambdaQueryWrapper() + .eq(InboundPlan::getPlanNo, planNo).eq(customerId != null, InboundPlan::getCustomerId, customerId).last("FOR UPDATE")); + if (plan == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found"); + if (!java.util.Set.of("PENDING_ASSIGNMENT", "ASSIGNED").contains(plan.getStatus())) + throw new ResponseStatusException(HttpStatus.CONFLICT, "Inbound plan has started and cannot be edited"); + plan.setOverseasWarehouseId(request.overseasWarehouseId()); + plan.setUpdatedAt(LocalDateTime.now()); + planMapper.updateById(plan); + return detail(customerId, plan); + } + @Transactional public InboundPlanResponse.Pallet supplementPallet(String customerId, String planNo, SupplementPalletRequest request) { InboundPlan plan = planMapper.selectOne(new LambdaQueryWrapper() diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java index 87226951e..eeea51bf8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java @@ -24,6 +24,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; import org.springframework.web.server.ResponseStatusException; import java.nio.charset.StandardCharsets; @@ -80,7 +81,7 @@ public class InventoryQueryController { @GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv") @PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_EXPORT + "')") @Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT) - public ResponseEntity exportBalances( + public ResponseEntity exportBalances( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid InventoryQueryRequests.BalanceQuery query) { @@ -90,18 +91,19 @@ public class InventoryQueryController { @GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv") @PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_EXPORT + "')") @Operation(summary = "Export filtered inventory movements as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT) - public ResponseEntity exportLedgers( + public ResponseEntity exportLedgers( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid InventoryQueryRequests.LedgerQuery query) { return csv(service.exportLedgers(platformScope(user, customerId), query), "inventory-ledger.csv"); } - private static ResponseEntity csv(byte[] body, String filename) { + private static ResponseEntity csv(byte[] body, String filename) { + StreamingResponseBody stream = output -> { output.write(body); output.flush(); }; return ResponseEntity.ok() .contentType(new MediaType("text", "csv", StandardCharsets.UTF_8)) .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + filename) - .body(body); + .body(stream); } private String platformScope(AuthenticatedUser user, String requestedCustomerId) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java index b07c332a9..ddb631144 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java @@ -7,8 +7,24 @@ import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; +import java.util.List; + @Mapper public interface LastlegOrderMapper extends BaseMapper { + record PalletMergeRelationRow(Long orderId, String relatedOrderNo, String mergeGroupNo) {} + + @Select("") + List findPalletMergeRelations(@Param("orderIds") List orderIds); + @Select("SELECT * FROM ll_order WHERE customer_id=#{customerId} AND source_region=#{sourceRegion} " + "AND source_inbound_id=#{sourceInboundId} LIMIT 1") LastlegOrder findSource(@Param("customerId") String customerId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java index b8a887139..7a472e84d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -31,7 +31,9 @@ import org.springframework.web.server.ResponseStatusException; import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; @Service @@ -53,7 +55,8 @@ public class LastlegOrderService { public PageResult page(String customerId, LastlegOrderRequests.Query query) { Page page = orderMapper.selectPage( new Page<>(query.page(), query.size()), filter(customerId, query).orderByDesc(LastlegOrder::getCreatedAt)); - return new PageResult<>(page.getRecords().stream().map(this::summary).toList(), + Map> mergeRelations = palletMergeRelations(page.getRecords()); + return new PageResult<>(page.getRecords().stream().map(order -> summary(order, mergeRelations.get(order.getId()))).toList(), page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); } @@ -75,7 +78,7 @@ public class LastlegOrderService { .eq(LastlegOrderOperationLog::getCustomerId, order.getCustomerId()) .eq(LastlegOrderOperationLog::getOrderId, orderId) .orderByAsc(LastlegOrderOperationLog::getCreatedAt)); - return new LastlegOrderDetailResponse(summary(order), cargo, history.stream() + return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId())), cargo, history.stream() .map(value -> new LastlegOrderDetailResponse.History(value.getId(), value.getFromStatus(), value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList(), fees, operations.stream() .map(value -> new LastlegOrderDetailResponse.Operation(value.getId(), value.getOperationNode(), @@ -294,7 +297,20 @@ public class LastlegOrderService { } } - private LastlegOrderSummary summary(LastlegOrder o) { + private Map> palletMergeRelations(List orders) { + if (orders.isEmpty()) return Map.of(); + Map> result = new LinkedHashMap<>(); + List rows = orderMapper.findPalletMergeRelations( + orders.stream().map(LastlegOrder::getId).toList()); + if (rows == null) return Map.of(); + for (LastlegOrderMapper.PalletMergeRelationRow row : rows) { + result.computeIfAbsent(row.orderId(), ignored -> new ArrayList<>()).add(row); + } + return result; + } + + private LastlegOrderSummary summary(LastlegOrder o, List relations) { + List related = relations == null ? List.of() : relations; return new LastlegOrderSummary(o.getId(), o.getOrderNo(), o.getOrderType(), LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(), o.getInboundCode(), o.getSourceOrderNo(), o.getCustomerId(), o.getCustomerName(), o.getOverseasWarehouseId(), @@ -303,7 +319,9 @@ public class LastlegOrderService { o.getAbnormal(), o.getDetained(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(), o.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(), o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), o.getRelabelOrderNo(), o.getRelabelStatus(), - o.getPalletMergeFlag(), o.getCurrentStatus(), o.getSubOrderStatus(), o.getPrivateRemark(), + o.getPalletMergeFlag(), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::mergeGroupNo).distinct().findFirst().orElse(null), + related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::relatedOrderNo).filter(Objects::nonNull).distinct().toList(), + o.getCurrentStatus(), o.getSubOrderStatus(), o.getPrivateRemark(), o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt()); } diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index e4e46e484..48cf108a2 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -580,7 +580,7 @@ components: feeItem: { type: string, minLength: 1, maxLength: 128 } currency: { type: string, pattern: '^[A-Z]{3}$', example: USD } amount: { type: number, format: decimal, minimum: 0.01 } - status: { type: string, enum: [PENDING_REVIEW, APPROVED, REJECTED, PENDING_OUTBOUND, PARTIALLY_OUTBOUND, OUTBOUNDED] } + status: { type: string, enum: [PENDING_REVIEW, APPROVED, REJECTED] } supplierDescription: { type: string, maxLength: 500 } LastMileFee: type: object diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementInventoryBoundaryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementInventoryBoundaryTest.java index 7e5702813..6288bad5f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementInventoryBoundaryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementInventoryBoundaryTest.java @@ -23,6 +23,9 @@ class OverseasProcurementInventoryBoundaryTest { assertFalse(source.contains("availablePackages")); assertFalse(source.contains("addOutbound")); assertFalse(source.contains("OverseasProcurementOutbound")); - assertFalse(source.contains("outboundQuantity")); + String responses = Files.readString(Path.of( + "src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java"), + StandardCharsets.UTF_8); + assertTrue(responses.contains("outboundQuantity")); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java index 08ec9fc11..f96d0f514 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java @@ -64,9 +64,32 @@ class OverseasProcurementServiceTest { assertEquals(OverseasProcurementService.PENDING_REVIEW, detail.summary().status()); assertEquals(0, detail.summary().inboundQuantity()); + assertEquals(0, detail.summary().outboundQuantity()); assertEquals(0, detail.summary().inventoryQuantity()); } + @Test + void outboundQuantityAndDisplayStatusFollowOutboundDetails() { + OverseasProcurementOrder order = order(OverseasProcurementService.APPROVED, 90, 0); + when(orders.findNo("100", "HC1")).thenReturn(order); + when(histories.listOrder("100", 1L)).thenReturn(List.of()); + + order.setOutboundDetails("[]"); + assertEquals("PENDING_OUTBOUND", service.detail("100", "HC1").summary().status()); + + order.setOutboundDetails("[{\"id\":\"1\",\"quantity\":1}]"); + var partial = service.detail("100", "HC1").summary(); + assertEquals(1, partial.outboundQuantity()); + assertEquals(89, partial.inventoryQuantity()); + assertEquals("PARTIALLY_OUTBOUND", partial.status()); + + order.setOutboundDetails("[{\"id\":\"1\",\"quantity\":90}]"); + var completed = service.detail("100", "HC1").summary(); + assertEquals(90, completed.outboundQuantity()); + assertEquals(0, completed.inventoryQuantity()); + assertEquals("OUTBOUNDED", completed.status()); + } + @Test void pendingOrderCannotReceiveInventory() { OverseasProcurementOrder order = order(OverseasProcurementService.PENDING_REVIEW, 0, 0); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java index 6284fcb56..bb7ac8c31 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -193,6 +193,26 @@ class LastlegOrderServiceTest { assertEquals(25, page.getValue().getSize()); } + @Test + void pageReturnsConfirmedPalletMergeRelatedOrderNumbers() { + LastlegOrder order = order(11L, "100", "INBOUND_COMPLETED", "IB-1"); + order.setPalletMergeFlag("MERGED"); + var resultPage = new com.baomidou.mybatisplus.extension.plugins.pagination.Page(1, 10); + resultPage.setRecords(List.of(order)); + resultPage.setTotal(1); + when(orders.selectPage(any(), any())).thenReturn(resultPage); + when(orders.findPalletMergeRelations(List.of(11L))).thenReturn(List.of( + new LastlegOrderMapper.PalletMergeRelationRow(11L, "LMO-RELATED-2", "PMG-1"), + new LastlegOrderMapper.PalletMergeRelationRow(11L, "LMO-RELATED-3", "PMG-1"))); + var query = new LastlegOrderRequests.Query(1, 10, null, null, null, null, + null, null, null, null, null, null, null, null, null); + + var result = service.page(null, query); + + assertEquals("PMG-1", result.records().get(0).palletMergeBatchId()); + assertEquals(List.of("LMO-RELATED-2", "LMO-RELATED-3"), result.records().get(0).palletMergeOrderNos()); + } + @Test void holdingBeforeInboundPersistsTheOrderMarkerAndOperationRemark() { LastlegOrder order = order(11L, "100", "PENDING_INBOUND", null); From a4189a00746169aa61b5e130f625be307856e3b1 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Mon, 31 Aug 2026 14:39:40 +0800 Subject: [PATCH 21/24] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/海外区域统一增量-MySQL8.sql | 16 ++++ .../controller/LastlegOrderController.java | 17 +++- .../pda/dto/DispatchPdaResponses.java | 3 +- .../service/DispatchPdaWorkflowService.java | 9 ++- .../lastleg/dto/LastlegOrderRequests.java | 14 +++- .../lastleg/dto/LastlegOrderSummary.java | 3 +- .../baiyue/lastleg/entity/LastlegOrder.java | 1 + .../dto/InboundPlanListResponse.java | 5 +- .../inboundplan/dto/InboundPlanResponse.java | 13 +++- .../inboundplan/entity/InboundPlan.java | 3 + .../mapper/InboundPlanOrderMapper.java | 9 +-- .../mapper/InboundPlanQueryMapper.java | 5 +- .../service/InboundPlanService.java | 18 ++++- .../controller/InventoryQueryController.java | 9 ++- .../query/service/InventoryQueryService.java | 69 ++++++++++++---- .../lastleg/mapper/LastlegOrderMapper.java | 22 +++++- .../lastleg/service/LastlegOrderService.java | 78 ++++++++++++++++--- .../src/main/resources/db/lastleg.sql | 5 ++ .../LastlegOrderControllerTest.java | 28 +++++++ .../DispatchPdaWorkflowServiceTest.java | 8 ++ .../InboundPlanOrderMapperContractTest.java | 21 +++++ .../InboundPlanQueryMapperContractTest.java | 22 ++++++ .../service/InboundPlanServiceTest.java | 16 ++-- .../service/LastlegOrderServiceTest.java | 8 +- scripts/db/incremental.sql | 7 ++ 25 files changed, 345 insertions(+), 64 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapperContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapperContractTest.java diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index d55f1a6dd..22681b015 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -1055,6 +1055,22 @@ CREATE TABLE IF NOT EXISTS ll_order_operation_log ( ) ENGINE=InnoDB COMMENT='尾程订单操作记录'; USE baiyue_lastleg; +SET @ddl = (SELECT IF(EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='version' +), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''入库计划乐观锁版本'' AFTER updated_at')); +PREPARE ll_inbound_plan_version_ddl FROM @ddl; +EXECUTE ll_inbound_plan_version_ddl; +DEALLOCATE PREPARE ll_inbound_plan_version_ddl; + +SET @ddl = (SELECT IF(EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='pallet_merge_batch_id' +), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN pallet_merge_batch_id VARCHAR(64) NULL COMMENT ''平台预标记合托分组号'' AFTER pallet_merge_flag')); +PREPARE ll_order_merge_batch_ddl FROM @ddl; +EXECUTE ll_order_merge_batch_ddl; +DEALLOCATE PREPARE ll_order_merge_batch_ddl; + CREATE TABLE IF NOT EXISTS ll_security_event_inbox ( event_id VARCHAR(64) NOT NULL COMMENT '跨区安全事件ID', event_type VARCHAR(96) NOT NULL COMMENT 'IAM安全事件类型', payload_hash CHAR(64) NOT NULL COMMENT '规范化载荷SHA-256哈希', payload_json JSON NOT NULL COMMENT '冻结事件载荷JSON', diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java index d9762c336..201e61a28 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java @@ -32,7 +32,6 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; import org.springframework.web.server.ResponseStatusException; import java.nio.charset.StandardCharsets; @@ -119,19 +118,29 @@ public class LastlegOrderController { return ApiResponse.success(null); } + @PutMapping("/api/platform/v1/last-leg/orders/pallet-merge-required/batch") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") + @Operation(summary = "Mark a selected tail-leg order group for PDA pallet merge") + public ApiResponse setPalletMergeBatch( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @Valid @RequestBody LastlegOrderRequests.SetPalletMergeBatch request) { + orderService.setPalletMergeBatch(platformScope(user, customerId), request, operatorName(user)); + return ApiResponse.success(null); + } + @GetMapping(value = "/api/platform/v1/last-leg/orders/export", produces = "text/csv") @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EXPORT + "')") @Operation(summary = "Export filtered tail-leg orders as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EXPORT) - public ResponseEntity export( + public ResponseEntity export( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid LastlegOrderRequests.Query query) { byte[] body = orderService.exportCsv(platformScope(user, customerId), query); - StreamingResponseBody stream = output -> { output.write(body); output.flush(); }; return ResponseEntity.ok() .contentType(new MediaType("text", "csv", StandardCharsets.UTF_8)) .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=lastleg-orders.csv") - .body(stream); + .body(body); } @PutMapping("/api/platform/v1/last-leg/orders/{id}/destination") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java index 638d51be2..e259a89b8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java @@ -7,7 +7,8 @@ public final class DispatchPdaResponses { public record Task(Long id, String taskNo, String dispatchNo, String vehiclePlate, String warehouseId, String status, List items) {} public record Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode, - String destinationWarehouseName, String fbaWarehouseCode, int scheduledPackages, + String destinationWarehouseName, String fbaWarehouseCode, String warehouseRouteLabel, + int scheduledPackages, long pickedPackages, List positions) {} public record Position(Long id, Long balanceId, long availablePackages, long allocatedPackages, long pickedPackages) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java index 19b5979d4..537ca0f97 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java @@ -225,7 +225,8 @@ public class DispatchPdaWorkflowService { List palletPositions = item.getWarehousePalletId() == null ? List.of() : positions.listPallet(customerId, item.getWarehousePalletId()); values.add(new DispatchPdaResponses.Item(item.getId(), item.getPalletNo(), item.getDestinationWarehouseCode(), - item.getDestinationWarehouseName(), item.getFbaWarehouseCode(), item.getPackageCount(), + item.getDestinationWarehouseName(), item.getFbaWarehouseCode(), + warehouseRouteLabel(item.getFbaWarehouseCode(), item.getDestinationWarehouseCode()), item.getPackageCount(), picks.totalItem(customerId, task.getId(), item.getId()), palletPositions.stream().map(p -> new DispatchPdaResponses.Position(p.getId(), p.getBalanceId(), n(p.getAvailablePackages()), n(p.getAllocatedPackages()), n(p.getPickedPackages()))).toList())); @@ -268,6 +269,12 @@ public class DispatchPdaWorkflowService { private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw new IllegalStateException(e); } } private static long n(Long value) { return value == null ? 0 : value; } private static long n(Integer value) { return value == null ? 0 : value; } + static String warehouseRouteLabel(String originalFbaWarehouseCode, String destinationWarehouseCode) { + String original = trim(originalFbaWarehouseCode); + String destination = trim(destinationWarehouseCode); + return original != null && destination != null && !original.equalsIgnoreCase(destination) + ? original + " -> " + destination : null; + } private static String trim(String value) { return value == null || value.trim().isEmpty() ? null : value.trim(); } private static String required(String value, String message) { String result = trim(value); if (result == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); return result; } private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java index 122ae8520..1b3c2da56 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java @@ -18,8 +18,8 @@ public final class LastlegOrderRequests { } public record Query( - @Min(1) long page, - @Min(1) @Max(200) long size, + @Min(1) Long page, + @Min(1) @Max(200) Long size, String orderNo, String orderType, String inboundCode, @@ -35,8 +35,8 @@ public final class LastlegOrderRequests { LocalDateTime createdTo ) { public Query { - if (page == 0) page = 1; - if (size == 0) size = 20; + if (page == null || page == 0) page = 1L; + if (size == null || size == 0) size = 20L; } } @@ -79,4 +79,10 @@ public final class LastlegOrderRequests { @NotNull Boolean palletMergeRequired ) { } + + public record SetPalletMergeBatch( + @NotEmpty @Size(max = 200) List orderIds, + @NotNull Boolean palletMergeRequired + ) { + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java index d0a5ca5c7..6384a8b69 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java @@ -42,7 +42,8 @@ public record LastlegOrderSummary( String relabelStatus, String palletMergeFlag, String palletMergeBatchId, - List palletMergeOrderNos, + @Schema(description = "同一合托组内其他来源入仓单的入仓码") + List palletMergeInboundNos, String currentStatus, String subOrderStatus, String privateRemark, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java index 89234d307..4f48bb76c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java @@ -52,6 +52,7 @@ public class LastlegOrder { private String relabelOrderNo; private String relabelStatus; private String palletMergeFlag; + private String palletMergeBatchId; private String currentStatus; private String subOrderStatus; private String privateRemark; 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 df4947455..d59689a19 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 @@ -26,7 +26,8 @@ public record InboundPlanListResponse( LocalDateTime appointmentAt, LocalDateTime expectedLoadingAt, String dispatchStatus, - LocalDateTime createdAt + LocalDateTime createdAt, + Integer version ) { public InboundPlanListResponse(Long lineId, Long planId, String planNo, String customerId, Long orderId, String inboundCode, String orderType, @@ -39,6 +40,6 @@ public record InboundPlanListResponse( this(lineId, planId, planNo, customerId, orderId, inboundCode, orderType, warehouseId, warehouseCode, warehouseName, fbaWarehouseCode, customerName, packageCount, 0, volumeCbm, palletCount, status, appointmentNo, appointmentStatus, appointmentAt, - expectedLoadingAt, dispatchStatus, createdAt); + expectedLoadingAt, dispatchStatus, createdAt, 0); } } 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 ffafec563..0fd8cd3e6 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 @@ -18,15 +18,24 @@ public record InboundPlanResponse( int pickedPackageCount, List lines, List pallets, - List allocations + List allocations, + int version ) { 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, pallets, allocations, 0); + } + + 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, int version) { 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); + allocations.stream().mapToInt(Allocation::pickedPackageCount).sum(), lines, pallets, allocations, version); } public record Line(Long id, Long orderId, String inboundCode, String fbaWarehouseCode, String destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, 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 79ffac343..51c30b470 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 @@ -3,6 +3,7 @@ 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 com.baomidou.mybatisplus.annotation.Version; import lombok.Data; import java.math.BigDecimal; @@ -24,4 +25,6 @@ public class InboundPlan { private BigDecimal totalVolumeCbm; private LocalDateTime createdAt; private LocalDateTime updatedAt; + @Version + private Integer version; } 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 44952b38c..88cdade9b 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 @@ -23,12 +23,9 @@ public interface InboundPlanOrderMapper { "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," + "COALESCE(NULLIF(o.transfer_warehouse_name,''),MIN(NULLIF(d.destination_warehouse_name,''))) AS destinationWarehouseName," + - "COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#')) AS destinationCount," + - "SUM(CASE WHEN d.id IS NOT NULL " + - "AND COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) IS NOT NULL " + - "AND COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#') " + - "<> COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) THEN 1 ELSE 0 END) " + - "AS destinationMismatchCount " + + "CASE WHEN COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) IS NOT NULL THEN 1 " + + "ELSE COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#')) END AS destinationCount," + + "0 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.pallet_count,o.fba_warehouse_code," + 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 97a4476f7..aea91cc5b 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 @@ -45,8 +45,9 @@ public interface InboundPlanQueryMapper { "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," + - "CASE WHEN EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE di.customer_id=#{customerId} AND di.order_id=l.order_id)" + - " THEN 'SCHEDULED' ELSE 'UNSCHEDULED' END dispatchStatus,p.created_at createdAt" + + "CASE WHEN EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE di.order_id=l.order_id" + + " AND di.customer_id=#{customerId})" + + " THEN 'SCHEDULED' ELSE 'UNSCHEDULED' END dispatchStatus,p.created_at createdAt,p.version version" + FROM_AND_FILTER + " ORDER BY p.created_at DESC,l.id DESC LIMIT #{limit} OFFSET #{offset}") List rows(@Param("customerId") String customerId, @Param("q") InboundPlanRequests.Query query, 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 390204228..3eae2cef2 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 @@ -227,9 +227,22 @@ public class InboundPlanService { if (plan == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found"); if (!java.util.Set.of("PENDING_ASSIGNMENT", "ASSIGNED").contains(plan.getStatus())) throw new ResponseStatusException(HttpStatus.CONFLICT, "Inbound plan has started and cannot be edited"); + int currentVersion = plan.getVersion() == null ? 0 : plan.getVersion(); + if (request.version() != currentVersion) + throw new ResponseStatusException(HttpStatus.CONFLICT, "Inbound plan version is stale; reload before editing"); plan.setOverseasWarehouseId(request.overseasWarehouseId()); plan.setUpdatedAt(LocalDateTime.now()); planMapper.updateById(plan); + // Keep the denormalized destination snapshot used by list queries in sync with the plan. + List lines = lineMapper.selectList(new LambdaQueryWrapper() + .eq(InboundPlanLine::getPlanId, plan.getId()) + .eq(InboundPlanLine::getCustomerId, plan.getCustomerId())); + for (InboundPlanLine line : lines) { + line.setDestinationWarehouseId(String.valueOf(request.overseasWarehouseId())); + if (trimToNull(request.warehouseCode()) != null) line.setDestinationWarehouseCode(request.warehouseCode().trim()); + if (trimToNull(request.warehouseName()) != null) line.setDestinationWarehouseName(request.warehouseName().trim()); + lineMapper.updateById(line); + } return detail(customerId, plan); } @@ -342,7 +355,7 @@ public class InboundPlanService { throw new ResponseStatusException(HttpStatus.CONFLICT, "Inbound code " + order.inboundCode() + " is not ready for an inbound plan"); } - if (order.destinationCount() != 1 || order.destinationMismatchCount() != 0 || warehouseKey(order) == null) { + if (order.destinationCount() != 1 || warehouseKey(order) == null) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Inbound code " + order.inboundCode() + " must have one destination warehouse consistent with all cargo details"); @@ -388,7 +401,8 @@ public class InboundPlanService { pallet.getActualPackageCount())).toList(), allocations.stream().map(item -> new InboundPlanResponse.Allocation(item.getLineId(), item.getPalletId(), item.getAllocatedVolumeCbm(), item.getPlannedPackageCount(), item.getCountedPackageCount(), - item.getPickedPackageCount(), item.getActualVolumeCbm(), item.getStatus(), item.getVersion())).toList()); + item.getPickedPackageCount(), item.getActualVolumeCbm(), item.getStatus(), item.getVersion())).toList(), + plan.getVersion() == null ? 0 : plan.getVersion()); } private BigDecimal volume(SourceOrderSnapshot order) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java index eeea51bf8..efb7abbbe 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java @@ -85,7 +85,8 @@ public class InventoryQueryController { @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid InventoryQueryRequests.BalanceQuery query) { - return csv(service.exportBalances(platformScope(user, customerId), query), "inventory-balances.csv"); + String scope = platformScope(user, customerId); + return csv(output -> service.writeBalancesCsv(scope, query, output), "inventory-balances.csv"); } @GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv") @@ -95,11 +96,11 @@ public class InventoryQueryController { @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid InventoryQueryRequests.LedgerQuery query) { - return csv(service.exportLedgers(platformScope(user, customerId), query), "inventory-ledger.csv"); + String scope = platformScope(user, customerId); + return csv(output -> service.writeLedgersCsv(scope, query, output), "inventory-ledger.csv"); } - private static ResponseEntity csv(byte[] body, String filename) { - StreamingResponseBody stream = output -> { output.write(body); output.flush(); }; + private static ResponseEntity csv(StreamingResponseBody stream, String filename) { return ResponseEntity.ok() .contentType(new MediaType("text", "csv", StandardCharsets.UTF_8)) .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + filename) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java index eb6bcab9a..c1252cc6e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java @@ -17,13 +17,14 @@ import org.springframework.stereotype.Service; import org.springframework.web.server.ResponseStatusException; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; import java.util.List; import java.util.Set; @Service public class InventoryQueryService { - private static final int EXPORT_LIMIT = 10_000; private static final Set BALANCE_STATUSES = Set.of( "AVAILABLE", "HOLD", "ALLOCATED", "PICKED", "IN_TRANSIT", "RECEIVING", "PROCUREMENT"); private static final Set POSITION_STATUSES = Set.of( @@ -79,34 +80,74 @@ public class InventoryQueryService { } public byte[] exportBalances(String customerId, InventoryQueryRequests.BalanceQuery query) { + try (ByteArrayOutputStream output = new ByteArrayOutputStream()) { + validateStatus(query.inventoryStatus(), BALANCE_STATUSES); + output.write(("\uFEFFcustomerId,customerName,overseasWarehouseCode,overseasWarehouseName,destinationWarehouseCode,destinationWarehouseName,inboundOrderId,inboundOrderNo,inboundCode,skuCode,productNameZh,productNameEn,totalPackages,availablePackages,holdPackages,allocatedPackages,pickedPackages,inTransitPackages,receivingPackages,procurementPackages\r\n").getBytes(StandardCharsets.UTF_8)); + for (InventoryBalance value : mapper.balances(customerId, query, 0, 10_000)) { + InventoryBalanceView v = balanceView(value); + output.write((String.join(",", csv(v.customerId()), csv(v.customerName()), csv(v.overseasWarehouseCode()), csv(v.overseasWarehouseName()), csv(v.destinationWarehouseCode()), csv(v.destinationWarehouseName()), csv(v.inboundOrderId()), csv(v.inboundOrderNo()), csv(v.inboundCode()), csv(v.skuCode()), csv(v.productNameZh()), csv(v.productNameEn()), csv(v.totalPackages()), csv(v.availablePackages()), csv(v.holdPackages()), csv(v.allocatedPackages()), csv(v.pickedPackages()), csv(v.inTransitPackages()), csv(v.receivingPackages()), csv(v.procurementPackages())) + "\r\n").getBytes(StandardCharsets.UTF_8)); + } + return output.toByteArray(); + } catch (IOException e) { throw new IllegalStateException("Unable to export inventory balances", e); } + } + + public void writeBalancesCsv(String customerId, InventoryQueryRequests.BalanceQuery query, OutputStream output) + throws IOException { validateStatus(query.inventoryStatus(), BALANCE_STATUSES); - List rows = new ArrayList<>(); - rows.add("customerId,customerName,overseasWarehouseCode,overseasWarehouseName,destinationWarehouseCode," + + output.write(("\uFEFFcustomerId,customerName,overseasWarehouseCode,overseasWarehouseName,destinationWarehouseCode," + "destinationWarehouseName,inboundOrderId,inboundOrderNo,inboundCode,skuCode,productNameZh,productNameEn,totalPackages,availablePackages," + - "holdPackages,allocatedPackages,pickedPackages,inTransitPackages,receivingPackages,procurementPackages"); - mapper.balances(customerId, query, 0, EXPORT_LIMIT).stream().map(this::balanceView).forEach(v -> rows.add( + "holdPackages,allocatedPackages,pickedPackages,inTransitPackages,receivingPackages,procurementPackages\r\n").getBytes(StandardCharsets.UTF_8)); + final int batchSize = 1000; + long offset = 0; + while (true) { + List batch = mapper.balances(customerId, query, offset, batchSize).stream().map(this::balanceView).toList(); + if (batch.isEmpty()) break; + for (InventoryBalanceView v : batch) output.write(( String.join(",", csv(v.customerId()), csv(v.customerName()), csv(v.overseasWarehouseCode()), csv(v.overseasWarehouseName()), csv(v.destinationWarehouseCode()), csv(v.destinationWarehouseName()), csv(v.inboundOrderId()), csv(v.inboundOrderNo()), csv(v.inboundCode()), csv(v.skuCode()), csv(v.productNameZh()), csv(v.productNameEn()), csv(v.totalPackages()), csv(v.availablePackages()), csv(v.holdPackages()), csv(v.allocatedPackages()), csv(v.pickedPackages()), csv(v.inTransitPackages()), csv(v.receivingPackages()), - csv(v.procurementPackages())))); - return ("\uFEFF" + String.join("\r\n", rows)).getBytes(StandardCharsets.UTF_8); + csv(v.procurementPackages())) + "\r\n").getBytes(StandardCharsets.UTF_8)); + output.flush(); + if (batch.size() < batchSize) break; + offset += batchSize; + } } public byte[] exportLedgers(String customerId, InventoryQueryRequests.LedgerQuery query) { + try (ByteArrayOutputStream output = new ByteArrayOutputStream()) { + validateDateRange(query); + output.write(("\uFEFFoccurredAt,customerId,overseasWarehouseId,destinationWarehouseCode,inboundOrderId,inboundOrderNo,inboundCode,businessType,businessOrderNo,outboundOrderNo,palletNo,batchNo,statusFrom,statusTo,beforePackages,deltaPackages,afterPackages,operatorId,remark\r\n").getBytes(StandardCharsets.UTF_8)); + for (InventoryLedger value : mapper.ledgers(customerId, query, 0, 10_000)) { + InventoryLedgerView v = ledgerView(value); + output.write((String.join(",", csv(v.occurredAt()), csv(v.customerId()), csv(v.overseasWarehouseId()), csv(v.destinationWarehouseCode()), csv(v.inboundOrderId()), csv(v.inboundOrderNo()), csv(v.inboundCode()), csv(v.businessType()), csv(v.businessOrderNo()), csv(v.outboundOrderNo()), csv(v.palletNo()), csv(v.batchNo()), csv(v.statusFrom()), csv(v.statusTo()), csv(v.beforePackages()), csv(v.deltaPackages()), csv(v.afterPackages()), csv(v.operatorId()), csv(v.remark())) + "\r\n").getBytes(StandardCharsets.UTF_8)); + } + return output.toByteArray(); + } catch (IOException e) { throw new IllegalStateException("Unable to export inventory ledger", e); } + } + + public void writeLedgersCsv(String customerId, InventoryQueryRequests.LedgerQuery query, OutputStream output) + throws IOException { validateDateRange(query); - List rows = new ArrayList<>(); - rows.add("occurredAt,customerId,overseasWarehouseId,destinationWarehouseCode,inboundOrderId,inboundOrderNo," + + output.write(("\uFEFFoccurredAt,customerId,overseasWarehouseId,destinationWarehouseCode,inboundOrderId,inboundOrderNo," + "inboundCode,businessType,businessOrderNo,outboundOrderNo,palletNo,batchNo,statusFrom,statusTo," + - "beforePackages,deltaPackages,afterPackages,operatorId,remark"); - mapper.ledgers(customerId, query, 0, EXPORT_LIMIT).stream().map(this::ledgerView).forEach(v -> rows.add( + "beforePackages,deltaPackages,afterPackages,operatorId,remark\r\n").getBytes(StandardCharsets.UTF_8)); + final int batchSize = 1000; + long offset = 0; + while (true) { + List batch = mapper.ledgers(customerId, query, offset, batchSize).stream().map(this::ledgerView).toList(); + if (batch.isEmpty()) break; + for (InventoryLedgerView v : batch) output.write(( String.join(",", csv(v.occurredAt()), csv(v.customerId()), csv(v.overseasWarehouseId()), csv(v.destinationWarehouseCode()), csv(v.inboundOrderId()), csv(v.inboundOrderNo()), csv(v.inboundCode()), csv(v.businessType()), csv(v.businessOrderNo()), csv(v.outboundOrderNo()), csv(v.palletNo()), csv(v.batchNo()), csv(v.statusFrom()), csv(v.statusTo()), csv(v.beforePackages()), csv(v.deltaPackages()), - csv(v.afterPackages()), csv(v.operatorId()), csv(v.remark())))); - return ("\uFEFF" + String.join("\r\n", rows)).getBytes(StandardCharsets.UTF_8); + csv(v.afterPackages()), csv(v.operatorId()), csv(v.remark())) + "\r\n").getBytes(StandardCharsets.UTF_8)); + output.flush(); + if (batch.size() < batchSize) break; + offset += batchSize; + } } private InventoryBalanceView balanceView(InventoryBalance value) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java index ddb631144..f0e93104b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java @@ -11,10 +11,18 @@ import java.util.List; @Mapper public interface LastlegOrderMapper extends BaseMapper { - record PalletMergeRelationRow(Long orderId, String relatedOrderNo, String mergeGroupNo) {} + record PalletMergeRelationRow(Long orderId, String relatedInboundNo, String mergeGroupNo) {} - @Select("