From 8e9cbf6aa2cec1f5ea2b3abf2a82aa7b50bb34c1 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Tue, 20 Oct 2026 18:30:50 +0800
Subject: [PATCH] Integrate verified owned dispatch operations and keep
unfinished numbering out of TEST
---
deploy/sql/海外区域统一增量-MySQL8.sql | 9 +--
.../mapper/DispatchCandidateMapper.java | 3 +-
.../repository/DispatchNumberSequence.java | 29 ---------
.../service/DispatchAggregateService.java | 3 +-
.../service/DispatchNumberGenerator.java | 33 -----------
.../dispatch/service/DispatchService.java | 4 +-
.../RelabelDispatchSourceRepository.java | 2 +
.../service/RelabelDispatchSourceService.java | 5 +-
.../src/main/resources/db/lastleg.sql | 7 ---
.../openapi/fleet-owned-business-v1.yaml | 15 ++---
.../DispatchNumberSequenceTest.java | 59 -------------------
.../DispatchAggregatePersistenceTest.java | 3 -
.../DispatchMergedRelabelPersistenceTest.java | 27 +++++++++
.../service/DispatchNumberGeneratorTest.java | 31 ----------
14 files changed, 45 insertions(+), 185 deletions(-)
delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchNumberSequence.java
delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchNumberGenerator.java
delete mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/repository/DispatchNumberSequenceTest.java
delete mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchNumberGeneratorTest.java
diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql
index a82791968..99375cbc7 100644
--- a/deploy/sql/海外区域统一增量-MySQL8.sql
+++ b/deploy/sql/海外区域统一增量-MySQL8.sql
@@ -1865,12 +1865,6 @@ CREATE TABLE ll_order_no_daily_sequence (
PRIMARY KEY (business_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程单号按日原子流水';
-CREATE TABLE ll_dispatch_daily_sequence (
- business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)',
- last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999',
- PRIMARY KEY(business_date)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水';
-
CREATE TABLE ll_order_operation_log (
id bigint NOT NULL COMMENT '操作记录主键',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键',
@@ -4888,7 +4882,6 @@ CREATE TABLE ll_fleet_pickup_history (
KEY idx_pickup_history_trip(trip_no,id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史';
--- 自有业务仅保存原排车的归属证据和附加运营信息,不复制履约主单。
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_ownership (
dispatch_id BIGINT NOT NULL COMMENT '原排车内部主键',
resource_mode VARCHAR(16) NOT NULL COMMENT '原排车资源模式MASTER或EXTERNAL',
@@ -4968,4 +4961,4 @@ CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_history (
CONSTRAINT ck_owned_business_history_version CHECK (record_version>0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营不可变历史';
-SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 228 AS initialized_tables;
+SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 232 AS initialized_tables;
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java
index 434f24fc9..b566fec62 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java
@@ -261,7 +261,8 @@ public interface DispatchCandidateMapper {
"AND a.customer_id=#{customerId} " +
"AND a.relabel_no=#{relabelNo} " +
"AND (a.relabel_no LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_order_no LIKE CONCAT('%',#{orderNo},'%')) " +
- "AND pos.hold_packages > 0 " +
+ // 未绑定实托的位置不是可选运输来源;完整申请中的缺失组成仍由提供者返回阻断原因。
+ "AND pos.hold_packages > 0 AND pos.pallet_id > 0 " +
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di JOIN ll_dispatch_order existing_dispatch " +
"ON existing_dispatch.id=di.dispatch_order_id " +
"WHERE di.customer_id=a.customer_id AND di.source_stage='RELABEL' AND di.order_refs=a.relabel_no " +
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchNumberSequence.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchNumberSequence.java
deleted file mode 100644
index b24f3ba13..000000000
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchNumberSequence.java
+++ /dev/null
@@ -1,29 +0,0 @@
-package com.baiyue.lastleg.dispatch.repository;
-
-import com.baiyue.common.core.api.ResultCode;
-import com.baiyue.common.core.exception.BizException;
-import org.springframework.jdbc.core.JdbcTemplate;
-import org.springframework.stereotype.Repository;
-import org.springframework.transaction.annotation.Propagation;
-import org.springframework.transaction.annotation.Transactional;
-import java.time.LocalDate;
-
-/** 整车单号独立日流水;独立短事务归还序列锁后才进入车辆、司机及货物锁。 */
-@Repository
-public class DispatchNumberSequence {
- private final JdbcTemplate jdbc;
-
- /** 流水与排车主单使用相同服务数据库。 */
- public DispatchNumberSequence(JdbcTemplate jdbc) { this.jdbc = jdbc; }
-
- /** 唯一日期行原子递增,连接内取回本次值;失败不复用已分配的业务号。 */
- @Transactional(propagation = Propagation.REQUIRES_NEW)
- public long next(LocalDate date) {
- jdbc.update("INSERT INTO ll_dispatch_daily_sequence(business_date,last_sequence) VALUES(?,LAST_INSERT_ID(1)) "
- + "ON DUPLICATE KEY UPDATE last_sequence=LAST_INSERT_ID(last_sequence+1)", date);
- Long value = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
- if (value == null || value < 1) throw new IllegalStateException("排车日流水分配失败");
- if (value > 9999) throw new BizException(ResultCode.CONFLICT, "当日排车单四位流水号已用满");
- return value;
- }
-}
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 7c5c760be..ee7959563 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
@@ -32,7 +32,6 @@ import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
/** 真整车创建、加货与查询;客户只属于明细,不属于整车头。 */
@Service
public class DispatchAggregateService implements DispatchExecutionPort,DispatchAppointmentAmendmentPort {
- @jakarta.annotation.Resource private DispatchNumberGenerator numbers;
@jakarta.annotation.Resource private DispatchAppointmentAllocations appointments;
@jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims;
@jakarta.annotation.Resource private DispatchItemSourceService relabelSources;
@@ -270,7 +269,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
var replay = commands.replay(actor,null,identity);
if (replay.isPresent()) return replay.get();
long id=IdWorker.getId();
- String number=numbers.next();
+ String number="DS-"+UUID.randomUUID().toString().replace("-","").toUpperCase(Locale.ROOT);
var shell=fleet.createDispatchShell(actor.operatorId(),new FleetExecutionModels.DispatchShellRequest(id,number,
Long.parseLong(request.warehouseId()),request.idempotencyKey(),identity.commandHash(),request.plannedDepartureAt(),
fleetVehicle(request.vehicle()),fleetDriver(request.driver()),request.remark()));
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchNumberGenerator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchNumberGenerator.java
deleted file mode 100644
index 0acba1e36..000000000
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchNumberGenerator.java
+++ /dev/null
@@ -1,33 +0,0 @@
-package com.baiyue.lastleg.dispatch.service;
-
-import com.baiyue.lastleg.dispatch.repository.DispatchNumberSequence;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.stereotype.Component;
-import java.time.Clock;
-import java.time.LocalDate;
-import java.time.ZoneId;
-import java.time.format.DateTimeFormatter;
-import java.util.Locale;
-
-/** 平台排车单号:PC、北京时间日期与每日独立四位流水。 */
-@Component
-public class DispatchNumberGenerator {
- private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
- private final DispatchNumberSequence sequences;
- private final Clock clock;
-
- /** 正式运行按统一业务时区取当天日期。 */
- @Autowired
- public DispatchNumberGenerator(DispatchNumberSequence sequences) { this(sequences, Clock.systemUTC()); }
-
- /** 固定时钟用于核实跨午夜归零边界。 */
- DispatchNumberGenerator(DispatchNumberSequence sequences, Clock clock) { this.sequences = sequences; this.clock = clock; }
-
- /** 已有命令重放由上层账本恢复原号,本方法只用于首次建单。 */
- public String next() {
- LocalDate date = LocalDate.now(clock.withZone(BUSINESS_ZONE));
- long value = sequences.next(date);
- if (value < 1 || value > 9999) throw new IllegalStateException("排车日流水不在四位范围内");
- return "PC" + date.format(DateTimeFormatter.BASIC_ISO_DATE) + String.format(Locale.ROOT, "%04d", value);
- }
-}
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 c2b706f2c..58626665d 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
@@ -90,7 +90,9 @@ public class DispatchService {
if(facts==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"实托完整来源快照缺失");
reasons.addAll(facts.blockReasons());
if(!facts.complete() && facts.blockReasons().isEmpty())reasons.add("实托实际绑定组成未核齐");
- facts.positions().stream().map(RelabelDispatchSourcePort.Position::warehousePalletId).filter(Objects::nonNull).forEach(pallets::add);
+ // 缺失的实托身份保留在事实与阻断原因中,不能拿零值扩组或传给严格的实托查询入口。
+ facts.positions().stream().map(RelabelDispatchSourcePort.Position::warehousePalletId)
+ .filter(id->id!=null && id>0).forEach(pallets::add);
for(var app:facts.applications())applications.put(app.relabelNo(),app.customerId());
var codes=rows.values().stream().map(DispatchPalletCandidate::palletNo).filter(Objects::nonNull).distinct().toList();
for(var app:wholeSources.nativeApplications(seed.overseasWarehouseId(),codes))applications.put(app.relabelNo(),app.customerId());
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/repository/RelabelDispatchSourceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/repository/RelabelDispatchSourceRepository.java
index 2adcf5885..579500b79 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/repository/RelabelDispatchSourceRepository.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/repository/RelabelDispatchSourceRepository.java
@@ -68,7 +68,9 @@ public class RelabelDispatchSourceRepository {
if (position == null) position = mapper.position(item.getSourceInventoryPositionId());
if (position == null) continue;
positions.put(position.getId(), position);
+ // 无效身份只作为本申请缺失组成保留,不能把其他未绑定位置误连为同一实托。
if (warehouseId.equals(position.getOverseasWarehouseId()) && position.getPalletId() != null
+ && position.getPalletId() > 0
&& !visitedPallets.contains(position.getPalletId())) pending.add(position.getPalletId());
}
}
diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelDispatchSourceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelDispatchSourceService.java
index 28a643181..5052440a0 100644
--- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelDispatchSourceService.java
+++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelDispatchSourceService.java
@@ -149,8 +149,9 @@ public class RelabelDispatchSourceService implements RelabelDispatchSourcePort {
long notified = number(item.getNotifiedPackageCount());
if (!Objects.equals(item.getCustomerId(), app.getCustomerId()) || position == null
|| !Objects.equals(position.getCustomerId(), app.getCustomerId())
- || !Objects.equals(warehouseId, position.getOverseasWarehouseId()) || position.getPalletId() == null) {
- reasons.add(app.getRelabelNo() + ":明细当前位置缺失或客户、仓库归属不一致");
+ || !Objects.equals(warehouseId, position.getOverseasWarehouseId()) || position.getPalletId() == null
+ || position.getPalletId() <= 0) {
+ reasons.add(app.getRelabelNo() + ":明细当前位置缺失、实托身份无效或客户、仓库归属不一致");
bound = false;
}
if (item.getReturnCartonId() != null || "RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) {
diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql
index e8617c50a..95d394795 100644
--- a/lastleg-service/src/main/resources/db/lastleg.sql
+++ b/lastleg-service/src/main/resources/db/lastleg.sql
@@ -41,12 +41,6 @@ CREATE TABLE IF NOT EXISTS ll_order_no_daily_sequence (
PRIMARY KEY(business_date)
) ENGINE=InnoDB COMMENT='尾程单号按日原子流水';
-CREATE TABLE IF NOT EXISTS ll_dispatch_daily_sequence (
- business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)',
- last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999',
- PRIMARY KEY(business_date)
-) ENGINE=InnoDB COMMENT='排车单号每日独立原子流水';
-
CREATE TABLE IF NOT EXISTS ll_order (
id BIGINT NOT NULL COMMENT '雪花 ID',
order_no VARCHAR(32) NOT NULL COMMENT '尾程单号',
@@ -4560,7 +4554,6 @@ CREATE TABLE IF NOT EXISTS ll_fleet_pickup_history (
KEY idx_pickup_history_trip(trip_no,id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史';
--- 自有业务仅保存原排车的归属证据和附加运营信息,不复制履约主单。
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_ownership (
dispatch_id BIGINT NOT NULL COMMENT '原排车内部主键',
resource_mode VARCHAR(16) NOT NULL COMMENT '原排车资源模式MASTER或EXTERNAL',
diff --git a/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml
index 2b2b9372d..dfc46779a 100644
--- a/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml
+++ b/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml
@@ -237,15 +237,6 @@ paths:
-
"$ref": "#/components/schemas/Photo"
nullable: true
- parameters:
- -
- name: "idempotencyKey"
- in: "query"
- required: true
- schema:
- type: "string"
- minLength: 8
- maxLength: 128
requestBody:
required: true
content:
@@ -254,7 +245,13 @@ paths:
type: "object"
required:
- "file"
+ - "idempotencyKey"
properties:
+ idempotencyKey:
+ type: "string"
+ minLength: 8
+ maxLength: 128
+ description: "原上传请求键,以multipart表单字段提交,绑定使用独立请求键。"
file:
type: "string"
format: "binary"
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/repository/DispatchNumberSequenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/repository/DispatchNumberSequenceTest.java
deleted file mode 100644
index d8758a6d9..000000000
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/repository/DispatchNumberSequenceTest.java
+++ /dev/null
@@ -1,59 +0,0 @@
-package com.baiyue.lastleg.dispatch.repository;
-
-import org.junit.jupiter.api.Test;
-import org.springframework.aop.framework.ProxyFactory;
-import org.springframework.jdbc.core.JdbcTemplate;
-import org.springframework.jdbc.datasource.*;
-import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
-import org.springframework.transaction.interceptor.TransactionInterceptor;
-import org.springframework.transaction.support.TransactionTemplate;
-import java.time.LocalDate;
-import java.util.*;
-import java.util.concurrent.*;
-import static org.assertj.core.api.Assertions.*;
-
-/** 使用真实SQL、连接内流水与Spring事务代理核对唯一性和事务边界。 */
-class DispatchNumberSequenceTest {
- private static class Fixture {
- final JdbcTemplate jdbc;
- final DataSourceTransactionManager transactions;
- final DispatchNumberSequence sequence;
- Fixture() {
- var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000","sa","");
- jdbc=new JdbcTemplate(data);transactions=new DataSourceTransactionManager(data);
- jdbc.execute("CREATE TABLE ll_dispatch_daily_sequence(business_date DATE PRIMARY KEY,last_sequence BIGINT NOT NULL)");
- var proxy=new ProxyFactory(new DispatchNumberSequence(jdbc));proxy.setProxyTargetClass(true);
- proxy.addAdvice(new TransactionInterceptor(transactions,new AnnotationTransactionAttributeSource()));
- sequence=(DispatchNumberSequence)proxy.getProxy();
- }
- }
- @Test void startsAtOneEachDayAndContinuesAcrossGeneratorInstances() {
- var f=new Fixture();var today=LocalDate.of(2026,10,20);
- assertThat(f.sequence.next(today)).isEqualTo(1);
- assertThat(f.sequence.next(today)).isEqualTo(2);
- assertThat(f.sequence.next(today.plusDays(1))).isEqualTo(1);
- assertThat(f.sequence.next(today)).isEqualTo(3);
- }
- @Test void concurrentConnectionsAllocateEveryNumberOnce() throws Exception {
- var f=new Fixture();var date=LocalDate.of(2026,10,20);var pool=Executors.newFixedThreadPool(2);
- try {
- var work=new ArrayList>();for(int i=0;i<64;i++)work.add(()->f.sequence.next(date));
- var values=new TreeSet();for(var future:pool.invokeAll(work))values.add(future.get());
- assertThat(values).hasSize(64).first().isEqualTo(1L);
- assertThat(values.last()).isEqualTo(64L);
- } finally {pool.shutdownNow();}
- }
- @Test void outerBusinessRollbackDoesNotReuseAnAlreadyAllocatedNumber() {
- var f=new Fixture();var date=LocalDate.of(2026,10,20);
- assertThatThrownBy(()->new TransactionTemplate(f.transactions).execute(status->{
- assertThat(f.sequence.next(date)).isEqualTo(1);throw new IllegalStateException("business rollback");
- })).isInstanceOf(IllegalStateException.class);
- assertThat(f.sequence.next(date)).isEqualTo(2);
- }
- @Test void fourDigitExhaustionDoesNotWrapOrIncrementTheStoredCounter() {
- var f=new Fixture();var date=LocalDate.of(2026,10,20);
- f.jdbc.update("INSERT INTO ll_dispatch_daily_sequence VALUES(?,9999)",date);
- assertThatThrownBy(()->f.sequence.next(date)).hasMessageContaining("四位流水号已用满");
- assertThat(f.jdbc.queryForObject("SELECT last_sequence FROM ll_dispatch_daily_sequence WHERE business_date=?",Long.class,date)).isEqualTo(9999L);
- }
-}
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 add73ae54..d74c6ed17 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
@@ -536,9 +536,6 @@ public class DispatchAggregatePersistenceTest {
service=new DispatchAggregateService(repository,
new DispatchSourceRepository(session.getMapper(DispatchCandidateMapper.class),session.getMapper(ReturnDispatchCandidateMapper.class),session.getMapper(DispatchSourceMapper.class)),rules,fleet,
new DispatchCommandLedger(new DispatchCommandRepository(session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(json)),new DispatchCommandHasher(json),json);
- var numbers=mock(DispatchNumberGenerator.class);var dailySequence=new java.util.concurrent.atomic.AtomicInteger();
- when(numbers.next()).thenAnswer(invocation->"PC20261020"+String.format(java.util.Locale.ROOT,"%04d",dailySequence.incrementAndGet()));
- org.springframework.test.util.ReflectionTestUtils.setField(service,"numbers",numbers);
org.springframework.test.util.ReflectionTestUtils.setField(service,"evidence",mock(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.class));
org.springframework.test.util.ReflectionTestUtils.setField(service,"ownedBusinessOwnership",ownedBusinessOwnership);
org.springframework.test.util.ReflectionTestUtils.setField(gateway,"workflow",service);
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java
index d4c32ae67..e000a06ac 100644
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java
+++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java
@@ -148,6 +148,33 @@ class DispatchMergedRelabelPersistenceTest {
var created=f.create("actual-plan",group.sources().stream().map(s->s.selection()).toList());assertThat(created.counts().palletCount()).isEqualTo(1);assertThat(created.counts().packageCount()).isEqualTo(4);
}
+ /** 无效实托不成为候选或跨申请扩组锚点;同申请缺失的组成仍明确阻断建单。 */
+ @ParameterizedTest @ValueSource(longs={0,-1})
+ void invalidPalletIdentityDoesNotCrashOrConnectUnrelatedApplications(Long invalidPallet) throws Exception {
+ var f=new Merged(false);
+ for(long id:List.of(102L,103L))f.insert("ll_inventory_position","id",id,"customer_id","101","balance_id",601,
+ "overseas_warehouse_id","9","pallet_id",invalidPallet,"pallet_no","","batch_no","MISSING-"+id,
+ "warehouse_location_code","LOC-1","hold_packages",1);
+ f.insert("ll_relabel_item","id",303,"application_id",201,"customer_id","101","product_name_zh","待核对来源",
+ "total_package_count",1,"notified_package_count",1,"carton_mark","OLD-MISSING","fba_carton_mark","NEW-MISSING",
+ "operation_type","CARTON_LABEL","source_inventory_balance_id",601,"source_inventory_position_id",102,
+ "source_inbound_order_id",7201,"source_inbound_order_no","ORIGIN-MISSING","source_inbound_code","CODE-MISSING",
+ "destination_fba_warehouse_code","ONT8");
+ f.application(203,"RL-UNRELATED","101",9003,304,103,1,"UNRELATED");
+ var candidates=f.rules.wholeCandidates("platform",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"RELABEL",null));
+ var groups=f.rules.selectionGroups(candidates);
+ assertThat(candidates).hasSize(2).allSatisfy(row->assertThat(row.warehousePalletId()).isEqualTo(501L));
+ var group=groups.get("RELABEL:RL-A:501");
+ assertThat(group.complete()).isFalse();
+ assertThat(group.blockReasons()).anyMatch(reason->reason.contains("RL-A")&&reason.contains("明细当前位置缺失"));
+ assertThat(group.sources()).extracting(DispatchPlanningModels.SelectionGroupSource::sourceBusinessNo)
+ .containsExactlyInAnyOrder("RL-A","RL-B");
+ assertThat(group.pallets()).hasSize(1);
+ assertThatThrownBy(()->f.create("invalid-pallet",group.sources().stream().map(s->s.selection()).toList()))
+ .isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
+ assertThat(f.count("ll_dispatch_order")).isZero();assertThat(f.count("ll_dispatch_pallet_lock")).isZero();
+ }
+
/** 只隔离测量和车队外部目录;来源提供者、候选、库存与所有排车Mapper均为实际实现。 */
private static final class Merged {
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchNumberGeneratorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchNumberGeneratorTest.java
deleted file mode 100644
index 2dfe4acbd..000000000
--- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchNumberGeneratorTest.java
+++ /dev/null
@@ -1,31 +0,0 @@
-package com.baiyue.lastleg.dispatch.service;
-
-import com.baiyue.lastleg.dispatch.repository.DispatchNumberSequence;
-import org.junit.jupiter.api.Test;
-import java.time.*;
-import static org.assertj.core.api.Assertions.*;
-import static org.mockito.Mockito.*;
-
-class DispatchNumberGeneratorTest {
- @Test void usesBusinessDateAndFourDigitsWithoutWarehouseOrCustomerSuffix() {
- var sequences=mock(DispatchNumberSequence.class);
- when(sequences.next(LocalDate.of(2026,10,20))).thenReturn(1L,42L,9999L);
- var numbers=new DispatchNumberGenerator(sequences,Clock.fixed(Instant.parse("2026-10-20T08:00:00Z"),ZoneOffset.UTC));
- assertThat(numbers.next()).isEqualTo("PC202610200001");
- assertThat(numbers.next()).isEqualTo("PC202610200042");
- assertThat(numbers.next()).isEqualTo("PC202610209999");
- }
- @Test void switchesToTheNewDailySequenceAtShanghaiMidnight() {
- var sequences=mock(DispatchNumberSequence.class);
- when(sequences.next(LocalDate.of(2026,10,19))).thenReturn(238L);
- when(sequences.next(LocalDate.of(2026,10,20))).thenReturn(1L);
- assertThat(new DispatchNumberGenerator(sequences,Clock.fixed(Instant.parse("2026-10-19T15:59:59Z"),ZoneOffset.UTC)).next()).isEqualTo("PC202610190238");
- assertThat(new DispatchNumberGenerator(sequences,Clock.fixed(Instant.parse("2026-10-19T16:00:00Z"),ZoneOffset.UTC)).next()).isEqualTo("PC202610200001");
- }
- @Test void rejectsExhaustedOrInvalidSequenceInsteadOfWrappingOrReusingNumbers() {
- for(long value:new long[]{0,10000}) {
- var sequences=mock(DispatchNumberSequence.class);when(sequences.next(any())).thenReturn(value);
- assertThatThrownBy(()->new DispatchNumberGenerator(sequences).next()).isInstanceOf(IllegalStateException.class);
- }
- }
-}