feat(dispatch): paginate candidates and expose received PLAN first-pick positions

This commit is contained in:
2026-10-21 00:40:39 +08:00
parent 6877c372bd
commit eeb50d5f60
18 changed files with 436 additions and 76 deletions
@@ -16,12 +16,62 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertEquals;
class DispatchCandidateMapperContractTest {
/** 执行正式计划/实收判定与离港门禁,覆盖自动预留、人工覆盖、混合来源及库存耗尽。 */
@Test
void reservedPalletsCanBePlannedAfterDepartureWithoutInventingReceivedStock() throws Exception {
try (var connection = DriverManager.getConnection("jdbc:h2:mem:dispatch-prearrival;MODE=MySQL")) {
var db = connection.createStatement();
db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR)");
db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,warehouse_pallet_id BIGINT,assignment_status VARCHAR,actual_package_count INT)");
db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR,order_id BIGINT)");
db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT)");
db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,order_type VARCHAR,manual_node_override BOOLEAN,source_container_booking_id BIGINT)");
db.execute("CREATE TABLE ll_order_manual_node(order_id BIGINT,customer_id VARCHAR,node_code VARCHAR,node_at TIMESTAMP)");
db.execute("CREATE TABLE ll_container_shipping_gate(container_booking_id BIGINT,node_sequence INT,actual_at TIMESTAMP)");
db.execute("CREATE TABLE ll_inventory_position(customer_id VARCHAR,pallet_id BIGINT,available_packages INT)");
for (int id = 1; id <= 9; id++) {
db.execute("INSERT INTO ll_inbound_plan VALUES (" + id + ",'A')");
db.execute("INSERT INTO ll_inbound_plan_pallet VALUES (" + id + "," + id + "," + (100 + id) + ",'RESERVED',0)");
db.execute("INSERT INTO ll_inbound_plan_line VALUES (" + id + ",'A'," + id + ")");
db.execute("INSERT INTO ll_inbound_plan_pallet_allocation VALUES (" + id + "," + id + ")");
db.execute("INSERT INTO ll_order VALUES (" + id + ",'A','FIRST_LEG',FALSE," + id + ")");
if (id != 2) db.execute("INSERT INTO ll_container_shipping_gate VALUES (" + id + ",40,CURRENT_TIMESTAMP)");
}
db.execute("UPDATE ll_inbound_plan_pallet SET assignment_status='ASSIGNED',actual_package_count=5 WHERE id IN (3,4)");
db.execute("INSERT INTO ll_inventory_position VALUES ('A',103,5),('A',104,0),('B',104,10)");
db.execute("UPDATE ll_inbound_plan_pallet SET actual_package_count=1 WHERE id=5");
db.execute("UPDATE ll_order SET manual_node_override=TRUE WHERE id IN (6,7,9)");
db.execute("INSERT INTO ll_order_manual_node VALUES (7,'A','ACTUAL_DEPARTURE',CURRENT_TIMESTAMP),(9,'B','ACTUAL_DEPARTURE',CURRENT_TIMESTAMP)");
db.execute("INSERT INTO ll_inbound_plan_line VALUES (20,'A',2)");
db.execute("INSERT INTO ll_inbound_plan_pallet_allocation VALUES (8,20)");
String plan = DispatchCandidateMapper.PLAN_ONLY;
String eligible = "AND (NOT " + plan + " OR (" + DispatchCandidateMapper.DEPARTED_PLAN + ")) "
+ "AND (" + plan + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos "
+ "WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0))";
var ids = new ArrayList<Long>();
try (var rows = db.executeQuery("SELECT p.id,CASE WHEN " + plan + " THEN NULL ELSE p.warehouse_pallet_id END AS physical,"
+ "CASE WHEN " + plan + " THEN 'PLAN' ELSE 'RECEIVED' END AS stage "
+ "FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id WHERE 1=1 " + eligible + " ORDER BY p.id")) {
while (rows.next()) {
long id = rows.getLong("id"); ids.add(id);
assertEquals(id == 3 ? "RECEIVED" : "PLAN", rows.getString("stage"));
assertEquals(id == 3 ? 103L : null, rows.getObject("physical", Long.class));
}
}
assertEquals(List.of(1L, 3L, 7L), ids);
// 查询和写入选中来源共用同一判定;不允许仅在列表展示在途但提交时又要求实收。
for (String sql : List.of(DispatchCandidateMapper.AVAILABLE_PLAN_BODY, DispatchCandidateMapper.SELECTED_PLAN_SQL)) {
assertTrue(sql.contains(eligible.replace("> 0", "&gt; 0").replace("pos.customer_id=ip.customer_id", sql.contains("WHERE pos.customer_id=#{customerId}") ? "pos.customer_id=#{customerId}" : "pos.customer_id=ip.customer_id")));
assertTrue(sql.contains("CASE WHEN " + plan + " THEN NULL ELSE p.warehouse_pallet_id END AS warehousePalletId"));
}
}
}
/** 执行正式仓ID的完整CASE及外层过滤,保留实库存优先、客户来源和长ID文本身份。 */
@Test
void wholeCandidateWarehouseProjectionExecutesAsTextWithoutChangingSourceOwnership() throws Exception {
String sql = String.join(" ", DispatchCandidateMapper.class.getMethod("wholeAvailable", String.class,
String.class, List.class, String.class, String.class, String.class, String.class).getAnnotation(Select.class).value());
int start = sql.indexOf("CAST(CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id)");
int start = sql.indexOf("CAST(CASE WHEN NOT " + DispatchCandidateMapper.PLAN_ONLY + " THEN (SELECT MIN(pos.overseas_warehouse_id)");
int end = sql.indexOf(" AS overseasWarehouseId", start);
assertTrue(start >= 0 && end > start, "正式whole候选必须在整个仓CASE外统一为文本投影");
String expression = sql.substring(start, end).replace("&gt;", ">").replace("&lt;", "<");
@@ -35,15 +85,16 @@ class DispatchCandidateMapperContractTest {
try (var connection = DriverManager.getConnection("jdbc:h2:mem:dispatch-whole-warehouse-text;MODE=MySQL")) {
var statement = connection.createStatement();
statement.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR(128),overseas_warehouse_id BIGINT,source_order_id BIGINT)");
statement.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,warehouse_pallet_id BIGINT)");
statement.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,warehouse_pallet_id BIGINT,assignment_status VARCHAR DEFAULT 'ASSIGNED',actual_package_count INT DEFAULT 1)");
statement.execute("CREATE TABLE ll_inventory_position(customer_id VARCHAR(128),pallet_id BIGINT,available_packages BIGINT,overseas_warehouse_id VARCHAR(128))");
statement.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(128),overseas_warehouse_id VARCHAR(128))");
statement.execute("INSERT INTO ll_inbound_plan VALUES (1,'A',193000000000200002,NULL),"
+ "(2,'A',193000000000100001,NULL),(3,'B',NULL,20),(4,'A',193000000000100001,NULL),"
+ "(5,'B',193000000000200002,NULL),(6,'A',NULL,NULL),(7,'A',193000000000100001,21),"
+ "(8,'A',193000000000100001,NULL),(9,'A',NULL,21)");
statement.execute("INSERT INTO ll_inbound_plan_pallet VALUES (1,1,10),(2,2,NULL),(3,3,NULL),"
statement.execute("INSERT INTO ll_inbound_plan_pallet(id,plan_id,warehouse_pallet_id) VALUES (1,1,10),(2,2,NULL),(3,3,NULL),"
+ "(4,4,40),(5,5,50),(6,6,NULL),(7,7,NULL),(8,8,80),(9,9,NULL)");
statement.execute("INSERT INTO ll_inbound_plan_pallet VALUES (10,1,1000,'RESERVED',0)");
statement.execute("INSERT INTO ll_inventory_position VALUES ('A',10,1,'193000000000100001'),"
+ "('B',40,1,'193000000000100001'),('A',40,0,'193000000000100001'),"
+ "('B',50,1,'193000000000100001'),('A',80,1,'193000000000200002')");
@@ -51,7 +102,7 @@ class DispatchCandidateMapperContractTest {
+ "(20,'B','193000000000100001'),(21,'B','193000000000100001')");
try (var prepared = connection.prepareStatement(query)) {
assertWarehouseRows(prepared, "193000000000100001", List.of(1L, 2L, 3L, 5L, 7L));
assertWarehouseRows(prepared, "193000000000200002", List.of(8L));
assertWarehouseRows(prepared, "193000000000200002", List.of(8L, 10L));
for (String invalid : List.of("19300000000010000", "193000000000100001x", "0193000000000100001", "1.93e17", "wrong"))
assertWarehouseRows(prepared, invalid, List.of());
}
@@ -215,7 +266,7 @@ class DispatchCandidateMapperContractTest {
Method method = DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class);
String sql = String.join(" ", method.getAnnotation(Select.class).value());
assertTrue(sql.contains("p.warehouse_pallet_id IS NULL OR EXISTS"));
assertTrue(sql.contains("AND (" + DispatchCandidateMapper.PLAN_ONLY + " OR EXISTS"));
assertTrue(sql.contains("pos.available_packages &gt; 0"));
}
@@ -249,7 +300,7 @@ class DispatchCandidateMapperContractTest {
String.class, String.class, String.class),
DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class))) {
String sql = String.join(" ", method.getAnnotation(Select.class).value());
assertTrue(sql.contains("CASE WHEN p.warehouse_pallet_id IS NOT NULL"));
assertTrue(sql.contains("CASE WHEN NOT " + DispatchCandidateMapper.PLAN_ONLY));
assertTrue(sql.contains("pos.overseas_warehouse_id"));
assertTrue(sql.contains(method.getName().equals("available")
? "NULLIF(CAST(ip.overseas_warehouse_id AS CHAR(128)),'')"
@@ -38,7 +38,9 @@ class SmartDispatchOpenApiContractTest {
assertThat(map(paths.get(base+"/drafts"))).containsKey("get");
var candidate=map(map(paths.get(base+"/candidates")).get("get"));
var parameterNames=((List<?>)candidate.get("parameters")).stream().map(SmartDispatchOpenApiContractTest::map).map(p->p.get("name")).toList();
assertThat(parameterNames).contains("warehouseId","destinationWarehouseCode","draftToken").doesNotContain("customerId","fbaWarehouseCode");
assertThat(parameterNames).contains("warehouseId","destinationWarehouseCode","draftToken","page","size","warehouseTab").doesNotContain("customerId","fbaWarehouseCode");
assertThat(map(map(schemas.get("DispatchCandidateList")).get("properties")).keySet())
.containsExactlyInAnyOrder("candidates","snapshotAt","complete","total","page","size","pages");
var draft=map(map(schemas.get("DispatchDraft")).get("properties"));
assertThat(draft.keySet()).containsExactlyInAnyOrder("draftToken","recordVersion","status","warehouseId","containers","sources","expiresAt");
var vehicle=map(map(schemas.get("DispatchDraftVehicle")).get("properties"));assertThat(vehicle.keySet()).containsExactlyInAnyOrder("draftContainerId","dispatch");
@@ -33,8 +33,8 @@ class SmartDispatchControllerTest {
/** 候选仍委托实际主流程,保留仓库及全部真实筛选。 */
@Test void candidatesKeepWarehouseMultiCustomerFilters(){
controller.candidates(platform(),"92",List.of(1L,2L),"LMO-1","ONT8","PLAN","draft-1");
verify(aggregate).candidates(actor,new CandidateQuery("92",List.of(1L,2L),"LMO-1","ONT8","PLAN","draft-1"));
controller.candidates(platform(),"92",List.of(1L,2L),"LMO-1","ONT8","PLAN","draft-1",2,10,"LAX");
verify(aggregate).candidates(actor,new CandidateQuery("92",List.of(1L,2L),"LMO-1","ONT8","PLAN","draft-1",2,10,"LAX"));
verifyNoInteractions(planning);
}
/** HTTP实际参数采用destinationWarehouseCode,不能因旧名称使前端筛选静默丢失。 */
@@ -43,7 +43,7 @@ class SmartDispatchControllerTest {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(platform(),null,List.of()));
mvc.perform(get("/api/platform/v1/last-leg/dispatches/candidates").param("warehouseId","92").param("destinationWarehouseCode","LAX9"))
.andExpect(status().isOk());
verify(aggregate).candidates(actor,new CandidateQuery("92",null,null,"LAX9",null,null));verifyNoInteractions(planning);
verify(aggregate).candidates(actor,new CandidateQuery("92",null,null,"LAX9",null,null,1,20,"ALL"));verifyNoInteractions(planning);
}
/** 每个写入都将冻结DTO原样传递,拥有者只能来自认证主体。 */
@Test void allDraftRoutesDelegateFrozenVersionedRequests(){
@@ -28,6 +28,45 @@ import static org.mockito.Mockito.*;
/** 真实提供者→完整组选货→主单/占用→实际库存命令/归属→PDA/装车的本机事务链。 */
class DispatchMergedRelabelPersistenceTest {
/** 原生退货分页按实箱去重计量,不能因申请多明细重复计箱或重量。 */
@Test void nativeReturnPageMeasuresOneCartonOnce() throws Exception {
var f=new Merged(false);var at=LocalDateTime.of(2026,1,1,0,0);
f.insert("ll_return_inventory_carton","id",901,"customer_id","101","overseas_warehouse_id","9",
"carton_mark","NATIVE-CARTON","pallet_code","NATIVE-PALLET","relabel_application_id",201,
"relabel_no","RL-A","status","RELABEL_PENDING","sku_types",1,"total_quantity",2,
"weight",12,"length",100,"width",100,"height",100,"created_at",at,"updated_at",at);
f.f.db.update("UPDATE ll_relabel_item SET source_inventory_position_id=NULL,return_carton_id=901 WHERE id=301");
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","NATIVE-CARTON","operation_type","CARTON_LABEL",
"return_carton_id",901,"destination_fba_warehouse_code","ONT8");
var query=new DispatchPlanningModels.CandidateQuery("9",null,"RL-A",null,"RELABEL",null,1,20,"ALL");
var mapper=f.f.session.getMapper(DispatchCandidatePageMapper.class);
assertThat(mapper.count(query)).isOne();
var row=mapper.page(query,0).get(0);
assertThat(row.returnCartonSource()).isTrue();assertThat(row.packageCount()).isOne();
assertThat(row.grossWeightKg()).isEqualByComparingTo("12");assertThat(row.volumeCbm()).isEqualByComparingTo("1");
f.f.db.update("UPDATE ll_return_inventory_carton SET weight=NULL,height=NULL WHERE id=901");
row=mapper.page(query,0).get(0);
assertThat(row.grossWeightKg()).isNull();assertThat(row.volumeCbm()).isNull();
}
/** 数据库限页不能截断同托来源组;总数、翻页及服务端搜索必须一致。 */
@Test void candidatePagesRetainCompleteCrossPageSelectionGroup() throws Exception {
var f=new Merged(false);
var all=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("9",null,null,null,null,null,1,1,"ALL"));
assertThat(all.total()).isEqualTo(2);assertThat(all.rows()).hasSize(1);
var first=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"RELABEL",null,1,1,"ALL"));
var second=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"RELABEL",null,2,1,"ALL"));
assertThat(first.total()).isEqualTo(2);assertThat(second.total()).isEqualTo(2);
assertThat(first.rows()).hasSize(1);assertThat(second.rows()).hasSize(1);
assertThat(first.rows().get(0).orderRefs()).isNotEqualTo(second.rows().get(0).orderRefs());
var group=f.rules.selectionGroups(first.rows()).values().iterator().next();
assertThat(group.complete()).as(group.blockReasons().toString()).isTrue();
assertThat(group.sources()).extracting(DispatchPlanningModels.SelectionGroupSource::sourceBusinessNo).containsExactlyInAnyOrder("RL-A","RL-B");
var filtered=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("9",null,"RL-B","ONT8","RELABEL",null,1,1,"ALL"));
assertThat(filtered.total()).isOne();assertThat(filtered.rows()).extracting(r->r.orderRefs()).containsExactly("RL-B");
var empty=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("99",null,null,null,"RELABEL",null,1,1,"ALL"));
assertThat(empty.total()).isZero();assertThat(empty.rows()).isEmpty();
}
/** 同客户共享position与跨客户共享physical都只占一托,来源及SHIP严格独立。 */
@ParameterizedTest @ValueSource(booleans={false,true})
void mergedApplicationsUseOnePhysicalPalletAndKeepExactSources(boolean crossCustomer) throws Exception {
@@ -161,7 +200,7 @@ class DispatchMergedRelabelPersistenceTest {
"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 candidates=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"RELABEL",null,1,20,"ALL")).rows();
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");
@@ -190,17 +229,22 @@ class DispatchMergedRelabelPersistenceTest {
for(var table:List.of("ll_order","ll_inventory_position","ll_inventory_balance","ll_inventory_ledger","ll_inventory_operation","ll_physical_pallet_guard",
"ll_relabel_application","ll_relabel_item","ll_relabel_pda_task","ll_relabel_pda_history","ll_relabel_pda_unit_fact","ll_inbound_plan","ll_inbound_plan_pallet",
"ll_inbound_stock_receipt","ll_dispatch_item_source","ll_dispatch_physical_pallet_lock","ll_dispatch_pallet_lock","ll_dispatch_pda_task","ll_dispatch_pda_pick","ll_dispatch_pda_pick_order","ll_warehouse_transfer_inventory_allocation","ll_warehouse_transfer_order","ll_warehouse_transfer_pallet",
"ll_inbound_plan_line","ll_inbound_plan_pallet_allocation","ll_inbound_plan_carton","ll_order_detail","ll_inbound_order","ll_inbound_fba_appointment","ll_inbound_fba_appointment_item")) {
"ll_inbound_plan_line","ll_inbound_plan_pallet_allocation","ll_inbound_plan_carton","ll_order_detail","ll_inbound_order","ll_inbound_fba_appointment","ll_inbound_fba_appointment_item","ll_order_manual_node","ll_container_shipping_gate",
"ll_return_inventory_carton","ll_return_receipt","ll_return_stock_receipt","ll_exception_order","ll_delivery_order")) {
f.db.execute("DROP TABLE IF EXISTS "+table);int start=schema.indexOf("CREATE TABLE "+table+" (");int end=schema.indexOf(") ENGINE=",start);
if(start<0 || end<0)throw new IllegalStateException("正式初始化表缺失:"+table);
// H2的JSON、collation及约束命名空间与MySQL不同;保留实际列和等价四来源排他约束。
f.db.execute(schema.substring(start,end+1).replaceAll("(?i)\\bJSON\\b","CLOB").replaceAll("(?i)\\s+COLLATE\\s+\\w+","")
.replace("_utf8mb4'","'")
.replace("_utf8mb4", "")
.replaceAll("(?i)\\b(UNIQUE KEY|KEY|CONSTRAINT)\\s+(\\w+)","$1 "+table+"_$2")
.replaceAll("\\((receipt_id|return_receipt_id|return_carton_id|dispatch_item_source_id) IS NOT NULL\\)","(CASE WHEN $1 IS NOT NULL THEN 1 ELSE 0 END)"));
}
var provider=new RelabelDispatchSourceService(new RelabelDispatchSourceRepository(f.session.getMapper(RelabelDispatchSourceMapper.class)),f.json);
var origins=new DispatchItemSourceRepository(f.session.getMapper(DispatchItemSourceMapper.class));var snapshots=new DispatchItemSourceService(origins,provider,f.json);
f.session.getConfiguration().addMapper(DispatchCandidatePageMapper.class);
var sourceRepository=new DispatchSourceRepository(f.session.getMapper(DispatchCandidateMapper.class),f.session.getMapper(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class));
ReflectionTestUtils.setField(sourceRepository,"pages",f.session.getMapper(DispatchCandidatePageMapper.class));
ReflectionTestUtils.setField(rules,"wholeSources",sourceRepository);ReflectionTestUtils.setField(rules,"wholeHashes",new DispatchCommandHasher(f.json));
ReflectionTestUtils.setField(rules,"relabelSources",provider);ReflectionTestUtils.setField(rules,"palletSerial",new PhysicalPalletSerialService(new PhysicalPalletSerialRepository(f.session.getMapper(PhysicalPalletSerialMapper.class))));
ReflectionTestUtils.setField(rules,"candidates",f.session.getMapper(DispatchCandidateMapper.class));ReflectionTestUtils.setField(rules,"relabelApplications",f.session.getMapper(RelabelApplicationMapper.class));
@@ -0,0 +1,91 @@
package com.baiyue.lastleg.dispatch.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper.Stock;
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchWholePdaReadMapper;
import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.dispatch.repository.*;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
/** 真实PDA任务投影衔接原计划;来源提供者隔离,GET不得写绑定或库存。 */
class DispatchPlanPdaReadPersistenceTest {
/** 已实收原计划直接提供首拣仓位,查询不推进原车版本或写入实托。 */
@Test void receivedPlanExposesPositionWithoutMaterializing() throws Exception {
var f=new Fixture();var before=f.dbState();
var task=f.read();
assertThat(task.items().get(0).positions()).extracting(DispatchViews.Position::positionId).containsExactly(11L);
assertThat(task.items().get(0).positions().get(0).availablePackages()).isEqualTo(3);
assertThat(f.dbState()).isEqualTo(before);
verify(f.sources).stock("101",501L,false);
verify(f.sources,never()).materialize(anyLong(),anyString(),anyLong(),anyLong(),anyLong(),any());
verify(f.rules,never()).lockExecutionSources(anyString(),anyList());
}
/** 在途、预留托盘和实收数量漂移均不成为可拣库存。 */
@Test void unreceivedOrChangedPlanHasNoPositions() throws Exception {
var f=new Fixture();var row=f.f.candidates.get(1L);
row.sourceStage("PLAN");assertThat(f.read().items().get(0).positions()).isEmpty();
row.sourceStage("RECEIVED");row.packageCount(2);assertThat(f.read().items().get(0).positions()).isEmpty();
row.packageCount(3);row.overseasWarehouseId("99");assertThat(f.read().items().get(0).positions()).isEmpty();
row.overseasWarehouseId("9");row.customerId("other");assertThat(f.read().items().get(0).positions()).isEmpty();
verify(f.sources,never()).stock(anyString(),anyLong(),anyBoolean());
}
/** 原计划不能吸收新的订单或未经原车审核的同托来源。 */
@Test void changedOrdersOrAdditionalPalletSourceBlocksFirstPick() throws Exception {
var f=new Fixture();
when(f.sources.sourceOrders("101",501L,1L)).thenReturn(List.of(9001L,9002L));
assertThat(f.read().items().get(0).positions()).isEmpty();
when(f.sources.sourceOrders("101",501L,1L)).thenReturn(List.of(9001L));
when(f.rules.selectionGroup(anyString(),any())).thenReturn(new DispatchPlanningModels.SelectionGroup(
"group","version",List.of(new DispatchPlanningModels.SelectionGroupSource(
null,"PLAN:2","202","其他客户","INBOUND_PLAN","PLAN-2",1)),List.of(),true,List.of()));
assertThat(f.read().items().get(0).positions()).isEmpty();
verify(f.sources,never()).stock(anyString(),anyLong(),anyBoolean());
}
/** 库存移动或数量变化不能用原计划数量伪造首拣仓位。 */
@Test void actualStockMustMatchWarehousePalletAndQuantity() throws Exception {
var f=new Fixture();
for(var stock:List.of(new Stock(11,21,501L,"99",3,0,0,0,1),
new Stock(11,21,502L,"9",3,0,0,0,1),new Stock(11,21,501L,"9",2,0,0,0,1))) {
when(f.sources.stock("101",501L,false)).thenReturn(List.of(stock));
assertThat(f.read().items().get(0).positions()).isEmpty();
}
}
/** 复用真实排车及PDA Mapper,模拟原计划来源快照。 */
private static final class Fixture {
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
final DispatchSourceRepository sources=mock(DispatchSourceRepository.class);
final DispatchService rules=mock(DispatchService.class);
final DispatchPdaQueryService queries;final String number;
/** 原车已审核,只清除自然收货尚未补写的实托字段。 */
Fixture() throws Exception {
var selection=f.source(1,"101",501,"ONT8",3);
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("plan-pda-read",List.of(selection))));
f.approve(created);number=created.dispatchNo();
f.db.update("UPDATE ll_dispatch_item SET warehouse_pallet_id=NULL,source_stage='PLAN'");
f.db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(64),balance_id BIGINT,pallet_id BIGINT,overseas_warehouse_id VARCHAR(64),available_packages BIGINT,hold_packages BIGINT,allocated_packages BIGINT,picked_packages BIGINT)");
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(DispatchWholePdaReadMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(f.db.getDataSource());factory.setConfiguration(config);factory.afterPropertiesSet();
queries=new DispatchPdaQueryService(new DispatchPdaReadRepository(new SqlSessionTemplate(factory.getObject()).getMapper(DispatchWholePdaReadMapper.class)),f.service);
ReflectionTestUtils.setField(queries,"sources",sources);ReflectionTestUtils.setField(queries,"sourceRules",rules);
when(sources.selectedPlan("101",1L)).thenReturn(List.of(f.candidates.get(1L)));
when(sources.sourceOrders("101",null,1L)).thenReturn(List.of(9001L));
when(sources.sourceOrders("101",501L,1L)).thenReturn(List.of(9001L));
when(sources.stock("101",501L,false)).thenReturn(List.of(new Stock(11,21,501L,"9",3,0,0,0,1)));
when(rules.selectionGroup(anyString(),any())).thenReturn(new DispatchPlanningModels.SelectionGroup(
"group","version",List.of(new DispatchPlanningModels.SelectionGroupSource(
selection,"PLAN:1","101","测试客户","INBOUND_PLAN","PLAN-1",3)),List.of(),true,List.of()));
}
/** 读取正式PDA入口。 */
DispatchViews.PdaTask read(){return f.tx.execute(s->queries.task(new DispatchContracts.Actor("picker",ActorMode.PDA,"9"),number));}
/** 比较持久化原明细与主单版本。 */
Object dbState(){return List.of(f.db.queryForList("SELECT * FROM ll_dispatch_item"),f.db.queryForList("SELECT record_version FROM ll_dispatch_order"));}
}
}
@@ -58,12 +58,16 @@ class DispatchServiceTest {
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);
assertThatThrownBy(()->service.candidatePage("operator",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"PLAN","foreign",1,20,"ALL"))).hasMessageContaining("草稿");verifyNoInteractions(sources);
}
@Test void wholeCandidatesPreserveActualCustomersAndDoNotChooseAHeaderOwner(){
var query=new DispatchPlanningModels.CandidateQuery("9",null,null,null,"PLAN",null);
when(sources.availablePlans(query)).thenReturn(List.of(row(2,502,"102"),row(1,501,"101")));
assertThat(service.wholeCandidates("operator",query)).extracting(DispatchPalletCandidate::customerId).containsExactly("101","102");
@Test void candidatePagePreservesDatabaseOrderAndDoesNotReadOtherRows(){
var query=new DispatchPlanningModels.CandidateQuery("9",null,null,null,"PLAN",null,2,20,"ALL");
when(sources.candidateCount(query)).thenReturn(55L);
when(sources.candidatePage(query)).thenReturn(List.of(row(1,501,"101"),row(2,502,"102")));
var page=service.candidatePage("operator",query);
assertThat(page.total()).isEqualTo(55);
assertThat(page.rows()).extracting(DispatchPalletCandidate::customerId).containsExactly("101","102");
verify(sources).candidateCount(query);verify(sources).candidatePage(query);verifyNoMoreInteractions(sources);
}
@Test void duplicateAndPartialRelabelSelectionsAreRejectedBeforeMutation(){
var app=new RelabelApplication();app.setCustomerId("101");app.setRelabelNo("RL-1");app.setStatus("READY_FOR_OUTBOUND");app.setDeliveryMethod("TRUCK");app.setFulfillmentStatus("WAIT_PRE_DISPATCH");
@@ -26,6 +26,23 @@ import static org.mockito.Mockito.*;
/** 真实MyBatis、批次行锁、逐客户分配与回滚;原批次和来源读取边界由当前DB事实供给。 */
class AppointmentWorkDispatchPersistenceTest {
/** 自动预留托盘与已实收托盘分开取数,防止有预约的在途货物被零实收数量挡住。 */
@Test void reservedPalletKeepsPlannedQuantityInBatchReferencesUntilReceivingStarts() {
jdbc.update("UPDATE ll_inbound_plan_pallet SET assignment_status='RESERVED',actual_package_count=0 WHERE id=111");
jdbc.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=0 WHERE pallet_id=111");
var physical = List.of(new AppointmentWorkDispatchPort.CandidatePhysical("101",111L));
assertEquals("PLAN", mapper.candidateSources(physical).get(0).sourceStage());
assertEquals("PLAN", mapper.candidates(1L).stream().filter(row -> row.planPalletId()==111).findFirst().orElseThrow().sourceStage());
var planned = candidates.batchReferences(physical).get(physical.get(0));
assertTrue(planned.blockReasons().isEmpty());
assertEquals(80, planned.appointmentBatches().get(0).sources().get(0).packageCount());
jdbc.update("UPDATE ll_inbound_plan_pallet SET actual_package_count=1 WHERE id=111");
assertEquals("RECEIVED", mapper.candidateSources(physical).get(0).sourceStage());
assertFalse(candidates.batchReferences(physical).get(physical.get(0)).blockReasons().isEmpty());
jdbc.update("UPDATE ll_inbound_plan_pallet SET assignment_status='ASSIGNED',actual_package_count=80 WHERE id=111");
jdbc.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=80 WHERE pallet_id=111");
assertTrue(candidates.batchReferences(physical).get(physical.get(0)).blockReasons().isEmpty());
}
private JdbcTemplate jdbc;
private TransactionTemplate tx;
private AppointmentWorkDispatchPort binding;
@@ -46,7 +63,7 @@ class AppointmentWorkDispatchPersistenceTest {
jdbc.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT PRIMARY KEY,appointment_no VARCHAR(40),version INT,replacement_keys_json VARCHAR(200))");
jdbc.execute("CREATE TABLE ll_inbound_fba_appointment_item(appointment_id BIGINT,order_id BIGINT,customer_id VARCHAR(40),source_key VARCHAR(60),fba_warehouse_code VARCHAR(40),package_count INT,active INT)");
jdbc.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY,plan_no VARCHAR(40))");
jdbc.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT PRIMARY KEY,pallet_no VARCHAR(40),plan_id BIGINT,customer_id VARCHAR(40),warehouse_pallet_id BIGINT)");
jdbc.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT PRIMARY KEY,pallet_no VARCHAR(40),plan_id BIGINT,customer_id VARCHAR(40),warehouse_pallet_id BIGINT,assignment_status VARCHAR DEFAULT 'ASSIGNED',actual_package_count INT DEFAULT 1)");
jdbc.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT PRIMARY KEY,order_id BIGINT,customer_id VARCHAR(40),destination_type VARCHAR(30),fba_warehouse_code VARCHAR(40))");
jdbc.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT,customer_id VARCHAR(40),planned_package_count INT,counted_package_count INT)");
jdbc.execute("CREATE TABLE ll_dispatch_pallet_lock(id BIGINT PRIMARY KEY,plan_pallet_id BIGINT,warehouse_pallet_id BIGINT,status VARCHAR(30),dispatch_order_id BIGINT,expires_at TIMESTAMP)");
@@ -67,7 +84,7 @@ class AppointmentWorkDispatchPersistenceTest {
/** 插入真实客户、托盘和逐票数量,不从请求猜测。 */
private void seed(long item,String customer,long order,int count,String destination) {
jdbc.update("INSERT INTO ll_dispatch_item VALUES(?,1,?,?,'RECEIVED',?)",item,customer,item+100,count);
jdbc.update("INSERT INTO ll_inbound_plan_pallet VALUES(?,?,1,?,?)",item+100,"P-"+item,customer,item+1000);
jdbc.update("INSERT INTO ll_inbound_plan_pallet(id,pallet_no,plan_id,customer_id,warehouse_pallet_id) VALUES(?,?,1,?,?)",item+100,"P-"+item,customer,item+1000);
jdbc.update("INSERT INTO ll_inbound_plan_line VALUES(?,?,?,?, 'ONT8')",item,order,customer,destination);
jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,?,?,?,?)",item+100,item,customer,count,count);
}