Compare commits

...
14 changed files with 411 additions and 20 deletions
@@ -1656,6 +1656,12 @@ CREATE TABLE ll_monthly_exchange_rate_history (
KEY idx_ll_exchange_rate_history (exchange_rate_id,created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水';
CREATE TABLE ll_order (
id bigint NOT NULL COMMENT '雪花 ID',
order_no varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '尾程单号',
@@ -4961,4 +4967,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, 232 AS initialized_tables;
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 233 AS initialized_tables;
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.dispatch.mapper;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.time.LocalDate;
/** 排车日期流水的原子分配与连接级结果读取,必须加入同一个短事务。 */
@Mapper
public interface DispatchNumberSequenceMapper {
/** 日期主键串行递增,同时在当前数据库连接记住本次实际分配值。 */
@Insert("INSERT INTO ll_dispatch_daily_sequence(business_date,last_sequence) VALUES(#{date},LAST_INSERT_ID(1)) "
+ "ON DUPLICATE KEY UPDATE last_sequence=LAST_INSERT_ID(last_sequence+1)")
int allocate(@Param("date") LocalDate date);
/** 连接级值不能使用会话或二级查询缓存,须读取本事务刚刚分配的值。 */
@Select("SELECT LAST_INSERT_ID()")
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
Long allocatedValue();
}
@@ -11,6 +11,8 @@ public interface DispatchSourceMapper {
long allocatedPackages, long pickedPackages, long version) { }
record Receipt(long id, long orderId, long inventoryPositionId, long packages, String inboundCode) { }
record ReturnedReceipt(long id,long orderId,long inventoryPositionId,long packages) { }
record PalletReceipt(long palletId,long id,long orderId,long inventoryPositionId,long packages,String inboundCode) { }
record PalletReturnedReceipt(long palletId,long id,long orderId,long inventoryPositionId,long packages) { }
record Claim(Long id, Long dispatchId, String draftToken) { }
record RelabelBasis(String status,String fulfillmentStatus,LocalDateTime updatedAt,LocalDateTime customerConfirmedAt) { }
record NativeApplication(String customerId,String relabelNo) { }
@@ -112,6 +114,27 @@ public interface DispatchSourceMapper {
@Select("SELECT r.id,r.order_id AS orderId,r.inventory_position_id AS inventoryPositionId,r.received_packages AS packages FROM ll_return_stock_receipt r JOIN ll_inventory_position p ON p.id=r.inventory_position_id AND p.customer_id=r.customer_id WHERE r.customer_id=#{customer} AND p.pallet_id=#{pallet} ORDER BY r.id")
List<ReturnedReceipt> returnedReceipts(@Param("customer")String customer,@Param("pallet")Long pallet);
/** 同客户整页实托一次读齐库存版本,仍保留原仓身份供服务校验。 */
@Select("<script>SELECT id,balance_id AS balanceId,pallet_id AS palletId,overseas_warehouse_id AS warehouseId,"
+ "available_packages AS availablePackages,hold_packages AS holdPackages,allocated_packages AS allocatedPackages,"
+ "picked_packages AS pickedPackages,version FROM ll_inventory_position WHERE customer_id=#{customer} AND pallet_id IN "
+ "<foreach collection='pallets' item='pallet' open='(' separator=',' close=')'>#{pallet}</foreach> ORDER BY id</script>")
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
List<Stock> stocks(@Param("customer")String customer,@Param("pallets")List<Long> pallets);
/** 批读保留托盘身份和原实收顺序,拆分后与单托版本依据完全相同。 */
@Select("<script>SELECT warehouse_pallet_id AS palletId,id,order_id AS orderId,inventory_position_id AS inventoryPositionId,"
+ "received_packages AS packages,inbound_code AS inboundCode FROM ll_inbound_stock_receipt WHERE customer_id=#{customer} AND warehouse_pallet_id IN "
+ "<foreach collection='pallets' item='pallet' open='(' separator=',' close=')'>#{pallet}</foreach> ORDER BY id</script>")
List<PalletReceipt> palletReceipts(@Param("customer")String customer,@Param("pallets")List<Long> pallets);
/** 退回实收按客户与当前实托位置批读,不混入另一客户的同托记录。 */
@Select("<script>SELECT p.pallet_id AS palletId,r.id,r.order_id AS orderId,r.inventory_position_id AS inventoryPositionId,"
+ "r.received_packages AS packages FROM ll_return_stock_receipt r JOIN ll_inventory_position p "
+ "ON p.id=r.inventory_position_id AND p.customer_id=r.customer_id WHERE r.customer_id=#{customer} AND p.pallet_id IN "
+ "<foreach collection='pallets' item='pallet' open='(' separator=',' close=')'>#{pallet}</foreach> ORDER BY r.id</script>")
List<PalletReturnedReceipt> palletReturnedReceipts(@Param("customer")String customer,@Param("pallets")List<Long> pallets);
/** 只使本客户本完整来源键的到期草稿成员失效,物理键由独立物理层管理。 */
@Update("UPDATE ll_dispatch_pallet_lock SET status='EXPIRED',active_key=NULL,updated_at=#{at} "
+ "WHERE status='ACTIVE' AND expires_at IS NOT NULL AND expires_at<=#{at} AND customer_id=#{customer} AND source_pallet_key=#{key}")
@@ -0,0 +1,29 @@
package com.baiyue.lastleg.dispatch.repository;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.dispatch.mapper.DispatchNumberSequenceMapper;
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 DispatchNumberSequenceMapper mapper;
/** 流水与排车主单使用相同服务数据库。 */
public DispatchNumberSequence(DispatchNumberSequenceMapper mapper) { this.mapper = mapper; }
/** 唯一日期行原子递增,连接内取回本次值;失败不复用已分配的业务号。 */
@Transactional(propagation = Propagation.REQUIRES_NEW)
public long next(LocalDate date) {
// 两个实际 Mapper 操作加入本次 REQUIRES_NEW,连接级结果不跨连接也不读缓存。
mapper.allocate(date);
Long value = mapper.allocatedValue();
if (value == null || value < 1) throw new IllegalStateException("排车日流水分配失败");
if (value > 9999) throw new BizException(ResultCode.CONFLICT, "当日排车单四位流水号已用满");
return value;
}
}
@@ -75,6 +75,24 @@ public class DispatchSourceRepository {
public List<DispatchSourceMapper.Receipt> receipts(String customer, Long pallet) { return source.receipts(customer,pallet); }
/** 同一实托的退回实收不得从来源证明丢失。 */
public List<DispatchSourceMapper.ReturnedReceipt> returnedReceipts(String customer,Long pallet){return source.returnedReceipts(customer,pallet);}
/** 一条来源的库存与实收版本依据;无实托的在途计划三项均为空。 */
public record SourceFacts(List<DispatchSourceMapper.Stock> stock,List<DispatchSourceMapper.Receipt> receipts,
List<DispatchSourceMapper.ReturnedReceipt> returnedReceipts) { }
/** 同客户的候选托盘只用三次查询;拆分顺序与原单托查询一致,不跨请求缓存。 */
public java.util.Map<Long,SourceFacts> sourceFacts(String customer,List<Long> pallets) {
if(pallets.isEmpty())return java.util.Map.of();
var stocks=source.stocks(customer,pallets).stream().collect(java.util.stream.Collectors.groupingBy(DispatchSourceMapper.Stock::palletId));
var receipts=source.palletReceipts(customer,pallets).stream().collect(java.util.stream.Collectors.groupingBy(
DispatchSourceMapper.PalletReceipt::palletId,java.util.stream.Collectors.mapping(
row->new DispatchSourceMapper.Receipt(row.id(),row.orderId(),row.inventoryPositionId(),row.packages(),row.inboundCode()),java.util.stream.Collectors.toList())));
var returned=source.palletReturnedReceipts(customer,pallets).stream().collect(java.util.stream.Collectors.groupingBy(
DispatchSourceMapper.PalletReturnedReceipt::palletId,java.util.stream.Collectors.mapping(
row->new DispatchSourceMapper.ReturnedReceipt(row.id(),row.orderId(),row.inventoryPositionId(),row.packages()),java.util.stream.Collectors.toList())));
var result=new java.util.LinkedHashMap<Long,SourceFacts>();
for(var pallet:pallets)result.put(pallet,new SourceFacts(List.copyOf(stocks.getOrDefault(pallet,List.of())),
List.copyOf(receipts.getOrDefault(pallet,List.of())),List.copyOf(returned.getOrDefault(pallet,List.of()))));
return java.util.Collections.unmodifiableMap(result);
}
/** 释放已到期草稿键。 */
public int expire(String customer, String key, LocalDateTime at) { return source.expire(customer,key,at); }
/** 锁定当前活动资源占用。 */
@@ -32,6 +32,8 @@ import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
/** 真整车创建、加货与查询;客户只属于明细,不属于整车头。 */
@Service
public class DispatchAggregateService implements DispatchExecutionPort,DispatchAppointmentAmendmentPort {
private static final org.slf4j.Logger LOG=org.slf4j.LoggerFactory.getLogger(DispatchAggregateService.class);
@jakarta.annotation.Resource private DispatchNumberGenerator numbers;
@jakarta.annotation.Resource private DispatchAppointmentAllocations appointments;
@jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims;
@jakarta.annotation.Resource private DispatchItemSourceService relabelSources;
@@ -148,9 +150,13 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
if(query.planIds()!=null && (query.planIds().size()>500 || query.planIds().stream().anyMatch(id->id==null || id<=0))) throw bad("计划筛选不合法");
var normalized=new DispatchPlanningModels.CandidateQuery(query.warehouseId(),query.planIds(),trim(query.orderNo()),
trim(query.destinationWarehouseCode()),trim(query.sourceStage()),trim(query.draftToken()));
long started=System.nanoTime();
var rows=sourceRules.wholeCandidates(actor.operatorId(),normalized);
long candidatesRead=System.nanoTime();
var groups=sourceRules.selectionGroups(rows);
long groupsRead=System.nanoTime();
var batchFacts=appointments.candidates(rows);
long batchesRead=System.nanoTime();
var result=new ArrayList<DispatchPlanningModels.Candidate>();
for(var row:rows) {
SourceType type=Set.of("RELABEL","RETURN_RELABEL").contains(Objects.toString(row.sourceStage(),""))?SourceType.RELABEL:
@@ -180,6 +186,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
row.overseasWarehouseCode(),row.overseasWarehouseName(),row.orderNo(),row.inboundCode(),row.planNo(),row.palletNo(),row.containerNo(),
destination,row.packageCount()==null?0:row.packageCount(),row.grossWeightKg(),row.volumeCbm(),"SOURCE_SUGGESTION",List.copyOf(references),List.copyOf(reasons),group));
}
long finished=System.nanoTime();
// 慢查询只记录阶段耗时和条数,不记录客户、订单、仓库或草稿凭据。
if(finished-started>=1_000_000_000L)LOG.warn(
"dispatch_candidates_slow count={} candidatesMs={} groupsMs={} appointmentsMs={} assembleMs={} totalMs={}",
rows.size(),(candidatesRead-started)/1_000_000L,(groupsRead-candidatesRead)/1_000_000L,
(batchesRead-groupsRead)/1_000_000L,(finished-batchesRead)/1_000_000L,(finished-started)/1_000_000L);
return new DispatchPlanningModels.CandidateList(List.copyOf(result),Instant.now().truncatedTo(ChronoUnit.MILLIS),true);
}
@@ -269,7 +281,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="DS-"+UUID.randomUUID().toString().replace("-","").toUpperCase(Locale.ROOT);
String number=numbers.next();
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()));
@@ -0,0 +1,33 @@
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);
}
}
@@ -4,6 +4,7 @@ import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchDraftMapper;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository.SourceFacts;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
@@ -34,16 +35,64 @@ public class DispatchService {
/** 同一完整组只解析一次,隐藏的真实成员也随服务器组返回。 */
public Map<String,SelectionGroup> selectionGroups(List<DispatchPalletCandidate> rows) {
var reads=new CandidateReadScope(rows);
var result=new LinkedHashMap<String,SelectionGroup>();
for(var row:rows) {
String key=wholeSourceKey(selection(row,null));
if(result.containsKey(key))continue;
var group=sourceGroup(row,false);
var group=sourceGroup(row,false,reads);
for(var source:group.view().sources())result.put(source.sourceKey(),group.view());
}
return Collections.unmodifiableMap(result);
}
/** 仅当前候选事务内批读普通来源;写入和锁内校验继续逐组读取当前事实。 */
private final class CandidateReadScope {
private final Map<String,Set<Long>> loaded=new HashMap<>();
private final Map<String,Map<Long,List<DispatchPalletCandidate>>> available=new HashMap<>();
private final Map<String,Map<Long,SourceFacts>> sourceFacts=new HashMap<>();
/** 同页同仓托盘一次读取,避免每一行再次执行完整计划和退回来源投影。 */
private CandidateReadScope(List<DispatchPalletCandidate> rows) {
var pallets=new TreeMap<String,Set<Long>>();
for(var row:rows)if(row.warehousePalletId()!=null)
pallets.computeIfAbsent(row.overseasWarehouseId(),key->new TreeSet<>()).add(row.warehousePalletId());
pallets.forEach((warehouse,ids)->read(warehouse,List.copyOf(ids)));
loadFacts(rows);
}
/** 换标闭包发现页外实托时补读缺失集合,不拿筛选后的页面充当完整组成。 */
private List<DispatchPalletCandidate> read(String warehouse,List<Long> pallets) {
var known=loaded.computeIfAbsent(warehouse,key->new HashSet<>());
var byPallet=available.computeIfAbsent(warehouse,key->new HashMap<>());
var missing=pallets.stream().filter(id->!known.contains(id)).distinct().sorted().toList();
if(!missing.isEmpty()) {
var rows=wholeSources.physicalAvailable(warehouse,missing);
for(var row:rows)
byPallet.computeIfAbsent(row.warehousePalletId(),key->new ArrayList<>()).add(row);
loadFacts(rows);
known.addAll(missing);
}
return pallets.stream().flatMap(id->byPallet.getOrDefault(id,List.of()).stream()).toList();
}
/** 只补齐本请求尚未读取的客户实托,隐藏组成员也必须有完整版本依据。 */
private void loadFacts(Collection<DispatchPalletCandidate> rows) {
var missing=new TreeMap<String,Set<Long>>();
for(var row:rows)if(row.warehousePalletId()!=null
&& !sourceFacts.getOrDefault(row.customerId(),Map.of()).containsKey(row.warehousePalletId()))
missing.computeIfAbsent(row.customerId(),key->new TreeSet<>()).add(row.warehousePalletId());
missing.forEach((customer,pallets)->sourceFacts.computeIfAbsent(customer,key->new HashMap<>())
.putAll(wholeSources.sourceFacts(customer,List.copyOf(pallets))));
}
/** 在途计划没有库存身份,不向数据库发出三个必为空的实收查询。 */
private SourceFacts facts(DispatchPalletCandidate row) {
return row.warehousePalletId()==null?new SourceFacts(List.of(),List.of(),List.of()):
Objects.requireNonNull(sourceFacts.getOrDefault(row.customerId(),Map.of()).get(row.warehousePalletId()),"实托版本依据缺失");
}
}
/** 草稿单次操作先从实际入口解析完整组,不让前端循环单来源凑整托。 */
public SelectionGroup selectionGroup(String warehouse,DispatchCommands.CargoSelection requested) {
var row=readSelection(warehouse,requested);return sourceGroup(row,false).view();
@@ -78,6 +127,11 @@ public class DispatchService {
/** 实托→申请→整申请实托求固定点;未证实来源只阻断,不补造第一客户或伪业务号。 */
private SourceGroup sourceGroup(DispatchPalletCandidate seed,boolean locked) {
return sourceGroup(seed,locked,null);
}
/** 只读候选复用同事务批读,单组和写入路径不复用候选读取作用域。 */
private SourceGroup sourceGroup(DispatchPalletCandidate seed,boolean locked,CandidateReadScope reads) {
var rows=new TreeMap<String,DispatchPalletCandidate>();rows.put(wholeSourceKey(selection(seed,null)),seed);
var pallets=new TreeSet<Long>();if(seed.warehousePalletId()!=null)pallets.add(seed.warehousePalletId());
var applications=new TreeMap<String,String>();var loaded=new HashSet<String>();var reasons=new LinkedHashSet<String>();
@@ -111,7 +165,8 @@ public class DispatchService {
// 原生退货的资格由其真实箱生产者负责;普通位置提供者明确提示走原生分支。
reasons.removeIf(reason->nativeNos.stream().anyMatch(no->reason.startsWith(no+":")));
if(!pallets.isEmpty()) {
var available=wholeSources.physicalAvailable(seed.overseasWarehouseId(),List.copyOf(pallets));
var available=reads==null?wholeSources.physicalAvailable(seed.overseasWarehouseId(),List.copyOf(pallets)):
reads.read(seed.overseasWarehouseId(),List.copyOf(pallets));
if(available.size()>1000)reasons.add("实托可用来源超过完整核验上限");
var buckets=available.stream().filter(r->r.packageCount()!=null && r.packageCount()>0).collect(Collectors.groupingBy(r->r.customerId()+":"+r.warehousePalletId(),TreeMap::new,Collectors.toList()));
for(var entry:buckets.entrySet()) {
@@ -132,7 +187,8 @@ public class DispatchService {
reasons.add("实托还有未完整选择或重复计入的真实可用库存来源");
}
}
var bases=new TreeMap<String,String>();for(var entry:rows.entrySet())bases.put(entry.getKey(),baseSourceVersion(entry.getValue(),entry.getKey(),false));
if(reads!=null)reads.loadFacts(rows.values());
var bases=new TreeMap<String,String>();for(var entry:rows.entrySet())bases.put(entry.getKey(),baseSourceVersion(entry.getValue(),entry.getKey(),reads));
String version=wholeHashes.source(Arrays.asList(bases,facts==null?null:facts.compositionVersion(),pallets));
var versions=new TreeMap<String,String>();bases.forEach((key,basis)->versions.put(key,wholeHashes.source(List.of(basis,version))));
var sources=new ArrayList<SelectionGroupSource>();
@@ -399,7 +455,7 @@ public class DispatchService {
}
/** 单来源基础摘要不递归包含组版本,组版本再绑定全部成员基础摘要。 */
private String baseSourceVersion(DispatchPalletCandidate row,String key,boolean lockStock) {
private String baseSourceVersion(DispatchPalletCandidate row,String key,CandidateReadScope reads) {
var facts = new java.util.TreeMap<String,Object>();
facts.put("sourceKey",key); facts.put("customerId",row.customerId()); facts.put("warehouseId",row.overseasWarehouseId());
facts.put("planPalletId",row.planPalletId()); facts.put("returnReceiptId",row.returnReceiptId());
@@ -411,12 +467,15 @@ public class DispatchService {
facts.put("grossWeightKg",row.grossWeightKg()); facts.put("volumeCbm",row.volumeCbm());
facts.put("cartonMarks",row.relabelCartonMarks());
if(key.startsWith("RELABEL:")) facts.put("relabelBasis",wholeSources.relabelBasis(key.substring(8,key.lastIndexOf(':'))));
var stock=wholeSources.stock(row.customerId(),row.warehousePalletId(),lockStock);
var basis=row.warehousePalletId()==null?new SourceFacts(List.of(),List.of(),List.of()):
reads!=null?reads.facts(row):new SourceFacts(wholeSources.stock(row.customerId(),row.warehousePalletId(),false),
wholeSources.receipts(row.customerId(),row.warehousePalletId()),wholeSources.returnedReceipts(row.customerId(),row.warehousePalletId()));
var stock=basis.stock();
if(stock.stream().anyMatch(position->!Objects.equals(row.overseasWarehouseId(),position.warehouseId())))
throw new ResponseStatusException(HttpStatus.CONFLICT,"实托库存起运仓不一致,请核对来源");
facts.put("stock",stock);
facts.put("receipts",wholeSources.receipts(row.customerId(),row.warehousePalletId()));
facts.put("returnedReceipts",wholeSources.returnedReceipts(row.customerId(),row.warehousePalletId()));
facts.put("receipts",basis.receipts());
facts.put("returnedReceipts",basis.returnedReceipts());
return wholeHashes.source(facts);
}
@@ -41,6 +41,12 @@ 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水';
CREATE TABLE IF NOT EXISTS ll_order (
id BIGINT NOT NULL COMMENT '雪花 ID',
order_no VARCHAR(32) NOT NULL COMMENT '尾程单号',
@@ -0,0 +1,83 @@
package com.baiyue.lastleg.dispatch.repository;
import com.baiyue.lastleg.dispatch.mapper.DispatchNumberSequenceMapper;
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.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;
final DispatchNumberSequenceMapper mapper;
/** 隔离库造表只使用测试 JDBC,被测分配和读取均由实际 Mapper 执行。 */
Fixture() throws Exception {
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 configuration=new MybatisConfiguration();configuration.addMapper(DispatchNumberSequenceMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(configuration);factory.afterPropertiesSet();
mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DispatchNumberSequenceMapper.class);
var proxy=new ProxyFactory(new DispatchNumberSequence(mapper));proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(transactions,new AnnotationTransactionAttributeSource()));
sequence=(DispatchNumberSequence)proxy.getProxy();
}
}
/** 每次业务事务独立取号,同一天连续增长,下一天从一开始。 */
@Test void startsAtOneEachDayAndContinuesAcrossGeneratorInstances() throws Exception {
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<Callable<Long>>();for(int i=0;i<64;i++)work.add(()->f.sequence.next(date));
var values=new TreeSet<Long>();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() throws Exception {
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() throws Exception {
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);
}
/** 直接改变连接级值后再次调用同一 Mapper,必须绕过一级会话缓存返回新值。 */
@Test void connectionResultBypassesSessionCacheInTheSameTransaction() throws Exception {
var f=new Fixture();
new TransactionTemplate(f.transactions).executeWithoutResult(status->{
f.jdbc.queryForObject("SELECT LAST_INSERT_ID(7)",Long.class);
assertThat(f.mapper.allocatedValue()).isEqualTo(7L);
f.jdbc.queryForObject("SELECT LAST_INSERT_ID(37)",Long.class);
assertThat(f.mapper.allocatedValue()).isEqualTo(37L);
});
}
}
@@ -536,6 +536,9 @@ 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);
@@ -0,0 +1,31 @@
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);
}
}
}
@@ -13,6 +13,67 @@ import static org.mockito.ArgumentMatchers.*;
/** 候选投影在此受控,来源订单/仓/库存版本/实收组成与锁序均经实际MyBatis查询。 */
class DispatchWholeSourcePersistenceTest {
/** 同页普通来源批读不随托盘逐行重复,完整组与版本须和单组权威读取完全一致。 */
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans={true,false})
void batchesPageSourceReadsWithoutChangingGroupsOrReusingThemAcrossRequests(boolean sameCustomer) throws Exception {
var fixture=new Sources(sameCustomer);var selections=fixture.selections();
var first=fixture.rules.selectionGroup("9",selections.get(0));
var second=fixture.rules.selectionGroup("9",selections.get(1));
clearInvocations(fixture.sourceMapper,fixture.candidateMapper);
var rows=fixture.f.candidates.entrySet().stream().sorted(Map.Entry.comparingByKey()).map(Map.Entry::getValue).toList();
var groups=fixture.f.tx.execute(status->fixture.rules.selectionGroups(rows));
assertThat(groups.get("PLAN:1")).isEqualTo(first);
assertThat(groups.get("PLAN:2")).isEqualTo(second);
verify(fixture.sourceMapper,times(1)).physicalPlans("9",List.of(501L,502L));
verify(fixture.sourceMapper,times(1)).physicalReturns("9",List.of(501L,502L));
if(sameCustomer) {
verify(fixture.candidateMapper,times(1)).readSelected("101",List.of(1L,2L));
verify(fixture.candidateMapper,never()).readSelected("101",List.of(1L));
}
var customers=sameCustomer?Map.of("101",List.of(501L,502L)):Map.of("101",List.of(501L),"202",List.of(502L));
customers.forEach((customer,pallets)->{
verify(fixture.sourceMapper,times(1)).stocks(customer,pallets);
verify(fixture.sourceMapper,times(1)).palletReceipts(customer,pallets);
verify(fixture.sourceMapper,times(1)).palletReturnedReceipts(customer,pallets);
});
verify(fixture.sourceMapper,never()).stock(anyString(),anyLong(),anyBoolean());
verify(fixture.sourceMapper,never()).receipts(anyString(),anyLong());
verify(fixture.sourceMapper,never()).returnedReceipts(anyString(),anyLong());
verify(fixture.sourceMapper,never()).physicalPlans("9",List.of(501L));
fixture.f.db.update("UPDATE ll_inbound_stock_receipt SET received_packages=4 WHERE id=1");
var refreshed=fixture.f.tx.execute(status->fixture.rules.selectionGroups(rows));
assertThat(refreshed.get("PLAN:1").groupVersion()).isNotEqualTo(first.groupVersion());
verify(fixture.sourceMapper,times(2)).physicalPlans("9",List.of(501L,502L));
}
/** 未实收计划没有实托,整页版本生成不能逐条发空库存和实收查询。 */
@Test void plannedCandidatesNeedNoPhysicalFactQueries() throws Exception {
var fixture=new Sources(true);
var rows=fixture.f.candidates.values().stream().map(row->row.warehousePalletId(null).sourceStage("PLAN")).toList();
var expected=new HashMap<String,String>();
for(var row:rows)expected.put("PLAN:"+row.planPalletId(),fixture.rules.wholeSourceVersion(row,"PLAN:"+row.planPalletId(),false));
clearInvocations(fixture.sourceMapper,fixture.candidateMapper);
var groups=fixture.f.tx.execute(status->fixture.rules.selectionGroups(rows));
for(var entry:expected.entrySet())assertThat(groups.get(entry.getKey()).sources().get(0).selection().sourceVersion()).isEqualTo(entry.getValue());
verifyNoInteractions(fixture.sourceMapper,fixture.candidateMapper);
}
/** 同实托不同客户的收货依据必须隔离,真实退回实收仍参加摘要且保持原顺序。 */
@Test void batchFactsMatchIndividualQueriesForSharedPalletCustomersAndReturns() throws Exception {
var fixture=new Sources();
fixture.f.db.update("INSERT INTO ll_inventory_position VALUES(3,'202',3,501,'PALLET-501','9',2,0,0,0,1)");
fixture.f.db.update("INSERT INTO ll_inbound_stock_receipt VALUES(3,'202','IN-OTHER',9002,3,2,501)");
fixture.f.db.update("INSERT INTO ll_return_stock_receipt VALUES(5,'101',1,9001,1,2)");
fixture.f.db.update("INSERT INTO ll_return_stock_receipt VALUES(6,'202',3,9002,3,1)");
var repository=new DispatchSourceRepository(fixture.candidateMapper,mock(ReturnDispatchCandidateMapper.class),fixture.sourceMapper);
for(var customer:List.of("101","202")) {
var batch=repository.sourceFacts(customer,List.of(501L,502L));
for(var pallet:List.of(501L,502L)) {
assertThat(batch.get(pallet).stock()).isEqualTo(repository.stock(customer,pallet,false));
assertThat(batch.get(pallet).receipts()).isEqualTo(repository.receipts(customer,pallet));
assertThat(batch.get(pallet).returnedReceipts()).isEqualTo(repository.returnedReceipts(customer,pallet));
}
}
}
/** 同仓多客户先锁齐来源订单,随后稳定锁计划和库存,不以MIN展示订单替代组成。 */
@Test void resolvesMultipleCustomersAndLocksAllSourceOrdersBeforeInventory() throws Exception {
var fixture=new Sources();var selections=fixture.selections();clearInvocations(fixture.sourceMapper);
@@ -43,10 +104,12 @@ class DispatchWholeSourcePersistenceTest {
/** 实际来源表保持独立客户;只有复杂候选投影查询在本用例隔离。 */
private static final class Sources {
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
final DispatchSourceMapper sourceMapper;final DispatchService rules=new DispatchService();
final DispatchSourceMapper sourceMapper;final DispatchCandidateMapper candidateMapper;final DispatchService rules=new DispatchService();
/** 构造真实订单组成和库存版本,不能由候选响应替代这些数据库事实。 */
Sources() throws Exception {
f.source(1,"101",501,"ONT8",3);f.source(2,"202",502,"LAX9",5);
Sources() throws Exception { this(false); }
/** 同客户多托用于核对候选批读次数,跨客户夹具继续覆盖原隔离与锁序。 */
Sources(boolean sameCustomer) throws Exception {
f.source(1,"101",501,"ONT8",3);f.source(2,sameCustomer?"101":"202",502,"LAX9",5);
f.db.execute("ALTER TABLE ll_order ADD detained INT DEFAULT 0");
f.db.execute("ALTER TABLE ll_order ADD overseas_warehouse_id VARCHAR(64)");
f.db.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),overseas_warehouse_id VARCHAR(64),source_order_id BIGINT)");
@@ -60,9 +123,9 @@ class DispatchWholeSourcePersistenceTest {
f.db.update("INSERT INTO ll_inventory_position VALUES(?,?,?,?,?,?,?,0,0,0,1)",id,row.customerId(),id,row.warehousePalletId(),row.palletNo(),"9",row.packageCount());
f.db.update("INSERT INTO ll_inbound_stock_receipt VALUES(?,?,?,?,?,?,?)",id,row.customerId(),row.inboundCode(),row.orderId(),id,row.packageCount(),row.warehousePalletId());
}
var plans=mock(DispatchCandidateMapper.class);
var plans=mock(DispatchCandidateMapper.class);candidateMapper=plans;
when(plans.findCustomerScopesByPlanPalletIds(anyList())).thenAnswer(call->{List<Long> ids=call.getArgument(0);return List.of(f.candidates.get(ids.get(0)).customerId());});
when(plans.readSelected(anyString(),anyList())).thenAnswer(call->{List<Long> ids=call.getArgument(1);return List.of(f.candidates.get(ids.get(0)));});
when(plans.readSelected(anyString(),anyList())).thenAnswer(call->{List<Long> ids=call.getArgument(1);return ids.stream().map(f.candidates::get).toList();});
sourceMapper=spy(f.session.getMapper(DispatchSourceMapper.class));
var sources=new DispatchSourceRepository(plans,mock(ReturnDispatchCandidateMapper.class),sourceMapper);
ReflectionTestUtils.setField(rules,"wholeSources",sources);ReflectionTestUtils.setField(rules,"wholeHashes",new DispatchCommandHasher(f.json));
@@ -12,20 +12,23 @@ import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class FleetTripAuthorizationTest {
@Test void calendarRequiresTripReadPermissionThroughMethodSecurity() {
/** 日历按现行排车只读权限放行,预约和旧车次权限不能替代。 */
@Test void calendarRequiresDispatchReadPermissionThroughMethodSecurity() {
try (var context = new AnnotationConfigApplicationContext(Config.class)) {
var controller = context.getBean(PlatformFleetTripController.class);
var calendar = context.getBean(FleetTripCalendarService.class);
var from = java.time.Instant.parse("2026-10-01T00:00:00Z");
var to = from.plusSeconds(86400);
var user = new com.baiyue.common.security.AuthenticatedUser(1L, 0L, 0L, 1L,
"platform", "PLATFORM", "WEB", java.util.Set.of("platform:fleet:trip:list"));
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
user, "unused", List.of(new SimpleGrantedAuthority("platform:appointment:list"))));
assertThatThrownBy(() -> controller.calendar(user, from, to)).isInstanceOf(AccessDeniedException.class);
"platform", "PLATFORM", "WEB", java.util.Set.of("platform:overseas-warehouse:dispatch:list"));
for (var permission : List.of("platform:appointment:list", "platform:fleet:trip:list")) {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
user, "unused", List.of(new SimpleGrantedAuthority(permission))));
assertThatThrownBy(() -> controller.calendar(user, from, to)).isInstanceOf(AccessDeniedException.class);
}
verifyNoInteractions(calendar);
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
user, "unused", List.of(new SimpleGrantedAuthority("platform:fleet:trip:list"))));
user, "unused", List.of(new SimpleGrantedAuthority("platform:overseas-warehouse:dispatch:list"))));
controller.calendar(user, from, to);
verify(calendar).calendar(from, to);
} finally { SecurityContextHolder.clearContext(); }