Compare commits

...
Author SHA1 Message Date
ouyangxq 135ffa662d Fix dispatch physical warehouse binding and empty cancellation 2026-10-20 09:32:13 +08:00
5 changed files with 186 additions and 3 deletions
@@ -25,11 +25,11 @@ public interface DispatchSourceMapper {
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
List<ItemPicked> pickedItems(@Param("dispatch")long dispatch);
/** 组成查询不按占用可选性筛掉本草稿或隐藏客户的真实成员。 */
/** 组成查询保留完整客户成员;仓ID两侧同转有界字符,避免列与连接字符集排序规则冲突。 */
@Select("<script>SELECT DISTINCT p.id AS planPalletId,ip.customer_id AS customerId FROM ll_inbound_plan_pallet p "
+ "JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id "
+ "JOIN ll_inventory_position s ON s.pallet_id=p.warehouse_pallet_id AND s.customer_id=ip.customer_id "
+ "AND s.overseas_warehouse_id=CAST(ip.overseas_warehouse_id AS CHAR) "
+ "AND CAST(s.overseas_warehouse_id AS CHAR(128))=CAST(ip.overseas_warehouse_id AS CHAR(128)) "
+ "WHERE s.overseas_warehouse_id=#{warehouse} AND p.warehouse_pallet_id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> ORDER BY ip.customer_id,p.id</script>")
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
List<PlanReference> physicalPlans(@Param("warehouse")String warehouse,@Param("ids")List<Long> ids);
@@ -337,6 +337,12 @@ public class DispatchService {
/** 执行沿用全车申请/订单→共同实托串行顺序;库存变化不重新解释原冻结贡献。 */
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public RelabelDispatchSourcePort.Snapshot lockExecutionSources(String warehouse,List<DispatchItem> 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<String>();
initial.applications().forEach(a->applications.add(a.relabelNo()));
@@ -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);
}
}
@@ -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);
@@ -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);