diff --git a/lastleg-service/docs/lastleg-internal-modularization-baseline.md b/lastleg-service/docs/lastleg-internal-modularization-baseline.md index 9d7fc7509..6a0a01299 100644 --- a/lastleg-service/docs/lastleg-internal-modularization-baseline.md +++ b/lastleg-service/docs/lastleg-internal-modularization-baseline.md @@ -30,7 +30,7 @@ ### 装车资料触发派送边界(2026-10-21) -`DispatchLoadingDocumentService`及平台/PDA两个装车资料Controller只引用`fleet.ownedbusiness.port.DispatchLoadingPhotoPort`的公开照片、版本、绑定请求和结果。`DispatchLoadingPhotoAdapter`在车队内部复用原附加表、上传账本和文件校验,不反向调用排车服务。排车服务持整车父锁,调用端口保存照片后在同一事务推进派送并记录原结果。结构门禁仅开放上述三类到此端口的精确引用,不开放车队Service、Repository或Mapper,也不新增包循环。 +`DispatchLoadingDocumentService`、`DispatchLoadingDepartureService`及平台/PDA两个装车资料Controller只引用`fleet.ownedbusiness.port.DispatchLoadingPhotoPort`的公开照片、版本、绑定请求和结果。`DispatchLoadingPhotoAdapter`在车队内部复用原附加表、上传账本和文件校验,不反向调用排车服务。排车服务持整车父锁,在装车确认或资料保存后核对两项条件,同一事务推进派送并记录原结果。结构门禁仅开放上述四类到此端口的精确引用,不开放车队Service、Repository或Mapper,也不新增包循环。 ### 外配业务原排车详情读取边界(2026-10-21) diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs index db76e8b93..b8baaae29 100644 --- a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs @@ -73,6 +73,7 @@ test('fleet stays a business slice and its policies have no infrastructure depen || edge.source === 'com.baiyue.lastleg.dispatch.service.DispatchAggregateService' && edge.target === 'com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture' || ['com.baiyue.lastleg.dispatch.service.DispatchLoadingDocumentService', + 'com.baiyue.lastleg.dispatch.service.DispatchLoadingDepartureService', 'com.baiyue.lastleg.dispatch.controller.DispatchLoadingDocumentController', 'com.baiyue.lastleg.dispatch.pda.controller.PdaDispatchLoadingDocumentController'].includes(edge.source) && edge.target === 'com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort' diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java index b88eb00dd..80fff1894 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java @@ -93,6 +93,11 @@ public interface DispatchSourceMapper { @Select("SELECT p.id FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id WHERE p.id=#{id} AND ip.customer_id=#{customer} FOR UPDATE") Long lockPlanPallet(@Param("customer") String customer, @Param("id") Long id); + /** 原计划完成实收后才能把预排估算替换为实际整托数量,避免收货中提前锁定半托。 */ + @Select("SELECT COUNT(*) FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id " + + "WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PENDING_RECONCILIATION')") + int receivedPlanCompleted(@Param("customer") String customer,@Param("id") Long id); + /** 退回来源仍使用本票实际实收,不借普通入仓别名。 */ @Select("SELECT id FROM ll_return_stock_receipt WHERE id=#{id} AND customer_id=#{customer} FOR UPDATE") Long lockReturn(@Param("customer") String customer, @Param("id") Long id); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java index 3009cb008..bf2d59126 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java @@ -74,6 +74,8 @@ public class DispatchSourceRepository { public Long lockOrder(String customer, Long id) { return source.lockOrder(customer,id); } /** 锁定真实计划托盘根。 */ public Long lockPlan(String customer, Long id) { return source.lockPlanPallet(customer,id); } + /** 只读核对原计划已经结束实际收货。 */ + public boolean receivedPlanCompleted(String customer,Long id){return source.receivedPlanCompleted(customer,id)==1;} /** 锁定实际退回收据根。 */ public Long lockReturn(String customer, Long id) { return source.lockReturn(customer,id); } /** 真实库存数量及版本。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchReceivedPlan.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchReceivedPlan.java index d9ed44cda..2eb896b59 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchReceivedPlan.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchReceivedPlan.java @@ -16,6 +16,7 @@ final class DispatchReceivedPlan { DispatchSourceRepository sources, DispatchService rules) { if(item.getWarehousePalletId()!=null || item.getPlanPalletId()==null || !Objects.equals(warehouse,item.getOverseasWarehouseId()))return Optional.empty(); + if(!sources.receivedPlanCompleted(item.getCustomerId(),item.getPlanPalletId()))return Optional.empty(); var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId()); if(rows.size()!=1)return Optional.empty(); var actual=rows.get(0); @@ -31,7 +32,7 @@ final class DispatchReceivedPlan { if(selected==null || !Objects.equals(selected.getCustomerId(),member.customerId()))return Optional.empty(); // 原计划的估算箱数不限制实收;已经绑定或换标的冻结贡献仍严格核对。 if((selected.getWarehousePalletId()!=null || selected.getPlanPalletId()==null) - && !Objects.equals(Long.valueOf(selected.getPackageCount()),member.packages()))return Optional.empty(); + && (selected.getPackageCount()==null || selected.getPackageCount().longValue()!=member.packages()))return Optional.empty(); } var stock=sources.stock(item.getCustomerId(),actual.warehousePalletId(),false); if(stock.stream().anyMatch(p->!Objects.equals(p.warehouseId(),warehouse) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java index 44a1c4eab..940f5870d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java @@ -23,6 +23,16 @@ import static org.mockito.Mockito.*; /** 根版本、幂等、真实客户作业与扫码持久化;库存/归属应用命令在此明确模拟,另由原域直接测试验证。 */ class DispatchPickingPersistenceTest { + /** 数量可自然同步不代表能接管其他车的实际托盘。 */ + @Test void receivedPalletClaimedByAnotherDispatchCannotBePicked() throws Exception { + var fixture=new Picking(true);fixture.receivedPlans(9,11); + fixture.f.db.update("UPDATE ll_dispatch_physical_pallet_lock SET dispatch_order_id=999 WHERE warehouse_pallet_id=501"); + assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item, + new DispatchCommands.Pick(fixture.version,"occupied-pallet",11L,9,0,null,null)))) + .isInstanceOf(com.baiyue.common.core.exception.BizException.class).hasMessageContaining("占用"); + verify(fixture.inventory,never()).ship(any(),any(),any()); + assertThat(fixture.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY customer_id",Integer.class)).containsExactly(10,10); + } /** 预排两托10+10、实收9+11,首次扫码同步真实数量并保留原审核,原键重放不重复扣货。 */ @Test void receivedPalletCountsReplaceEstimatesAtomically() throws Exception { var fixture=new Picking(true);fixture.receivedPlans(9,11); @@ -139,6 +149,7 @@ class DispatchPickingPersistenceTest { f.candidates.get(plan).packageCount(count); f.db.update("UPDATE ll_inventory_position SET available_packages=? WHERE id=?",count,10+n); doReturn(List.of(f.candidates.get(plan))).when(sources).selectedPlan(customer,plan); + doReturn(true).when(sources).receivedPlanCompleted(customer,plan); doReturn(List.of(9000+plan)).when(sources).sourceOrders(customer,null,plan); doReturn(List.of(9000+plan)).when(sources).sourceOrders(customer,pallet,plan); doReturn(plan).when(sources).lockPlan(customer,plan); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java index acb329134..9fcc6d86a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java @@ -17,6 +17,22 @@ import static org.mockito.ArgumentMatchers.*; /** 真实PDA任务投影衔接原计划;来源提供者隔离,GET不得写绑定或库存。 */ class DispatchPlanPdaReadPersistenceTest { + /** 完成状态查询走真实Mapper,未完成或其他客户的计划不能被当作本托实收。 */ + @Test void completionGateUsesOriginalCustomerAndPlan() throws Exception { + var f=new Fixture(); + f.f.db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR(64),status VARCHAR(64))"); + f.f.db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,customer_id VARCHAR(64))"); + f.f.db.update("INSERT INTO ll_inbound_plan VALUES(1,'101','IN_PROGRESS')"); + f.f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(2,1,'101')"); + var mapper=f.f.session.getMapper(com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper.class); + assertThat(mapper.receivedPlanCompleted("101",2L)).isZero(); + for(var status:List.of("COMPLETED","PENDING_RECONCILIATION")) { + f.f.db.update("UPDATE ll_inbound_plan SET status=?",status); + assertThat(mapper.receivedPlanCompleted("101",2L)).isOne(); + assertThat(mapper.receivedPlanCompleted("other",2L)).isZero(); + assertThat(mapper.receivedPlanCompleted("101",3L)).isZero(); + } + } /** 已实收原计划直接提供首拣仓位,查询不推进原车版本或写入实托。 */ @Test void receivedPlanExposesPositionWithoutMaterializing() throws Exception { var f=new Fixture();var before=f.dbState(); @@ -61,11 +77,18 @@ class DispatchPlanPdaReadPersistenceTest { @Test void actualStockMustMatchWarehousePalletAndQuantity() throws Exception { var f=new Fixture(); for(var stock:List.of(new Stock(11,21,501L,"99",3,0,0,0,1), - new Stock(11,21,502L,"9",3,0,0,0,1),new Stock(11,21,501L,"9",2,0,0,0,1))) { + new Stock(11,21,502L,"9",3,0,0,0,1),new Stock(11,21,501L,"9",2,0,0,0,1), + new Stock(11,21,501L,"9",3,1,0,0,1),new Stock(11,21,501L,"9",3,0,1,0,1))) { when(f.sources.stock("101",501L,false)).thenReturn(List.of(stock)); assertThat(f.read().items().get(0).positions()).isEmpty(); } } + /** 收货尚未结束时不把当前部分实收数量当成整托最终数量。 */ + @Test void receivingPlanDoesNotExposePartialPallet() throws Exception { + var f=new Fixture();when(f.sources.receivedPlanCompleted("101",1L)).thenReturn(false); + assertThat(f.read().items().get(0).positions()).isEmpty(); + verify(f.sources,never()).stock(anyString(),anyLong(),anyBoolean()); + } /** 复用真实排车及PDA Mapper,模拟原计划来源快照。 */ private static final class Fixture { final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture(); @@ -88,6 +111,7 @@ class DispatchPlanPdaReadPersistenceTest { queries=new DispatchPdaQueryService(new DispatchPdaReadRepository(new SqlSessionTemplate(factory.getObject()).getMapper(DispatchWholePdaReadMapper.class)),f.service); ReflectionTestUtils.setField(queries,"sources",sources);ReflectionTestUtils.setField(queries,"sourceRules",rules); when(sources.selectedPlan("101",1L)).thenReturn(List.of(f.candidates.get(1L))); + when(sources.receivedPlanCompleted(anyString(),anyLong())).thenReturn(true); when(sources.sourceOrders("101",null,1L)).thenReturn(List.of(9001L)); when(sources.sourceOrders("101",501L,1L)).thenReturn(List.of(9001L)); when(sources.stock("101",501L,false)).thenReturn(List.of(new Stock(11,21,501L,"9",3,0,0,0,1)));