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 9d23d70ec..a3be9c524 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 @@ -25,11 +25,11 @@ public interface DispatchSourceMapper { @Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE) List pickedItems(@Param("dispatch")long dispatch); - /** 组成查询不按占用可选性筛掉本草稿或隐藏客户的真实成员。 */ + /** 组成查询保留完整客户成员;仓ID两侧同转有界字符,避免列与连接字符集排序规则冲突。 */ @Select("") @Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE) List physicalPlans(@Param("warehouse")String warehouse,@Param("ids")List ids); 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 daa0b64f7..c2b706f2c 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 @@ -337,6 +337,12 @@ public class DispatchService { /** 执行沿用全车申请/订单→共同实托串行顺序;库存变化不重新解释原冻结贡献。 */ @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) public RelabelDispatchSourcePort.Snapshot lockExecutionSources(String warehouse,List items) { + // 合法空车没有来源或实托可锁;非空明细即使尚未关联实托,仍必须经过原严格校验。 + if(items.isEmpty()) { + if(warehouse==null || warehouse.isBlank())throw new IllegalArgumentException("空排车仍须明确起运仓库"); + return new RelabelDispatchSourcePort.Snapshot(List.of(),List.of(),List.of(), + wholeHashes.source(Map.of("warehouseId",warehouse,"items",List.of())),true,List.of()); + } var pallets=items.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).collect(Collectors.toCollection(TreeSet::new)); var initial=relabelSources.snapshot(warehouse,List.copyOf(pallets));var applications=new TreeSet(); initial.applications().forEach(a->applications.add(a.relabelNo())); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapperWarehouseBindingTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapperWarehouseBindingTest.java new file mode 100644 index 000000000..3a233f672 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapperWarehouseBindingTest.java @@ -0,0 +1,110 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +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查询仓/客户/实托交集;MySQL不同排序规则另由只读真实库探针验证。 */ +class DispatchSourceMapperWarehouseBindingTest { + private static final long WAREHOUSE = 193000000000200003L; + private static final long PHYSICAL_PALLET = 2112349761578209281L; + private static final long PLAN = 2112349764502786049L; + private static final long PLAN_PALLET = 2112349764502786050L; + private static final String CUSTOMER = "2112349740878327809"; + private static final String SECOND_CUSTOMER = "2112349740878327810"; + private JdbcTemplate db; + private DispatchSourceMapper mapper; + + @BeforeEach + void setUp() throws Exception { + var data = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + db = new JdbcTemplate(data); + // 仅保留本查询所需列,类型/长度与lastleg.sql一致:计划仓BIGINT,库存仓VARCHAR(128)。 + db.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),overseas_warehouse_id BIGINT)"); + db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT PRIMARY KEY,plan_id BIGINT,customer_id VARCHAR(128),warehouse_pallet_id BIGINT)"); + db.execute("CREATE TABLE ll_inventory_position(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),overseas_warehouse_id VARCHAR(128),pallet_id BIGINT)"); + plan(PLAN, PLAN_PALLET, CUSTOMER, CUSTOMER, WAREHOUSE, PHYSICAL_PALLET); + position(1, CUSTOMER, Long.toString(WAREHOUSE), PHYSICAL_PALLET); + var config = new MybatisConfiguration(); + config.addMapper(DispatchSourceMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(data); + factory.setConfiguration(config); + mapper = new SqlSessionTemplate(factory.getObject()).getMapper(DispatchSourceMapper.class); + } + + @Test + void longStringStockWarehouseMatchesTheSameNumericPlanWarehouseWithoutLosingIdentity() { + assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE), List.of(PHYSICAL_PALLET))) + .containsExactly(new DispatchSourceMapper.PlanReference(PLAN_PALLET, CUSTOMER)); + } + + @Test + void requestedWarehouseMustMatchTheRealStockPosition() { + assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE + 1), List.of(PHYSICAL_PALLET))).isEmpty(); + } + + @Test + void matchingRequestedStockWarehouseDoesNotAuthorizeAPlanInAnotherWarehouse() { + db.update("UPDATE ll_inbound_plan SET overseas_warehouse_id=? WHERE id=?", WAREHOUSE + 1, PLAN); + assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE), List.of(PHYSICAL_PALLET))).isEmpty(); + } + + @Test + void samePhysicalPalletCannotBorrowAnotherCustomersInventoryPosition() { + db.update("UPDATE ll_inventory_position SET customer_id=? WHERE id=1", SECOND_CUSTOMER); + assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE), List.of(PHYSICAL_PALLET))).isEmpty(); + } + + @Test + void matchingHeaderAndPositionCannotBypassThePlanPalletCustomer() { + db.update("UPDATE ll_inbound_plan_pallet SET customer_id=? WHERE id=?", SECOND_CUSTOMER, PLAN_PALLET); + assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE), List.of(PHYSICAL_PALLET))).isEmpty(); + } + + @Test + void completeSharedPalletCompositionRetainsBothSeparatelyOwnedCustomerPlans() { + plan(PLAN + 10, PLAN_PALLET + 10, SECOND_CUSTOMER, SECOND_CUSTOMER, WAREHOUSE, PHYSICAL_PALLET); + position(2, SECOND_CUSTOMER, Long.toString(WAREHOUSE), PHYSICAL_PALLET); + // 无本仓库存的第三份计划不能混入当前组成。 + plan(PLAN + 20, PLAN_PALLET + 20, "2112349740878327811", "2112349740878327811", WAREHOUSE, PHYSICAL_PALLET); + assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE), List.of(PHYSICAL_PALLET))) + .containsExactly(new DispatchSourceMapper.PlanReference(PLAN_PALLET, CUSTOMER), + new DispatchSourceMapper.PlanReference(PLAN_PALLET + 10, SECOND_CUSTOMER)); + } + + @Test + void aMatchingWarehouseAndCustomerCannotBypassTheRequestedPhysicalPalletSet() { + assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE), List.of(PHYSICAL_PALLET + 1))).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = {"0193000000000200003", "193000000000200003x", "193000000000200003.0"}) + void pseudoWarehouseIdentifiersAreNotAcceptedThroughNumericCoercion(String pseudoWarehouse) { + db.update("UPDATE ll_inventory_position SET overseas_warehouse_id=? WHERE id=1", pseudoWarehouse); + // 请求与库存字符串确实相同;必须仍因不等于计划仓的完整字符身份而拒绝。 + assertThat(mapper.physicalPlans(pseudoWarehouse, List.of(PHYSICAL_PALLET))).isEmpty(); + } + + private void plan(long planId, long planPalletId, String headerCustomer, String palletCustomer, + long warehouse, long physicalPallet) { + db.update("INSERT INTO ll_inbound_plan VALUES(?,?,?)", planId, headerCustomer, warehouse); + db.update("INSERT INTO ll_inbound_plan_pallet VALUES(?,?,?,?)", planPalletId, planId, palletCustomer, physicalPallet); + } + + private void position(long positionId, String customer, String warehouse, long pallet) { + db.update("INSERT INTO ll_inventory_position VALUES(?,?,?,?)", positionId, customer, warehouse, pallet); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java index 9b80587f5..26c740ada 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java @@ -35,6 +35,44 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; /** 主单/资源/占用/账本真实SQL回归;外部目录与选货权威提供者使用受控输入。 */ public class DispatchAggregatePersistenceTest { + /** 空单零版本取消使用真实来源规则,成功及原键重放均不制造来源、库存或重复命令。 */ + @Test void emptyVersionZeroCancellationUsesRealSourceRuleAndPreservesOriginalReplay() throws Exception { + var f=new Fixture();var created=f.tx.execute(t->f.service.create(f.actor,f.create("empty-real-cancel",List.of()))); + var rules=new DispatchService();var facts=mock(com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.class); + var sources=mock(DispatchSourceRepository.class);var serial=mock(com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort.class); + org.springframework.test.util.ReflectionTestUtils.setField(rules,"wholeHashes",new DispatchCommandHasher(f.json)); + org.springframework.test.util.ReflectionTestUtils.setField(rules,"relabelSources",facts); + org.springframework.test.util.ReflectionTestUtils.setField(rules,"wholeSources",sources); + org.springframework.test.util.ReflectionTestUtils.setField(rules,"palletSerial",serial); + when(facts.snapshot(anyString(),anyList())).thenThrow(new IllegalArgumentException("仓库和实托集合不能为空")); + org.springframework.test.util.ReflectionTestUtils.setField(f.service,"sourceRules",rules); + assertThat(created.recordVersion()).isZero(); + var request=new DispatchCommands.ReasonCommand(0L,"empty-cancel-original","取消尚未添加货物的空车"); + var cancelled=f.tx.execute(t->f.service.cancel(f.actor,created.dispatchNo(),request)); + assertThat(cancelled.status()).isEqualTo(Status.CANCELLED);assertThat(cancelled.stage()).isEqualTo(Stage.BEFORE_LOADING); + assertThat(f.tx.execute(t->f.service.cancel(f.actor,created.dispatchNo(),request)).replayed()).isTrue(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='empty-cancel-original'",Long.class)).isOne(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item",Long.class)).isZero(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock",Long.class)).isZero(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock",Long.class)).isZero(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class)).isZero(); + assertThat(f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("CANCELLED"); + verifyNoInteractions(facts,sources,serial); + } + + /** 空单编辑保留零货量与版本命令,不借编辑隐式制造可装车来源。 */ + @Test void emptyShellEditPreservesZeroCargoAndVersionedReplay() throws Exception { + var f=new Fixture();var created=f.tx.execute(t->f.service.create(f.actor,f.create("empty-edit",List.of()))); + var request=new DispatchCommands.Update(0L,"empty-edit-original",null,null,null,null,"空单补充备注"); + var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request)); + assertThat(saved.stage()).isEqualTo(Stage.BEFORE_LOADING);assertThat(saved.counts().packageCount()).isZero(); + assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion()); + assertThat(f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request)).replayed()).isTrue(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item",Long.class)).isZero(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='empty-edit-original'",Long.class)).isOne(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class)).isZero(); + } + /** 移货、取消只释放本车原占用;重排后旧键恢复不得释放新车的活动实托键。 */ @Test void removalCancellationAndOldCommandRecoveryNeverReleaseNewTruckClaims() throws Exception { var f=new Fixture();var a=f.source(1,"101",501,"ONT8",3);var b=f.source(2,"202",502,"LAX9",5); 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 3972a16c1..10478e7c8 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 @@ -18,15 +18,44 @@ class DispatchServiceTest { final DispatchDraftMapper drafts=mock(DispatchDraftMapper.class); final RelabelApplicationMapper relabels=mock(RelabelApplicationMapper.class); final RelabelPalletMeasurementService measurements=mock(RelabelPalletMeasurementService.class); + final com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort facts=mock(com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.class); + final com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort palletSerial=mock(com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort.class); @BeforeEach void setup(){ ReflectionTestUtils.setField(service,"wholeSources",sources);ReflectionTestUtils.setField(service,"wholeHashes",new DispatchCommandHasher(new com.fasterxml.jackson.databind.ObjectMapper())); ReflectionTestUtils.setField(service,"candidates",candidates);ReflectionTestUtils.setField(service,"drafts",drafts); ReflectionTestUtils.setField(service,"relabelApplications",relabels);ReflectionTestUtils.setField(service,"relabelMeasurements",measurements); ReflectionTestUtils.setField(service,"relabelBindingGuard",mock(RelabelBindingGuard.class));ReflectionTestUtils.setField(service,"returnRelabelStock",mock(ReturnRelabelDispatchStock.class)); - var facts=mock(com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.class); // 本用例只验证完整选择的早期拒绝;实际贡献/HOLD由持久化主链覆盖。 when(facts.snapshot(anyString(),anyList())).thenReturn(new com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.Snapshot(List.of(),List.of(),List.of(),"CONTROLLED",true,List.of())); ReflectionTestUtils.setField(service,"relabelSources",facts); + ReflectionTestUtils.setField(service,"palletSerial",palletSerial); + } + /** 空车返回仓范围内的确定空快照,不对不存在的申请、来源或实托取得锁。 */ + @Test void emptyExecutionSourcesHaveWarehouseScopedSnapshotWithoutAnySourceLocks(){ + var empty=service.lockExecutionSources("9",List.of()); + assertThat(empty.complete()).isTrue();assertThat(empty.applications()).isEmpty();assertThat(empty.sources()).isEmpty(); + assertThat(empty.positions()).isEmpty();assertThat(empty.blockReasons()).isEmpty(); + assertThat(empty.compositionVersion()).matches("[0-9a-f]{64}"); + assertThat(service.lockExecutionSources("9",List.of()).compositionVersion()).isEqualTo(empty.compositionVersion()); + assertThat(service.lockExecutionSources("10",List.of()).compositionVersion()).isNotEqualTo(empty.compositionVersion()); + verifyNoInteractions(facts,sources,palletSerial,relabels,measurements); + } + /** 空车也不能缺失起运仓,不能把无范围请求解释成合法空来源。 */ + @Test void emptyExecutionSourcesStillRequireWarehouse(){ + for(String warehouse:Arrays.asList(null,""," "))assertThatThrownBy(()->service.lockExecutionSources(warehouse,List.of())) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("起运仓库"); + verifyNoInteractions(facts,sources,palletSerial); + } + /** 非空货物缺实托或提供者拒绝时保留原错误,不能借空车分支绕过闭包。 */ + @Test void nonemptyExecutionSourcesRetainStrictProviderValidation(){ + var item=new com.baiyue.lastleg.dispatch.entity.DispatchItem(); + when(facts.snapshot(eq("9"),eq(List.of()))).thenThrow(new IllegalArgumentException("仓库和实托集合不能为空")); + assertThatThrownBy(()->service.lockExecutionSources("9",List.of(item))).hasMessageContaining("实托集合不能为空"); + verify(facts).snapshot("9",List.of()); + item.setWarehousePalletId(501L); + when(facts.snapshot("9",List.of(501L))).thenThrow(new IllegalStateException("完整实托来源已变化")); + assertThatThrownBy(()->service.lockExecutionSources("9",List.of(item))).hasMessageContaining("完整实托来源已变化"); + verify(facts).snapshot("9",List.of(501L));verifyNoInteractions(sources,palletSerial); } @Test void anotherOperatorsDraftIsRejectedBeforeAnyCandidateRead(){ assertThatThrownBy(()->service.wholeCandidates("operator",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"PLAN","foreign"))).hasMessageContaining("草稿");verifyNoInteractions(sources);