Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6877c372bd | ||
|
|
3ceafa9ca7 | ||
|
|
a43c4d3321 |
@@ -4967,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;
|
||||
|
||||
+23
@@ -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}")
|
||||
|
||||
+18
@@ -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); }
|
||||
/** 锁定当前活动资源占用。 */
|
||||
|
||||
+11
@@ -32,6 +32,7 @@ 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;
|
||||
@@ -149,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:
|
||||
@@ -181,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);
|
||||
}
|
||||
|
||||
|
||||
+66
-7
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+68
-5
@@ -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));
|
||||
|
||||
+9
-6
@@ -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(); }
|
||||
|
||||
Reference in New Issue
Block a user