From d1ade7e9ddcecc3a431b0669c32d701e25de0972 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Thu, 5 Nov 2026 16:04:10 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mapper/ContainerBookingMapper.java | 4 +- .../mapper/DropshipPalletizationMapper.java | 20 ++++++++ .../repository/DropshipSplitRepository.java | 23 +++++++++ .../service/PdaPickSettlementService.java | 35 +++++++++---- .../split/service/SplitPreloadService.java | 9 ++-- .../ContainerBookingMapperContractTest.java | 23 +++++++++ .../DropshipSplitRepositoryTest.java | 50 +++++++++++++++++++ .../service/PdaPickSettlementServiceTest.java | 24 +++++++++ .../service/SplitPreloadServiceTest.java | 5 +- .../mapper/DispatchWholePdaReadMapper.java | 4 +- .../service/DispatchAggregateService.java | 3 +- .../DispatchDestinationAppointments.java | 13 ++++- .../service/DispatchPdaQueryService.java | 4 +- .../mapper/InboundPlanQueryMapper.java | 2 +- .../lastleg/mapper/LastlegOrderMapper.java | 14 ++++-- .../PreDepartureOrderRepository.java | 19 +++++++ .../lastleg/service/LastlegOrderService.java | 29 ++++++++--- .../DispatchDestinationAppointmentsTest.java | 17 +++++++ .../DispatchPdaReadPersistenceTest.java | 7 +++ ...oundPlanDispatchStatusPersistenceTest.java | 13 ++++- .../service/LastlegOrderSailingStageTest.java | 12 +++++ .../service/LastlegOrderServiceTest.java | 23 +++++++++ 22 files changed, 316 insertions(+), 37 deletions(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/repository/DropshipSplitRepository.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipSplitRepositoryTest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/repository/PreDepartureOrderRepository.java diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java index f8d3764ca..af1fe235e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java @@ -100,8 +100,8 @@ public interface ContainerBookingMapper extends BaseMapper sealedPalletLines(@Param("palletId") long palletId); + /** 未装托盘标签按物理箱身份转移,保留原箱码不重新编号。 */ + @Update(""" + """) + int transferRemainderLabels(@Param("tenant") long tenant,@Param("source") long source,@Param("child") long child, + @Param("order") long order,@Param("code") String code,@Param("packages") int packages,@Param("picked") List picked); + /** 实箱随余量单转移,已装托与跨客户托内其他成员保持原归属。 */ + @Update(""" + """) + int transferRemainderCartons(@Param("tenant") long tenant,@Param("source") long source, + @Param("child") long child,@Param("picked") List picked); + @Getter @Setter class TaskRow { private Long id,tenantId,inboundId,warehouseId,assigneeId; private String inboundCode,customerName,warehouseName,taskStatus,assigneeName,processStatus,fulfillmentType; private Integer totalCartons,countedCartons,allocatedCartons,discrepancyCartons,expectedPalletCount,version; } @Getter @Setter class PalletRow { private Long id,taskId,tenantId,inboundId,warehouseId; private String palletNo,palletStatus,sealOperationId; private Integer version; private LocalDateTime completedAt; } @Getter @Setter class PlanPalletMemberRow { private Long palletId,accountId,inboundId,orderId; private String orderNo,inboundCode,customerName,orderStatusSnapshot; private Integer availablePackageCount; private LocalDateTime expectedArrivalAt; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/DropshipSplitRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/DropshipSplitRepository.java new file mode 100644 index 000000000..5f8d2f827 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/DropshipSplitRepository.java @@ -0,0 +1,23 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.api.ResultCode; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 分托装柜余量的实箱归属迁移。 */ +@Repository +public class DropshipSplitRepository { + private final DropshipPalletizationMapper mapper; + /** 复用一件代发实箱账本,不另建库存。 */ + public DropshipSplitRepository(DropshipPalletizationMapper mapper) { this.mapper = mapper; } + /** 排除本柜已拣托盘,逐箱迁移到余量入仓单;数量不一致回滚本地结算。 */ + public void transferRemainder(long tenant, long source, long child, long order, String code, + int packages, List pickedPallets) { + if (pickedPallets.isEmpty()) throw new BizException(ResultCode.CONFLICT, "缺少已装整托事实"); + if (mapper.transferRemainderLabels(tenant, source, child, order, code, packages, pickedPallets) != packages + || mapper.transferRemainderCartons(tenant, source, child, pickedPallets) != packages) + throw new BizException(ResultCode.CONFLICT, "未装托盘箱数与余量不一致,请刷新后重试"); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java index fb8e91cd4..0e491ff10 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java @@ -53,6 +53,8 @@ public class PdaPickSettlementService { private InboundHoldGuard inboundHoldGuard; @Resource private DropshipPalletizationMapper dropshipPallets; + @Resource + private com.baiyue.firstleg.repository.DropshipSplitRepository dropshipSplits; @Value("${baiyue.security.internal-service-token}") private String internalToken; @@ -121,6 +123,7 @@ public class PdaPickSettlementService { } } + /** 按柜次实拣数量结算,一件代发保留未装整托供后续柜次使用。 */ private boolean settleInbound(long parentInboundId, int pickedPackageCount, List taskItems, long taskId, long operatorId, String operatorName) { @@ -184,9 +187,7 @@ public class PdaPickSettlementService { && pickedByDetail.values().stream().mapToInt(Integer::intValue).sum() != pickedPackageCount) { throw conflict("PDA 已拣明细与入库单已拣箱数不一致"); } - if (dropshipWholePallet && childPackageCount != 0) { - throw conflict("一件代发现场托盘必须整票装柜,不能生成余量分片"); - } + List measurementLines = dropshipWholePallet ? wholePalletMeasurementLines(measurements, pickedPackageCount, parentAvailable) : measurementLines(measurements, pickedByDetail); @@ -219,8 +220,17 @@ public class PdaPickSettlementService { requireAffected(splits.cloneChildWithPurpose(parent.tenantId(), parentInboundId, rootInboundId, remainderChildId, orderSplit.remainderChild().orderId(), orderSplit.remainderChild().orderNo(), remainderChildCode, childPackageCount, sequence, "REMAINDER", operatorId, operatorName), "余量子入库单"); - splits.transferRemainingCartonLabels(parent.tenantId(), parentInboundId, remainderChildId, - orderSplit.remainderChild().orderId(), remainderChildCode, pickedPackageCount, childPackageCount); + if (dropshipWholePallet) { + var pickedPalletIds = taskItems.stream().filter(item -> "PICKED".equals(item.getItemStatus()) + && "SEALED_PALLET".equals(item.getUnitType())).map(LoadingPickItemEntity::getPalletId) + .filter(Objects::nonNull).distinct().toList(); + // 托内箱按实际箱唛移动,不能按箱序号截取,否则会移错已装托盘。 + dropshipSplits.transferRemainder(parent.tenantId(), parentInboundId, remainderChildId, + orderSplit.remainderChild().orderId(), remainderChildCode, childPackageCount, pickedPalletIds); + } else { + splits.transferRemainingCartonLabels(parent.tenantId(), parentInboundId, remainderChildId, + orderSplit.remainderChild().orderId(), remainderChildCode, pickedPackageCount, childPackageCount); + } splits.normalizeLoadedCartonLabels(parent.tenantId(), parentInboundId, pickedPackageCount); } requireAffected(splits.insertBatchWithChildren(batchId, parent.tenantId(), rootInboundId, operationId, parentInboundId, @@ -408,17 +418,24 @@ public class PdaPickSettlementService { return result; } - /** 一件代发逐箱封托已经冻结 SKU 归属;测量批次只承担整票体积重量,不再冒充 SKU 明细。 */ + /** 整托箱数守恒,测量行按箱数分摊重量体积;真实SKU组成仍由封托箱账本冻结。 */ private static List wholePalletMeasurementLines(List measurements, int pickedPackageCount, int parentAvailable) { int measuredPackages = measurements.stream() .mapToInt(item -> count(item.packageCount(), "测量明细箱数")).sum(); - if (pickedPackageCount != parentAvailable || measuredPackages != parentAvailable) { + if (pickedPackageCount <= 0 || pickedPackageCount > parentAvailable || measuredPackages != parentAvailable) { throw conflict("一件代发整托箱数与入库单、测量批次不守恒"); } - return measurements.stream().map(item -> new MeasurementSplitLine(item, - count(item.packageCount(), "测量明细箱数"), 0)).toList(); + int left = pickedPackageCount; + List result = new ArrayList<>(); + for (var item : measurements) { + int total = count(item.packageCount(), "测量明细箱数"); + int picked = Math.min(total, left); + result.add(new MeasurementSplitLine(item, picked, total - picked)); + left -= picked; + } + return List.copyOf(result); } private static Map measurementQuantities(List lines) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java b/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java index 78dbc1a09..a2527e927 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java @@ -72,7 +72,7 @@ public class SplitPreloadService { @Transactional public void ensurePendingForLoadingAllocation(long tenantId, long inboundId, long operatorId, String operatorName) { - ensureLoadingAllocationLifecycle(tenantId, inboundId, operatorId, operatorName, false); + ensureLoadingAllocationLifecycle(tenantId, inboundId, operatorId, operatorName); } /** Creates a provisional preload lifecycle for an order selected before it reaches WAIT_SPLIT. */ @@ -113,16 +113,17 @@ public class SplitPreloadService { @Transactional public void ensureForRunningLoadingAllocation(long tenantId, long inboundId, long operatorId, String operatorName) { - ensureLoadingAllocationLifecycle(tenantId, inboundId, operatorId, operatorName, true); + ensureLoadingAllocationLifecycle(tenantId, inboundId, operatorId, operatorName); } + /** 复用既有预装计划;同一入仓单其他未分配整托可继续进入不同柜次。 */ private void ensureLoadingAllocationLifecycle(long tenantId, long inboundId, long operatorId, - String operatorName, boolean running) { + String operatorName) { InboundSnapshot inbound = requireInbound(tenantId, inboundId); PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId); boolean waitingSplit = isWaitingSplit(inbound.processStatus()); boolean loading = "LOADING".equals(inbound.processStatus()); - if (!waitingSplit && !(running && loading)) { + if (!waitingSplit && !loading) { throw new BizException(ResultCode.CONFLICT, "仅待分柜订单可以建立智能装柜预装计划"); } if (loading && plan == null) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java index b34b5d14a..78708e852 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java @@ -28,6 +28,29 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; @DisplayName("货柜数据库映射契约") class ContainerBookingMapperContractTest { + /** 卸货不等于还柜;人工还柜及船司实际还柜都进入历史页签,两个集合互斥。 */ + @Test void completedScopeUsesActualEmptyReturnInsteadOfUnloadingReport() throws Exception { + String page = String.join("\n", ContainerBookingMapper.class + .getDeclaredMethod("selectBookingPage", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, + com.baiyue.firstleg.dto.ContainerDtos.Query.class, String.class) + .getAnnotation(Select.class).value()); + int start = page.indexOf("SELECT 1 FROM fl_terminal_drayage returned"); + String returned = page.substring(start, page.indexOf("))", start)); + var data = new JdbcDataSource(); + data.setURL("jdbc:h2:mem:return-scope-" + java.util.UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1"); + var db = new JdbcTemplate(data); + db.execute("CREATE TABLE fl_container_booking(id BIGINT,actual_empty_reported_at TIMESTAMP)"); + db.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,actual_empty_return_at TIMESTAMP)"); + db.execute("CREATE TABLE fl_carrier_schedule_node_fact(container_booking_id BIGINT,standard_node VARCHAR(40),actual_at TIMESTAMP)"); + db.execute("INSERT INTO fl_container_booking VALUES(1,CURRENT_TIMESTAMP),(2,NULL),(3,NULL),(4,NULL)"); + db.execute("INSERT INTO fl_terminal_drayage VALUES(2,CURRENT_TIMESTAMP)"); + db.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(3,'EMPTY_RETURNED',CURRENT_TIMESTAMP),(4,'EMPTY_RETURNED',NULL)"); + assertThat(db.queryForList("SELECT b.id FROM fl_container_booking b WHERE EXISTS ("+returned+") ORDER BY b.id",Long.class)) + .containsExactly(2L,3L); + assertThat(db.queryForList("SELECT b.id FROM fl_container_booking b WHERE NOT EXISTS ("+returned+") ORDER BY b.id",Long.class)) + .containsExactly(1L,4L); + } + @Test @DisplayName("货柜映射平台级最终表且状态只从装柜计划读取") void mapsFinalBookingContract() throws Exception { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipSplitRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipSplitRepositoryTest.java new file mode 100644 index 000000000..4f0c57d9e --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipSplitRepositoryTest.java @@ -0,0 +1,50 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.util.List; +import java.util.UUID; +import static org.assertj.core.api.Assertions.assertThat; + +/** 分托结算直接验证实际Mapper,箱序号不代表物理已装托盘。 */ +class DropshipSplitRepositoryTest { + /** 已装箱位于序号末尾时,仍只迁移另一托的箱,跨客户同码不受影响。 */ + @Test void transfersOnlyUnloadedPhysicalCartons() throws Exception { + var data = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + var db = new JdbcTemplate(data); + db.execute("CREATE TABLE fl_inbound_carton_label(id BIGINT,tenant_id BIGINT,inbound_id BIGINT,order_id BIGINT,inbound_code VARCHAR(50),batch_status VARCHAR(20),total_carton_count INT,detail_package_count INT,box_code VARCHAR(50))"); + db.execute("CREATE TABLE fl_dropship_pallet_carton(id BIGINT,tenant_id BIGINT,inbound_id BIGINT,pallet_id BIGINT,carton_label_id BIGINT)"); + db.update("INSERT INTO fl_inbound_carton_label VALUES(1,10,31,100,'IN-1','ACTIVE',4,4,'BOX-1'),(2,10,31,100,'IN-1','ACTIVE',4,4,'BOX-2'),(3,10,31,100,'IN-1','ACTIVE',4,4,'BOX-3'),(4,10,31,100,'IN-1','ACTIVE',4,4,'BOX-4'),(5,20,31,200,'IN-2','ACTIVE',1,1,'BOX-1')"); + db.update("INSERT INTO fl_dropship_pallet_carton VALUES(11,10,31,802,1),(12,10,31,802,2),(13,10,31,801,3),(14,10,31,801,4),(15,20,31,802,5)"); + var config = new MybatisConfiguration(); config.addMapper(DropshipPalletizationMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(data); factory.setConfiguration(config); + var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(DropshipPalletizationMapper.class); + new DropshipSplitRepository(mapper).transferRemainder(10,31,32,101,"IN-1-1",2,List.of(801L)); + assertThat(db.queryForList("SELECT id FROM fl_inbound_carton_label WHERE inbound_id=32 ORDER BY id",Long.class)).containsExactly(1L,2L); + assertThat(db.queryForList("SELECT id FROM fl_dropship_pallet_carton WHERE inbound_id=32 ORDER BY id",Long.class)).containsExactly(11L,12L); + assertThat(db.queryForObject("SELECT inbound_id FROM fl_inbound_carton_label WHERE id=5",Long.class)).isEqualTo(31L); + assertThat(db.queryForList("SELECT box_code FROM fl_inbound_carton_label WHERE inbound_id=32 ORDER BY id",String.class)).containsExactly("BOX-1","BOX-2"); + // 下一柜重新读取封托内容时,货物身份必须归到余量子入库单和余量子订单。 + db.execute("CREATE TABLE fl_dropship_pallet(id BIGINT,pallet_no VARCHAR(50),pallet_status VARCHAR(20))"); + db.execute("INSERT INTO fl_dropship_pallet VALUES(802,'DP-802','SEALED')"); + db.execute("ALTER TABLE fl_dropship_pallet_carton ADD inbound_detail_id BIGINT DEFAULT 41"); + db.execute("ALTER TABLE fl_dropship_pallet_carton ADD sku_code VARCHAR(30) DEFAULT 'SKU-1'"); + db.execute("ALTER TABLE fl_dropship_pallet_carton ADD cargo_name VARCHAR(30) DEFAULT '货物'"); + db.execute("ALTER TABLE fl_dropship_pallet_carton ADD actual_sku_quantity INT DEFAULT 1"); + db.execute("CREATE TABLE fl_dropship_pallet_carton_sku(carton_id BIGINT,inbound_detail_id BIGINT,sku_code VARCHAR(30),cargo_name VARCHAR(30),actual_quantity INT)"); + db.execute("CREATE TABLE fl_inbound_order(id BIGINT,tenant_id BIGINT,order_id BIGINT,order_no VARCHAR(50),inbound_code VARCHAR(50),warehouse_id BIGINT,channel_code VARCHAR(30),channel_name VARCHAR(30),destination_warehouse_id BIGINT,destination_code VARCHAR(30),destination_name VARCHAR(30),customer_name VARCHAR(30),actual_gross_weight_kg DECIMAL,actual_net_weight_kg DECIMAL,actual_volume_cbm DECIMAL,total_package_count INT)"); + db.execute("INSERT INTO fl_inbound_order(id,tenant_id,order_id,order_no,inbound_code,total_package_count) VALUES(32,10,101,'ORDER-1-1','IN-1-1',2)"); + var remainder = mapper.sealedPalletLines(802); + assertThat(remainder).hasSize(1); + assertThat(remainder.get(0).getInboundId()).isEqualTo(32L); + assertThat(remainder.get(0).getOrderId()).isEqualTo(101L); + assertThat(remainder.get(0).getInboundDetailId()).isEqualTo(41L); + assertThat(remainder.get(0).getCartonCount()).isEqualTo(2); + + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java index f3fb214c7..40bffbde5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java @@ -46,6 +46,7 @@ class PdaPickSettlementServiceTest { @MockBean private OrderFulfillmentSplitClient orderSplits; @MockBean private InboundHoldGuard inboundHoldGuard; @MockBean private DropshipPalletizationMapper dropshipPallets; + @MockBean private com.baiyue.firstleg.repository.DropshipSplitRepository dropshipSplits; @Resource private PdaPickSettlementService service; @Test @@ -266,6 +267,29 @@ class PdaPickSettlementServiceTest { && request.lines().size() == 1 && request.lines().get(0).packageCount() == 20)); } + @Test + void settlesOneSealedPalletAndMovesOnlyUnloadedPalletsToRemainder() { + var picked = item(11L, 21L, 31L, 41L, 2, 2, "PICKED"); + picked.setUnitType("SEALED_PALLET"); picked.setPalletId(801L); picked.setPalletNo("DP-1"); + resolved(List.of(picked)); + var physical = new DropshipPalletizationMapper.PlanPalletMemberRow(); + physical.setPalletId(801L); physical.setInboundId(31L); physical.setAccountId(10001L); + physical.setAvailablePackageCount(2); + when(dropshipPallets.palletInboundMembersByPalletIds(any())).thenReturn(List.of(physical)); + when(splits.lockParent(31L)).thenReturn(parent(4)); + when(splits.fulfillmentType(10001L, 31L)).thenReturn("ONE_CLICK_DROPSHIP"); + when(splits.lockLatestApprovedItemsForInbound(10001L, 31L)) + .thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 4))); + when(allocations.lockConfirmed(10001L, 31L)).thenReturn(List.of(allocation(1L, 1, 501L, 4))); + writes(2, 2); + service.settle(1L, 9L, "operator"); + verify(dropshipSplits).transferRemainder(eq(10001L), eq(31L), anyLong(), eq(1002L), + eq("IN-001-1"), eq(2), eq(List.of(801L))); + verify(splits, never()).transferRemainingCartonLabels(anyLong(), anyLong(), anyLong(), anyLong(), anyString(), anyInt(), anyInt()); + verify(splits).updateMeasurementItemForLoaded(10001L, 51L, 2); + verify(splits).markSourceLoaded(10001L, 31L, 2, 9L, 3); + } + @Test void onlyPureSkippedInboundRestoresCandidate() { LoadingPickItemEntity item = item(12L, 22L, 32L, 42L, 8, 0, "SKIPPED"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java index 29294fc3f..093fa11fc 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java @@ -148,14 +148,13 @@ class SplitPreloadServiceTest { } @Test - void ordinaryLoadingAllocationStillRequiresWaitingSplit() { + void otherCabinetMayReuseExistingPreloadWhileSourceIsLoading() { when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 3)); PreloadPlanEntity plan = new PreloadPlanEntity(); plan.setStatus("PENDING"); when(repository.findPlan(10, 20)).thenReturn(plan); - assertThatThrownBy(() -> service.ensurePendingForLoadingAllocation(10, 20, 7, "admin")) - .hasMessageContaining("仅待分柜订单"); + service.ensurePendingForLoadingAllocation(10, 20, 7, "admin"); verify(repository, never()).createPlan(any()); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchWholePdaReadMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchWholePdaReadMapper.java index 43823927a..d994ea41b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchWholePdaReadMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchWholePdaReadMapper.java @@ -11,8 +11,8 @@ public interface DispatchWholePdaReadMapper { record Picked(Long itemId,long packages) { } record Invalid(Long dispatchId,long quantity) { } record Carton(Long itemId,Long cartonId,String cartonMark,String status,long productQuantity) { } - /** 精确单号或有界阶段列表均先在SQL限制绑定仓。 */ - @Select("") List headers(@Param("warehouse")String warehouse,@Param("number")String number,@Param("stage")String stage); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index cd23abee9..f800355ba 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -962,8 +962,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA return batches.stream().collect(Collectors.toMap( b -> List.of(b.batchKey(),b.customerId(),b.destinationKey()),b->b,(a,b)->a,LinkedHashMap::new)).values().stream().map(b -> { var confirmed=confirmedAppointments.stream().filter(w->w.batchKey().equals(b.batchKey()) && w.customerId().equals(b.customerId()) && w.destinationKey().equals(b.destinationKey())).findFirst().orElse(null); - return new DispatchViews.FbaAppointment(b.batchKey(),b.batchNo(),confirmed==null?null:confirmed.confirmationNo(),b.destinationKey(),b.warehouseCode(), - confirmed==null?"PENDING":"SUCCESS",confirmed==null?null:confirmed.startAt(),confirmed==null?"尚无当前有效收货确认":null,b.customerId(),List.of()); + return DispatchDestinationAppointments.booked(b,confirmed); }).toList(); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDestinationAppointments.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDestinationAppointments.java index 3c27a9f7c..25bdadf9f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDestinationAppointments.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDestinationAppointments.java @@ -26,13 +26,13 @@ final class DispatchDestinationAppointments { if (maintenance != null) for (var entry : maintenance.destinationAppointments()) entered.put(entry.destinationKey(),entry.appointmentNo()); var result = new ArrayList(); destinations.forEach((key,code) -> { - var confirmed = booked.stream().filter(a -> key.equals(a.destinationKey()) && "SUCCESS".equals(a.status()) && a.appointmentNo() != null && !a.appointmentNo().isBlank()).toList(); + var confirmed = booked.stream().filter(a -> key.equals(a.destinationKey()) && ("SUCCESS".equals(a.status()) || "REGISTERED".equals(a.status())) && a.appointmentNo() != null && !a.appointmentNo().isBlank()).toList(); var numbers = confirmed.stream().map(FbaAppointment::appointmentNo).distinct().toList(); // 草稿暂未填写不能遮挡后来取得的唯一正式确认号,已填写值仍保留。 var enteredNumber = entered.get(key); var number = enteredNumber != null ? enteredNumber : numbers.size() == 1 ? numbers.get(0) : null; var matching = confirmed.stream().filter(a -> Objects.equals(number,a.appointmentNo())).toList(); - var at = matching.stream().map(FbaAppointment::confirmedAt).filter(Objects::nonNull).min(Comparator.naturalOrder()).orElse(null); + var at = matching.stream().filter(a -> "SUCCESS".equals(a.status())).map(FbaAppointment::confirmedAt).filter(Objects::nonNull).min(Comparator.naturalOrder()).orElse(null); var shipments = cargoShipments.stream().filter(a -> key.equals(a.destinationKey())).map(com.baiyue.lastleg.dispatch.mapper.DispatchReadMapper.CargoShipment::shipmentNo).filter(Objects::nonNull).map(String::trim).filter(s -> !s.isEmpty()).distinct().sorted().toList(); result.add(new FbaAppointment(null,null,number,key,code,number == null ? "PENDING" : at == null ? "REGISTERED" : "SUCCESS",at, number == null ? "请填写目的仓 " + code + " 的预约号" : null,null,shipments)); @@ -40,6 +40,15 @@ final class DispatchDestinationAppointments { return List.copyOf(result); } + /** 已约批次可回显已登记编号;只有当前有效收货确认才能提供审核用确认时间。 */ + static FbaAppointment booked(com.baiyue.lastleg.dispatch.mapper.DispatchReadMapper.BatchRow batch, ReceivingWindow confirmed) { + var registered = "SUCCESS".equals(batch.status()) && batch.confirmationNo() != null && !batch.confirmationNo().isBlank(); + var number = confirmed != null ? confirmed.confirmationNo() : registered ? batch.confirmationNo() : null; + return new FbaAppointment(batch.batchKey(),batch.batchNo(),number,batch.destinationKey(),batch.warehouseCode(), + confirmed != null ? "SUCCESS" : number != null ? "REGISTERED" : "PENDING",confirmed == null ? null : confirmed.startAt(), + confirmed == null ? "尚无当前有效收货确认" : null,batch.customerId(),List.of()); + } + /** 保存允许暂缺编号,但不能把别的目的仓写入本车。 */ static void validate(Maintenance maintenance, List destinations) { if (maintenance == null) return; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java index 1d7cadcdc..380836230 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java @@ -26,7 +26,7 @@ public class DispatchPdaQueryService { @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) public DispatchViews.PdaTask task(Actor actor,String number) { require(actor);if(number==null || number.isBlank())throw new BizException(ResultCode.BAD_REQUEST,"请输入排车单号"); - var rows=repository.headers(actor.boundWarehouseId(),number,null);if(rows.size()!=1)throw new BizException(ResultCode.NOT_FOUND,"当前绑定仓无此排车作业");return assemble(actor,rows).get(0); + var rows=repository.headers(actor.boundWarehouseId(),number,null);if(rows.size()!=1)throw new BizException(ResultCode.NOT_FOUND,"当前绑定仓无此排车作业");var tasks=assemble(actor,rows);if(tasks.isEmpty())throw new BizException(ResultCode.NOT_FOUND,"当前绑定仓无此排车作业");return tasks.get(0); } /** 按收货已确认的实托批量读取;未交接的原PLAN不推算实收仓位或箱数。 */ private List assemble(Actor actor,List roots) { @@ -42,6 +42,8 @@ public class DispatchPdaQueryService { var consumed=hasRelabel?sourceOrigins.pickedAll(ids).stream().collect(Collectors.toMap(p->p.sourceId(),p->p.packages())):Map.of(); for(var root:roots) { var remote=workflow.remoteItemIds(root);var items=cargo.getOrDefault(root.getId(),List.of()).stream().filter(i->!remote.contains(i.getId())).toList();var detail=new ArrayList(); + // 纯异地取货没有仓内拣货,不向PDA推送空任务。 + if(items.isEmpty())continue; boolean sourceValid=true;var expectedHold=new HashMap(); for(var source:frozen)if(Objects.equals(source.getDispatchOrderId(),root.getId())) { long used=consumed.getOrDefault(source.getId(),0L),left=n(source.getPackageCount())-used; 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 38357c4bb..cc7d2a270 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 @@ -15,7 +15,7 @@ public interface InboundPlanQueryMapper { String SCHEDULED_DISPATCH = "EXISTS (SELECT 1 FROM ll_dispatch_item di" + " JOIN ll_dispatch_order dispatch ON dispatch.id=di.dispatch_order_id" + " WHERE di.customer_id=p.customer_id AND di.order_id=l.order_id" + - " AND dispatch.status<>'CANCELLED')"; + " AND dispatch.status<>'CANCELLED' AND COALESCE(dispatch.task_type,'DELIVERY') NOT IN ('PICKUP','LOCAL_TRUCK'))"; String FROM_AND_FILTER = " FROM ll_inbound_plan_line l" + " JOIN ll_inbound_plan p ON p.id=l.plan_id" + 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 eb83554e2..c8a7604b9 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 @@ -51,14 +51,15 @@ public interface LastlegOrderMapper extends BaseMapper { String externalBookingNo, boolean poolSource) { } // Manual node snapshots replace container nodes permanently for the order. - String SAILING_STAGE_FILTER_SQL = "(ll_order.order_type='FIRST_LEG' AND " + + String SAILING_STAGE_VALUE_SQL = "(ll_order.order_type='FIRST_LEG' AND " + "(CASE WHEN COALESCE(ll_order.manual_node_override,FALSE)=TRUE THEN " + "CASE WHEN EXISTS (SELECT 1 FROM ll_order_manual_node n WHERE n.order_id=ll_order.id " + "AND n.customer_id=ll_order.customer_id AND n.node_code='ACTUAL_DEPARTURE' AND n.node_at IS NOT NULL) " + "THEN 1 ELSE 0 END ELSE " + "CASE WHEN EXISTS (SELECT 1 FROM ll_container_shipping_gate g " + "WHERE g.container_booking_id=ll_order.source_container_booking_id " + - "AND g.node_sequence>=40 AND g.actual_at IS NOT NULL) THEN 1 ELSE 0 END END)={0})"; + "AND g.node_sequence>=40 AND g.actual_at IS NOT NULL) THEN 1 ELSE 0 END END)"; + String SAILING_STAGE_FILTER_SQL = SAILING_STAGE_VALUE_SQL + "={0})"; @Select("") List findDispatchStatuses(@Param("orderIds") List orderIds); + /** 已开船来源键用于聚合前去重,保持客户隔离和人工节点覆盖规则。 */ + @Select("") + List departedSources(@Param("customer") String customer); + record DepartedSource(String customerId, String sourceOrderNo) { } + record DispatchStatusRow(Long orderId, String dispatchStatus) { } record OrderWeightRow(Long orderId, BigDecimal grossWeightKg, BigDecimal netWeightKg) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/PreDepartureOrderRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/PreDepartureOrderRepository.java new file mode 100644 index 000000000..e2d92cd7c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/PreDepartureOrderRepository.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.repository; + +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import org.springframework.stereotype.Repository; +import java.util.Set; +import java.util.stream.Collectors; + +/** 尾程实际船节点优先于头程摘要中缺失的开船时间。 */ +@Repository +public class PreDepartureOrderRepository { + private final LastlegOrderMapper mapper; + /** 仅读本服务尾程事实,不读取订单服务数据库。 */ + public PreDepartureOrderRepository(LastlegOrderMapper mapper) { this.mapper = mapper; } + /** 按客户与来源订单组成去重键,在分页前排除已开船记录。 */ + public Set departedKeys(String customer) { + return mapper.departedSources(customer).stream() + .map(row -> row.customerId() + ":" + row.sourceOrderNo()).collect(Collectors.toSet()); + } +} 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 fca81e758..2ddd79977 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 @@ -93,6 +93,7 @@ public class LastlegOrderService { @Resource private ReturnOrderReferenceResolver returnOrderReferences; @Resource private com.baiyue.lastleg.transfer.service.TransferQuantityService transferQuantities; @Resource private OrderPreDepartureClient orderPreDepartureClient; + @Resource private com.baiyue.lastleg.repository.PreDepartureOrderRepository preDepartureOrders; @Value("${baiyue.security.internal-service-token:}") private String internalServiceToken; public PageResult page(String customerId, LastlegOrderRequests.Query query) { @@ -101,12 +102,7 @@ public class LastlegOrderService { public PageResult page(String customerId, LastlegOrderRequests.Query query, String relabelNo) { if (query.sailingStage() == LastlegOrderRequests.SailingStage.BEFORE_DEPARTURE) { - Long customer = parseLong(customerId); - var response = orderPreDepartureClient.page(internalServiceToken, customer, query.orderNo(), query.inboundCode(), - query.page().intValue(), query.size().intValue()); - if (response == null || response.data() == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "订单服务暂时不可用"); - List rows = response.data().records().stream().map(this::preDepartureSummary).toList(); - return new PageResult<>(rows, response.data().page(), response.data().size(), response.data().total(), response.data().pages()); + return preDeparturePage(customerId, query); } if (query.sailingStage() == null && relabelNo == null) return aggregateAllPage(customerId, query); Page page = orderMapper.selectPage( @@ -115,6 +111,27 @@ public class LastlegOrderService { page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); } + /** 开船前先扣除尾程已有实际开船事实,再分页,避免两页签重复及总数失真。 */ + private PageResult preDeparturePage(String customerId, LastlegOrderRequests.Query query) { + var departed = preDepartureOrders.departedKeys(customerId); + var records = new java.util.ArrayList(); + for (int sourcePage = 1; ; sourcePage++) { + var response = orderPreDepartureClient.page(internalServiceToken, parseLong(customerId), + query.orderNo(), query.inboundCode(), sourcePage, 2000); + if (response == null || !response.successful() || response.data() == null) + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "订单服务暂时不可用"); + response.data().records().stream() + .filter(row -> !departed.contains(orderKey(String.valueOf(row.customerId()), row.orderNo()))) + .map(this::preDepartureSummary).forEach(records::add); + if (sourcePage >= response.data().pages()) break; + } + int from = (int)Math.min((query.page() - 1) * query.size(), records.size()); + int to = (int)Math.min(from + query.size(), records.size()); + long total = records.size(); + return new PageResult<>(records.subList(from, to), query.page(), query.size(), total, + total == 0 ? 0 : (total + query.size() - 1) / query.size()); + } + /** 全部及已开船列表使用同一组业务事实,避免聚合列表丢失并托、排车和换标信息。 */ private List nativeSummaries(List orders) { Map> mergeRelations = palletMergeRelations(orders); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDestinationAppointmentsTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDestinationAppointmentsTest.java index c26944385..cc6752f82 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDestinationAppointmentsTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDestinationAppointmentsTest.java @@ -9,6 +9,23 @@ import static org.assertj.core.api.Assertions.*; /** 本车同目的仓跨客户只填一次,先约自动带号,先排可暂缺。 */ class DispatchDestinationAppointmentsTest { + /** 已预约的编号不能因尚无有效收货确认而丢失,也不能冒充确认时间。 */ + @Test void registeredBatchNumberIsVisibleWithoutInventingReceivingConfirmation() { + var batch=new com.baiyue.lastleg.dispatch.mapper.DispatchReadMapper.BatchRow(1L,2L,"FBA_APPOINTMENT:1","AP-1","A","SOURCE","FBA:ONT8",22,"ISA-BOOKED","SUCCESS","ONT8",java.time.LocalDateTime.parse("2026-11-05T10:00:00"),1L); + var booked=DispatchDestinationAppointments.booked(batch,null); + assertThat(booked.status()).isEqualTo("REGISTERED"); + assertThat(booked.confirmedAt()).isNull(); + var rows=resolve(List.of(item("A","ONT8")),maintenance(new DestinationAppointment("FBA:ONT8",null)),List.of(booked)); + assertThat(rows).singleElement().satisfies(a->{ + assertThat(a.appointmentNo()).isEqualTo("ISA-BOOKED"); + assertThat(a.status()).isEqualTo("REGISTERED");assertThat(a.confirmedAt()).isNull(); + }); + var window=new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.ReceivingWindow("FBA_APPOINTMENT:1","AP-1","A","FBA:ONT8","ISA-CURRENT",java.time.Instant.parse("2026-11-06T10:00:00Z"),null,"AG-1",1); + var confirmed=DispatchDestinationAppointments.booked(batch,window); + assertThat(confirmed.appointmentNo()).isEqualTo("ISA-CURRENT"); + assertThat(confirmed.confirmedAt()).isEqualTo(window.startAt()); + assertThat(confirmed.status()).isEqualTo("SUCCESS"); + } @Test void groupsMultipleCustomersAndPalletsByDestination() { var rows=resolve(List.of(item("A","ONT8"),item("B","ONT8"),item("A","LGB8")),null,List.of()); assertThat(rows).extracting(FbaAppointment::destinationWarehouseCode).containsExactly("LGB8","ONT8"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPdaReadPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPdaReadPersistenceTest.java index 2c63dd68b..0dfb10782 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPdaReadPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPdaReadPersistenceTest.java @@ -62,6 +62,13 @@ class DispatchPdaReadPersistenceTest { assertThat(task.items()).allSatisfy(item->assertThat(item.positions()).hasSize(1)); assertThatThrownBy(()->f.tx.execute(s->queries.task(new DispatchContracts.Actor("foreign",ActorMode.PDA,"99"),created.dispatchNo()))) .isInstanceOf(com.baiyue.common.core.exception.BizException.class).hasMessageContaining("当前绑定仓无此排车作业"); + for (String type : List.of("LOCAL_TRUCK", "PICKUP")) { + f.db.update("UPDATE ll_dispatch_order SET task_type=?", type); + assertThat(f.tx.>execute(s->queries.list(actor,null))).isEmpty(); + assertThatThrownBy(()->f.tx.execute(s->queries.task(actor,created.dispatchNo()))) + .isInstanceOf(com.baiyue.common.core.exception.BizException.class); + } + f.db.update("UPDATE ll_dispatch_order SET task_type='DELIVERY'"); f.db.update("DELETE FROM ll_dispatch_pda_pick_order WHERE customer_id='202'"); assertThat(f.tx.execute(s->queries.task(actor,created.dispatchNo())).pickingReconciled()).isFalse(); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanDispatchStatusPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanDispatchStatusPersistenceTest.java index 401c9e65d..ad977ff04 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanDispatchStatusPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanDispatchStatusPersistenceTest.java @@ -32,7 +32,7 @@ class InboundPlanDispatchStatusPersistenceTest { db.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR(20),gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3))"); db.execute("CREATE TABLE ll_inbound_order(id BIGINT,customer_id VARCHAR(20),gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3))"); db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,order_id BIGINT)"); - db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,status VARCHAR(30),stage VARCHAR(30))"); + db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,status VARCHAR(30),stage VARCHAR(30),task_type VARCHAR(30) DEFAULT 'DELIVERY')"); db.update("INSERT INTO ll_inbound_plan VALUES(1,'LIP-1','A',90,TRUE,FALSE,FALSE,'RECEIVED',CURRENT_TIMESTAMP,0)"); db.update("INSERT INTO ll_inbound_plan_line VALUES(2,1,3,'IN-1','91','ABE4','ABE4','ABE4',1,1,1)"); db.update("INSERT INTO ll_order VALUES(3,'A','LX-1','FIRST_LEG','LA','洛杉矶仓','客户A')"); @@ -64,6 +64,15 @@ class InboundPlanDispatchStatusPersistenceTest { assertStatus("UNSCHEDULED"); } + @Test + void pickupTripDoesNotCountAsOutboundScheduling() { + dispatch(10, "ACTIVE", "DELIVERED", "A"); + db.update("UPDATE ll_dispatch_order SET task_type='PICKUP'"); + assertStatus("UNSCHEDULED"); + dispatch(11, "ACTIVE", "BEFORE_LOADING", "A"); + assertStatus("SCHEDULED"); + } + @Test void departedAndCompletedDispatchesRemainScheduled() { dispatch(10, "ACTIVE", "DELIVERING", "A"); @@ -110,7 +119,7 @@ class InboundPlanDispatchStatusPersistenceTest { } private void dispatch(long id, String status, String stage, String itemOwner) { - db.update("INSERT INTO ll_dispatch_order VALUES(?,?,?)", id, status, stage); + db.update("INSERT INTO ll_dispatch_order(id,status,stage) VALUES(?,?,?)", id, status, stage); db.update("INSERT INTO ll_dispatch_item VALUES(?,?,?,3)", id, itemOwner, id); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderSailingStageTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderSailingStageTest.java index 02cd882a6..a0778fc64 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderSailingStageTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderSailingStageTest.java @@ -53,6 +53,18 @@ class LastlegOrderSailingStageTest { assertEquals(List.of(3L, 4L, 7L, 8L, 9L), find(db, null, SailingStage.AFTER_DEPARTURE, null)); assertEquals(List.of(3L, 4L, 7L, 9L), find(db, "A", SailingStage.AFTER_DEPARTURE, null)); assertEquals(List.of(3L, 7L, 9L), find(db, "A", SailingStage.AFTER_DEPARTURE, "PENDING_ASSIGNMENT")); + // 去重仓储执行真实Mapper,沿用客户隔离和人工覆盖后的实际开船事实。 + sql.execute("ALTER TABLE ll_order ADD source_order_no VARCHAR(64)"); + sql.execute("UPDATE ll_order SET source_order_no=CONCAT('SOURCE-',id)"); + var config = new MybatisConfiguration(); config.setMapUnderscoreToCamelCase(true); + config.addMapper(com.baiyue.lastleg.mapper.LastlegOrderMapper.class); + var factory = new com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean(); + factory.setDataSource(new org.springframework.jdbc.datasource.DriverManagerDataSource("jdbc:h2:mem:sailing;MODE=MySQL", "", "")); + factory.setConfiguration(config); + var mapper = new org.mybatis.spring.SqlSessionTemplate(factory.getObject()).getMapper(com.baiyue.lastleg.mapper.LastlegOrderMapper.class); + assertEquals(java.util.Set.of("A:SOURCE-3", "A:SOURCE-4", "A:SOURCE-7", "A:SOURCE-9"), + new com.baiyue.lastleg.repository.PreDepartureOrderRepository(mapper).departedKeys("A")); + assertEquals(java.util.stream.LongStream.rangeClosed(1, 14).boxed().toList(), find(db, null, null, null)); // Discard applications have their own workflow and are excluded. } } 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 bea732115..ac17a2416 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 @@ -79,6 +79,7 @@ class LastlegOrderServiceTest { @Mock WarehouseResourceGateway warehouseResourceGateway; @Mock ContainerShippingGateMapper containerShippingGates; @Mock OrderPreDepartureClient preDeparture; + @Mock com.baiyue.lastleg.repository.PreDepartureOrderRepository preDepartureOrders; @Mock ReturnOrderReferenceResolver returnOrderReferences; @Mock com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService returnReceipts; LastlegOrderService service; @@ -118,6 +119,27 @@ class LastlegOrderServiceTest { assertEquals(new java.math.BigDecimal("22"), summary.netWeightKg()); } + /** 已清关来源即使订单服务仍返回开船前记录,也应先去重再分页。 */ + @Test void excludesDepartedSourcesBeforePagination() { + when(preDepartureOrders.departedKeys("101")).thenReturn(java.util.Set.of("101:EXXHK2611050001")); + var departed = preDepartureRow(1L, "EXXHK2611050001"); + var waiting = preDepartureRow(2L, "WAITING-2"); + when(preDeparture.page(any(), eq(101L), any(), any(), eq(1), eq(2000))) + .thenReturn(ApiResponse.success(new PageResult<>(List.of(departed, waiting), 1, 2000, 2, 1))); + var query = new LastlegOrderRequests.Query(1L, 1L, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, + LastlegOrderRequests.SailingStage.BEFORE_DEPARTURE, null, null, null); + var page = service.page("101", query); + assertEquals(1L, page.total()); + assertEquals(1L, page.pages()); + assertEquals("WAITING-2", page.records().get(0).sourceOrderNo()); + } + + private OrderPreDepartureClient.Row preDepartureRow(long id, String no) { + return new OrderPreDepartureClient.Row(id,101L,no,"IN-"+id,"WAIT_SPLIT", "客户",null,null,null, + null,2,null,null,null,null,null,null,null,null,null,null); + } + @BeforeEach void setUp() { com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo( @@ -144,6 +166,7 @@ class LastlegOrderServiceTest { ReflectionTestUtils.setField(service, "warehouseResourceGateway", warehouseResourceGateway); ReflectionTestUtils.setField(service, "containerShippingGates", containerShippingGates); ReflectionTestUtils.setField(service, "orderPreDepartureClient", preDeparture); + ReflectionTestUtils.setField(service, "preDepartureOrders", preDepartureOrders); lenient().when(preDeparture.page(any(), any(), any(), any(), anyInt(), anyInt())) .thenReturn(ApiResponse.success(new PageResult<>(List.of(), 1, 2000, 0, 0))); }