优化
This commit is contained in:
+57
-6
@@ -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", "> 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(">", ">").replace("<", "<");
|
||||
@@ -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 > 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)),'')"
|
||||
|
||||
+29
-5
@@ -1,6 +1,10 @@
|
||||
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.*;
|
||||
@@ -18,22 +22,29 @@ class DispatchNumberSequenceTest {
|
||||
final JdbcTemplate jdbc;
|
||||
final DataSourceTransactionManager transactions;
|
||||
final DispatchNumberSequence sequence;
|
||||
Fixture() {
|
||||
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 proxy=new ProxyFactory(new DispatchNumberSequence(jdbc));proxy.setProxyTargetClass(true);
|
||||
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() {
|
||||
/** 每次业务事务独立取号,同一天连续增长,下一天从一开始。 */
|
||||
@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 {
|
||||
@@ -43,17 +54,30 @@ class DispatchNumberSequenceTest {
|
||||
assertThat(values.last()).isEqualTo(64L);
|
||||
} finally {pool.shutdownNow();}
|
||||
}
|
||||
@Test void outerBusinessRollbackDoesNotReuseAnAlreadyAllocatedNumber() {
|
||||
/** 外层建单回滚不能回滚独立取号事务,也不能复用已分配号。 */
|
||||
@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() {
|
||||
/** 四位上限拒绝分配,并回滚本次递增,保持当日已用尽事实。 */
|
||||
@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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+28
-1
@@ -148,6 +148,33 @@ class DispatchMergedRelabelPersistenceTest {
|
||||
var created=f.create("actual-plan",group.sources().stream().map(s->s.selection()).toList());assertThat(created.counts().palletCount()).isEqualTo(1);assertThat(created.counts().packageCount()).isEqualTo(4);
|
||||
}
|
||||
|
||||
/** 无效实托不成为候选或跨申请扩组锚点;同申请缺失的组成仍明确阻断建单。 */
|
||||
@ParameterizedTest @ValueSource(longs={0,-1})
|
||||
void invalidPalletIdentityDoesNotCrashOrConnectUnrelatedApplications(Long invalidPallet) throws Exception {
|
||||
var f=new Merged(false);
|
||||
for(long id:List.of(102L,103L))f.insert("ll_inventory_position","id",id,"customer_id","101","balance_id",601,
|
||||
"overseas_warehouse_id","9","pallet_id",invalidPallet,"pallet_no","","batch_no","MISSING-"+id,
|
||||
"warehouse_location_code","LOC-1","hold_packages",1);
|
||||
f.insert("ll_relabel_item","id",303,"application_id",201,"customer_id","101","product_name_zh","待核对来源",
|
||||
"total_package_count",1,"notified_package_count",1,"carton_mark","OLD-MISSING","fba_carton_mark","NEW-MISSING",
|
||||
"operation_type","CARTON_LABEL","source_inventory_balance_id",601,"source_inventory_position_id",102,
|
||||
"source_inbound_order_id",7201,"source_inbound_order_no","ORIGIN-MISSING","source_inbound_code","CODE-MISSING",
|
||||
"destination_fba_warehouse_code","ONT8");
|
||||
f.application(203,"RL-UNRELATED","101",9003,304,103,1,"UNRELATED");
|
||||
var candidates=f.rules.wholeCandidates("platform",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"RELABEL",null));
|
||||
var groups=f.rules.selectionGroups(candidates);
|
||||
assertThat(candidates).hasSize(2).allSatisfy(row->assertThat(row.warehousePalletId()).isEqualTo(501L));
|
||||
var group=groups.get("RELABEL:RL-A:501");
|
||||
assertThat(group.complete()).isFalse();
|
||||
assertThat(group.blockReasons()).anyMatch(reason->reason.contains("RL-A")&&reason.contains("明细当前位置缺失"));
|
||||
assertThat(group.sources()).extracting(DispatchPlanningModels.SelectionGroupSource::sourceBusinessNo)
|
||||
.containsExactlyInAnyOrder("RL-A","RL-B");
|
||||
assertThat(group.pallets()).hasSize(1);
|
||||
assertThatThrownBy(()->f.create("invalid-pallet",group.sources().stream().map(s->s.selection()).toList()))
|
||||
.isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
|
||||
assertThat(f.count("ll_dispatch_order")).isZero();assertThat(f.count("ll_dispatch_pallet_lock")).isZero();
|
||||
}
|
||||
|
||||
/** 只隔离测量和车队外部目录;来源提供者、候选、库存与所有排车Mapper均为实际实现。 */
|
||||
private static final class Merged {
|
||||
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
@@ -163,7 +190,7 @@ 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")) {
|
||||
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不同;保留实际列和等价四来源排他约束。
|
||||
|
||||
+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));
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.baiyue.lastleg.fleet.outsourcedbusiness;
|
||||
|
||||
import com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels;
|
||||
import com.baiyue.lastleg.fleet.outsourcedbusiness.controller.OutsourcedBusinessController;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import java.util.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** HTTP公开JSON精简及拒绝行为,防止仅页面隐藏不支持字段。 */
|
||||
class OutsourcedBusinessContractTest {
|
||||
/** 即使全局允许未知字段,本域仍明确拒绝油耗、里程及客户端归属。 */
|
||||
@Test void unsupportedOperationsAreRejectedEvenWithLenientGlobalJackson() throws Exception {
|
||||
var json=new ObjectMapper().findAndRegisterModules().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
|
||||
String body="{\"recordVersion\":\"0\",\"idempotencyKey\":\"test-original-key\",\"operations\":{\"money\":null,\"arrivals\":[]}}";
|
||||
assertNotNull(json.readValue(body,OutsourcedBusinessModels.SaveOperations.class));
|
||||
for(String field:List.of("actualDistanceMiles","fuelConsumptionLitresPer100Km","actualFuelLitres","ownership","scope")) {
|
||||
String extra=body.replace("\"arrivals\":[]","\"arrivals\":[],\""+field+"\":null");
|
||||
assertThrows(JsonProcessingException.class,()->json.readValue(extra,OutsourcedBusinessModels.SaveOperations.class),field);
|
||||
}
|
||||
assertThrows(JsonProcessingException.class,()->json.readValue(body.replace("\"recordVersion\":\"0\"","\"recordVersion\":\"0\",\"scope\":\"OWNED\""),OutsourcedBusinessModels.SaveOperations.class));
|
||||
assertThrows(JsonProcessingException.class,()->json.readValue("{\"recordVersion\":\"0\",\"idempotencyKey\":\"bind-original\",\"fileId\":\"9\",\"ownership\":\"OWNED\"}",OutsourcedBusinessModels.BindPhoto.class));
|
||||
}
|
||||
/** 外配输出没有油耗字段和摘要,自有原JSON字段继续存在。 */
|
||||
@Test void outsourcedSerializationIsLeanAndOwnedPublicShapeRemains() throws Exception {
|
||||
var json=new ObjectMapper().findAndRegisterModules();
|
||||
var output=json.valueToTree(new OutsourcedBusinessModels.Operations(new OwnedBusinessModels.Money("USD","0"),List.of()));
|
||||
assertEquals(2,output.size());assertTrue(output.has("money"));assertTrue(output.has("arrivals"));
|
||||
assertFalse(output.has("actualDistanceMiles"));assertFalse(output.has("fuelConsumptionLitresPer100Km"));
|
||||
assertEquals(1,json.valueToTree(new OutsourcedBusinessModels.PhotoSummary(5)).size());
|
||||
var external=json.valueToTree(new OutsourcedBusinessModels.Vehicle(null,"TEST-EXT","DISPATCHED",null));
|
||||
assertTrue(external.get("vehicleId").isNull());assertTrue(external.get("carrierName").isNull());assertEquals(4,external.size());
|
||||
var owned=json.valueToTree(new OwnedBusinessModels.Operations(null,null,null,List.of()));
|
||||
assertTrue(owned.has("actualDistanceMiles"));assertTrue(owned.has("fuelConsumptionLitresPer100Km"));
|
||||
assertEquals(3,json.valueToTree(new OwnedBusinessModels.Vehicle("7","TEST-OWN","OWNED")).size());
|
||||
}
|
||||
/** 油耗框不是外配合法URL枚举,不能先上传再在页面隐藏。 */
|
||||
@Test void onlyFiveLoadingPhotoSlotsCanDeserialize() throws Exception {
|
||||
var json=new ObjectMapper();assertEquals(5,OutsourcedBusinessModels.PhotoSlot.values().length);
|
||||
for(var slot:OutsourcedBusinessModels.PhotoSlot.values())assertEquals(slot,json.readValue("\""+slot.name()+"\"",OutsourcedBusinessModels.PhotoSlot.class));
|
||||
assertThrows(JsonProcessingException.class,()->json.readValue("\"FUEL_START\"",OutsourcedBusinessModels.PhotoSlot.class));
|
||||
assertThrows(JsonProcessingException.class,()->json.readValue("\"FUEL_END\"",OutsourcedBusinessModels.PhotoSlot.class));
|
||||
}
|
||||
/** 八类HTTP操作各用外配真实权限,不将外配权限伪装为自有。 */
|
||||
@Test void controllerAlwaysRequiresTheActualOutsourcedPermission() {
|
||||
int count=0;
|
||||
for(var method:OutsourcedBusinessController.class.getDeclaredMethods()) {
|
||||
var authorization=method.getAnnotation(PreAuthorize.class);if(authorization==null)continue;count++;
|
||||
String action=switch(method.getName()){case "save"->"edit";case "upload","bind"->"upload";default->"list";};
|
||||
assertEquals("hasAuthority('platform:fleet:outsourced-business:"+action+"')",authorization.value());
|
||||
assertTrue(Arrays.stream(method.getParameterTypes()).noneMatch(p->p.getSimpleName().equals("OwnedBusinessScope")));
|
||||
}
|
||||
assertEquals(8,count);
|
||||
}
|
||||
}
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
package com.baiyue.lastleg.fleet.ownedbusiness;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchDetailReader;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchViews;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchTypes;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.OwnedBusinessFileGateway;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.service.*;
|
||||
import com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels;
|
||||
import com.baiyue.lastleg.fleet.outsourcedbusiness.service.OutsourcedBusinessService;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.springframework.aop.framework.ProxyFactory;
|
||||
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
|
||||
import org.springframework.transaction.interceptor.TransactionInterceptor;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 复用既有四表测试装配,实际Mapper、事务、权限与原键一并验收。 */
|
||||
class OwnedBusinessScopePersistenceTest {
|
||||
final OwnedBusinessPersistenceTest baseline=new OwnedBusinessPersistenceTest();
|
||||
OwnedBusinessQueryService queries;
|
||||
OwnedBusinessRecordService records;
|
||||
OwnedBusinessPhotoService photos;
|
||||
OwnedBusinessFileGateway files;
|
||||
OutsourcedBusinessService outsourced;
|
||||
final Map<Long,OwnedBusinessFileGateway.Evidence> uploaded=new HashMap<>();
|
||||
final byte[] png={(byte)137,80,78,71,13,10,26,10,0};
|
||||
/** 只调用原测试的建库装配,不继承或重复运行其@Test。 */
|
||||
@BeforeEach void setup() throws Exception {
|
||||
baseline.setup();var reader=mock(DispatchDetailReader.class);
|
||||
when(reader.detail(any(),anyString())).thenAnswer(call->{
|
||||
var s=baseline.repository.source(call.getArgument(1));var r=baseline.codec.read(s.getResourcesJson(),DispatchContracts.Resources.class);
|
||||
return new DispatchViews.Detail(s.getDispatchNo(),DispatchTypes.Status.ACTIVE,DispatchTypes.Stage.valueOf(s.getStage()),s.getRecordVersion(),
|
||||
DispatchTypes.ReviewState.APPROVED,null,null,true,Map.of(),new DispatchViews.VehicleInfo(r.vehicle(),r.driver()),null,null,List.of());
|
||||
});
|
||||
queries=tx(new OwnedBusinessQueryService(baseline.repository,baseline.projection,baseline.codec,reader));
|
||||
var commands=tx(new OwnedBusinessCommandService(baseline.repository,baseline.codec));
|
||||
records=tx(new OwnedBusinessRecordService(baseline.repository,queries,baseline.projection,commands,baseline.codec));
|
||||
files=mock(OwnedBusinessFileGateway.class);var sequence=new AtomicLong(900);
|
||||
when(files.upload(anyLong(),any(),anyString(),any())).thenAnswer(call->{
|
||||
long id=sequence.incrementAndGet();var evidence=new OwnedBusinessFileGateway.Evidence(id,"TEST-"+id+".png","image/png",png.length,"2026-10-20T08:00:00Z");uploaded.put(id,evidence);return evidence;
|
||||
});
|
||||
when(files.metadata(anyLong(),any(),anyLong())).thenAnswer(call->uploaded.get(call.<Long>getArgument(2)));
|
||||
when(files.content(anyLong(),any(),anyLong())).thenReturn(png);
|
||||
photos=tx(new OwnedBusinessPhotoService(queries,records,baseline.repository,commands,baseline.projection,baseline.codec,files));
|
||||
outsourced=new OutsourcedBusinessService(queries,records,photos,baseline.projection,baseline.codec);
|
||||
insert(2,"MASTER","AFTER_LOADING","TEST原承运商");insert(3,"EXTERNAL","DELIVERING",null);insert(4,"MASTER","DELIVERED","TEST另一承运商");
|
||||
}
|
||||
/** 数据库仅在本机隔离H2,用后关闭。 */
|
||||
@AfterEach void close(){baseline.closeDatabase();}
|
||||
/** 所有被测写路径都走真实注解事务,不以内存集合替代Mapper。 */
|
||||
@SuppressWarnings("unchecked") private <T>T tx(T target) {
|
||||
var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true);
|
||||
proxy.addAdvice(new TransactionInterceptor(baseline.manager,new AnnotationTransactionAttributeSource()));return (T)proxy.getProxy();
|
||||
}
|
||||
/** 两类外配身份固定原快照,实际托盘号和两个客户仍逐项保留。 */
|
||||
private void insert(long id,String mode,String stage,String carrier) {
|
||||
var original=OwnedBusinessFixtures.source();
|
||||
var resources=baseline.codec.read(original.getResourcesJson(),DispatchContracts.Resources.class);
|
||||
var vehicle=new DispatchContracts.VehicleSnapshot(DispatchTypes.ResourceMode.valueOf(mode),"MASTER".equals(mode)?7L:null,"TEST-OUT-"+id,carrier,"IN_SERVICE",resources.vehicle().capacity(),List.of());
|
||||
String snapshot=baseline.codec.write(new DispatchContracts.Resources(vehicle,resources.driver()));
|
||||
String load=original.getLoadingConfirmationJson().replace("\"warehousePalletId\":31","\"warehousePalletId\":"+(31+id));
|
||||
baseline.jdbc.update("""
|
||||
INSERT INTO ll_dispatch_order SELECT ?,?,trip_id,status,?,record_version,planned_departure_at,loading_completed_at,departed_at,delivery_completed_at,
|
||||
loading_completion_hash,?,?,?,overseas_warehouse_id,overseas_warehouse_code,overseas_warehouse_name FROM ll_dispatch_order WHERE id=1
|
||||
""",id,"DS-OUT-"+id,stage,load,snapshot,"TEST-OUT-"+id);
|
||||
baseline.jdbc.update("""
|
||||
INSERT INTO ll_dispatch_item SELECT id+?, ?,customer_id,customer_name,source_key,order_no,inbound_code,?, ?,destination_type,
|
||||
destination_warehouse_code,destination_warehouse_name,fba_warehouse_code,delivery_address,postal_code,package_count FROM ll_dispatch_item WHERE dispatch_order_id=1
|
||||
""",id*100,id,"PAL-"+(31+id),31+id);
|
||||
var ownership=new OwnedBusinessRows.Ownership();ownership.setDispatchId(id);ownership.setVehicleId(vehicle.vehicleId());
|
||||
ownership.setResourceMode(mode);ownership.setVehicleOwnership("DISPATCHED");ownership.setVehicleRecordVersion(vehicle.vehicleId()==null?null:3L);
|
||||
ownership.setLoadingCompletionHash(original.getLoadingCompletionHash());ownership.setLoadingCompletedAt(original.getLoadingCompletedAt());ownership.setOwnershipCapturedAt(original.getLoadingCompletedAt());baseline.repository.capture(ownership);
|
||||
baseline.jdbc.update("INSERT INTO ll_dispatch_history VALUES(?,?,8,'LOADING_COMPLETE','TEST-picker',NULL,'{}',?)",100+id,id,original.getLoadingCompletedAt());
|
||||
if(!"AFTER_LOADING".equals(stage))baseline.jdbc.update("UPDATE ll_dispatch_order SET departed_at=? WHERE id=?",original.getLoadingCompletedAt().plusHours(1),id);
|
||||
if("DELIVERED".equals(stage))baseline.jdbc.update("UPDATE ll_dispatch_order SET delivery_completed_at=? WHERE id=?",original.getLoadingCompletedAt().plusHours(2),id);
|
||||
}
|
||||
/** 外配真实三权限,与自有集合完全不相交。 */
|
||||
private OwnedBusinessModels.Actor actor(){return new OwnedBusinessModels.Actor("outsourced-operator","TEST外配操作员",Set.of(OutsourcedBusinessModels.LIST,OutsourcedBusinessModels.EDIT,OutsourcedBusinessModels.UPLOAD));}
|
||||
/** 请求只包含金额和本车目的地到达,版本与原键逐次显式提供。 */
|
||||
private OutsourcedBusinessModels.SaveOperations request(String version,String key,OwnedBusinessModels.Money money,List<OwnedBusinessModels.Arrival> arrivals) {
|
||||
return new OutsourcedBusinessModels.SaveOperations(version,key,new OutsourcedBusinessModels.Operations(money,arrivals),"本机TEST承运登记");
|
||||
}
|
||||
/** 真实SQL统计跨页且按归属、阶段、日期、车牌和目的仓共同过滤。 */
|
||||
@Test void fullSummaryAndFilterUseOneFrozenScope() {
|
||||
var query=new OwnedBusinessModels.Query(1,1,null,null,null,null,"9");var result=outsourced.list(actor(),query);
|
||||
assertEquals(1,result.records().size());assertEquals(3,result.total());assertEquals(new OwnedBusinessModels.Summary(3,1,1,1),result.summary());
|
||||
assertEquals(1,queries.list(OwnedBusinessFixtures.actor(),query).total());
|
||||
assertEquals(1,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,null,null,"TEST-OUT-3",null,"9")).total());
|
||||
assertEquals(3,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,null,null,"ONT8",null,"9")).total());
|
||||
assertEquals(0,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,null,null,null,null,"other")).total());
|
||||
assertEquals(0,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,null,"2026-10-20T08:00:00Z",null,null,"9")).total());
|
||||
for(var stage:OwnedBusinessModels.Stage.values())assertEquals(1,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,"2026-10-20T08:00:00Z","2026-10-21T08:00:00Z",null,stage,"9")).total());
|
||||
}
|
||||
/** 外配档案和临时车辆分别保持真实ID/空ID及原承运商,GET不建记录。 */
|
||||
@Test void masterAndExternalReadOriginalSnapshotWithoutBackfill() {
|
||||
var master=outsourced.detail(actor(),"DS-OUT-2");var external=outsourced.detail(actor(),"DS-OUT-3");
|
||||
assertEquals("7",master.row().vehicle().vehicleId());assertEquals("TEST原承运商",master.row().vehicle().carrierName());
|
||||
assertNull(external.row().vehicle().vehicleId());assertNull(external.row().vehicle().carrierName());
|
||||
assertEquals("DISPATCHED",external.row().vehicle().ownership());assertEquals("0",master.row().recordVersion());
|
||||
assertEquals(2,master.cargo().size());assertEquals(1,master.row().actualPalletCount());assertTrue(master.row().actions().edit().allowed());
|
||||
assertEquals(0,baseline.jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class));
|
||||
baseline.jdbc.update("DELETE FROM ll_fleet_owned_business_ownership WHERE dispatch_id=2");
|
||||
assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-2"));assertEquals(2,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,null,null,null,null,null)).total());assertNull(baseline.repository.ownership(2));
|
||||
}
|
||||
/** 实际服务权限与原归属双向隔离,不允许靠共享四表越权。 */
|
||||
@Test void bothPermissionAndDispatchScopeAreRequiredOnReadWriteAndCommand() {
|
||||
assertThrows(ResponseStatusException.class,()->outsourced.detail(OwnedBusinessFixtures.actor(),"DS-OUT-2"));
|
||||
assertThrows(ResponseStatusException.class,()->queries.detail(actor(),"DS-OWN-ONE"));
|
||||
assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OWN-ONE"));
|
||||
assertThrows(BizException.class,()->queries.detail(OwnedBusinessFixtures.actor(),"DS-OUT-2"));
|
||||
assertThrows(BizException.class,()->outsourced.save(actor(),"DS-OWN-ONE",request("0","foreign-scope-save",null,List.of())));
|
||||
assertThrows(BizException.class,()->outsourced.command(actor(),"DS-OWN-ONE","foreign-scope-key"));
|
||||
assertThrows(BizException.class,()->photos.command(OwnedBusinessFixtures.actor(),"DS-OUT-2","foreign-scope-key"));
|
||||
assertThrows(ResponseStatusException.class,()->outsourced.save(OwnedBusinessFixtures.actor(),"DS-OUT-2",request("0","wrong-permission-write",null,List.of())));
|
||||
var all=new HashSet<>(actor().permissions());all.addAll(OwnedBusinessFixtures.actor().permissions());
|
||||
var both=new OwnedBusinessModels.Actor("both-scopes","TEST双权限操作员",all);
|
||||
assertThrows(BizException.class,()->outsourced.detail(both,"DS-OWN-ONE"));assertThrows(BizException.class,()->queries.detail(both,"DS-OUT-2"));
|
||||
assertThrows(BizException.class,()->records.save(both,"DS-OUT-2",new OwnedBusinessModels.SaveOperations("0","wrong-owned-write",new OwnedBusinessModels.Operations(null,null,null,List.of()),null)));
|
||||
assertEquals(0,baseline.jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_command",Integer.class));
|
||||
assertEquals(0,baseline.jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class));
|
||||
}
|
||||
/** 多客户同一物理托盘只除一次,逐目的地登记、零值与NULL清空都走真实CAS。 */
|
||||
@Test void samePhysicalPalletMultipleDestinationsAndMoneyClearUseRealCas() {
|
||||
baseline.jdbc.update("UPDATE ll_dispatch_item SET destination_warehouse_code='LAX9' WHERE id=212");
|
||||
var arrivals=List.of(new OwnedBusinessModels.Arrival("FBA:ONT8","2026-10-20T08:10:00Z"),new OwnedBusinessModels.Arrival("FBA:LAX9","2026-10-20T08:20:00Z"));
|
||||
var body=request("0","carrier-amount-original",new OwnedBusinessModels.Money("CNY","60"),arrivals);
|
||||
var first=outsourced.save(actor(),"DS-OUT-2",body);
|
||||
assertEquals("60.00",first.row().unitPrice());assertEquals(2,first.row().destinations().size());assertTrue(first.row().destinations().stream().allMatch(d->d.actualArrivedAt()!=null));
|
||||
assertEquals(first,outsourced.save(actor(),"DS-OUT-2",body));
|
||||
assertEquals(first,outsourced.command(actor(),"DS-OUT-2",body.idempotencyKey()).result());
|
||||
var stale=assertThrows(BizException.class,()->outsourced.save(actor(),"DS-OUT-2",request("0","carrier-amount-stale",null,List.of())));assertEquals(ResultCode.CONFLICT,stale.getResultCode());
|
||||
assertThrows(BizException.class,()->outsourced.save(actor(),"DS-OUT-2",request("0",body.idempotencyKey(),new OwnedBusinessModels.Money("USD","0"),arrivals)));
|
||||
var zero=outsourced.save(actor(),"DS-OUT-2",request("1","carrier-amount-zero",new OwnedBusinessModels.Money("USD","0"),arrivals));
|
||||
assertEquals("0.00",zero.row().unitPrice());assertEquals("USD",zero.row().operations().money().currency());
|
||||
var cleared=outsourced.save(actor(),"DS-OUT-2",request("2","carrier-amount-clear",null,List.of()));
|
||||
assertNull(cleared.row().operations().money());assertNull(cleared.row().unitPrice());assertTrue(cleared.row().destinations().stream().allMatch(d->d.actualArrivedAt()==null));
|
||||
assertNull(baseline.repository.record(2).getCurrency());assertNull(baseline.repository.record(2).getActualDistanceMiles());assertNull(baseline.repository.record(2).getFuelConsumptionLitresPer100Km());
|
||||
assertEquals(8L,baseline.repository.source("DS-OUT-2").getRecordVersion());
|
||||
}
|
||||
/** 油耗及里程在共享服务再次拒绝,不能绕过外配DTO直接调用内部入口。 */
|
||||
@Test void unsupportedFuelAndForeignDestinationAreRejectedBeforeAnyWrite() {
|
||||
var body=new OwnedBusinessModels.SaveOperations("0","illegal-fuel-write",new OwnedBusinessModels.Operations(null,"1",null,List.of()),null);
|
||||
assertThrows(BizException.class,()->records.save(actor(),"DS-OUT-2",body,OwnedBusinessScope.OUTSOURCED));
|
||||
var fuel=new OwnedBusinessModels.SaveOperations("0","illegal-fuel-rate",new OwnedBusinessModels.Operations(null,null,"1",List.of()),null);
|
||||
assertThrows(BizException.class,()->records.save(actor(),"DS-OUT-2",fuel,OwnedBusinessScope.OUTSOURCED));
|
||||
assertThrows(BizException.class,()->photos.upload(actor(),"DS-OUT-2",OwnedBusinessModels.PhotoSlot.FUEL_START,"illegal-fuel-photo",new MockMultipartFile("file",png),OwnedBusinessScope.OUTSOURCED));
|
||||
assertThrows(BizException.class,()->outsourced.save(actor(),"DS-OUT-2",request("0","illegal-destination",null,List.of(new OwnedBusinessModels.Arrival("FBA:OTHER",null)))));
|
||||
assertThrows(BizException.class,()->outsourced.save(actor(),"DS-OUT-2",request("0","duplicate-destination",null,List.of(new OwnedBusinessModels.Arrival("FBA:ONT8",null),new OwnedBusinessModels.Arrival("FBA:ONT8",null)))));
|
||||
assertEquals(0,baseline.jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class));verifyNoInteractions(files);
|
||||
}
|
||||
/** 五框上传/绑定/原键恢复及历史读取保持同车原引用,异账号不能复用。 */
|
||||
@Test void fivePhotosReplayHistoryAndActorIsolationDoNotChangeDispatch() {
|
||||
String version="0",firstFile=null;int index=0;
|
||||
for(var slot:OutsourcedBusinessModels.PhotoSlot.values()) {
|
||||
var file=new MockMultipartFile("file","TEST.png","image/png",png);String uploadKey="out-upload-"+slot;
|
||||
var photo=outsourced.upload(actor(),"DS-OUT-2",slot,uploadKey,file);assertNull(photo.savedAt());
|
||||
assertEquals(photo,outsourced.upload(actor(),"DS-OUT-2",slot,uploadKey,file));
|
||||
assertEquals(version,outsourced.detail(actor(),"DS-OUT-2").row().recordVersion());
|
||||
var binding=new OutsourcedBusinessModels.BindPhoto(version,"out-bind-"+slot,photo.fileId());
|
||||
var bound=outsourced.bind(actor(),"DS-OUT-2",slot,binding);assertEquals(bound,outsourced.bind(actor(),"DS-OUT-2",slot,binding));version=bound.row().recordVersion();
|
||||
if(index++==0)firstFile=photo.fileId();assertArrayEquals(png,outsourced.content(actor(),"DS-OUT-2",slot,Long.parseLong(photo.fileId()),null).content());
|
||||
}
|
||||
assertEquals(5,outsourced.detail(actor(),"DS-OUT-2").row().photoSummary().loadingCount());
|
||||
var replacement=outsourced.upload(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,"out-replacement-upload",new MockMultipartFile("file","TEST.png","image/png",png));
|
||||
outsourced.bind(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,new OutsourcedBusinessModels.BindPhoto(version,"out-replacement-bind",replacement.fileId()));
|
||||
final long oldId=Long.parseLong(firstFile);
|
||||
assertThrows(BizException.class,()->outsourced.content(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,oldId,null));
|
||||
assertArrayEquals(png,outsourced.content(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,oldId,1L).content());
|
||||
var history=outsourced.history(actor(),"DS-OUT-2",1,200);assertEquals(7,history.total());
|
||||
assertTrue(history.records().stream().anyMatch(h->h.eventSource().equals("DISPATCH") && h.recordVersion()==null && h.dispatchRecordVersion().equals("8") && h.operations()==null));
|
||||
assertTrue(history.records().stream().filter(h->h.eventSource().equals("OPERATIONS")).allMatch(h->h.dispatchRecordVersion()==null && h.reason().startsWith("保存")));
|
||||
var other=new OwnedBusinessModels.Actor("other","TEST另一外配账号",actor().permissions());
|
||||
assertThrows(ResponseStatusException.class,()->outsourced.command(other,"DS-OUT-2","out-upload-LOADING_SCENE"));
|
||||
assertThrows(ResponseStatusException.class,()->outsourced.upload(other,"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,"out-upload-LOADING_SCENE",new MockMultipartFile("file","TEST.png","image/png",png)));
|
||||
verify(files,times(6)).upload(anyLong(),any(),anyString(),any());assertEquals(8L,baseline.repository.source("DS-OUT-2").getRecordVersion());
|
||||
}
|
||||
/** File结果未知只保存原意图,任何同键重放不得再次写File。 */
|
||||
@Test void unknownUploadIsRecoverableButNeverAutomaticallyRetried() {
|
||||
when(files.upload(anyLong(),any(),anyString(),any())).thenThrow(new IllegalStateException("本机模拟连接中断"));
|
||||
var file=new MockMultipartFile("file","TEST.png","image/png",png);
|
||||
assertThrows(BizException.class,()->outsourced.upload(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_LIST,"out-upload-unknown",file));
|
||||
assertThrows(BizException.class,()->outsourced.upload(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_LIST,"out-upload-unknown",file));
|
||||
var command=outsourced.command(actor(),"DS-OUT-2","out-upload-unknown");assertEquals("UNKNOWN_RESULT",command.state());assertFalse(command.retryAllowed());assertNull(command.result());
|
||||
assertEquals("OUTSOURCED_UPLOAD_UNKNOWN_RESULT",command.errorCode());verify(files,times(1)).upload(anyLong(),any(),anyString(),any());
|
||||
}
|
||||
/** 原装车ID、模式、hash、时间任何一项不一致都不能进入外配详情。 */
|
||||
@Test void frozenIdentityHashAndTimeCannotBeSubstituted() {
|
||||
baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET vehicle_id=999 WHERE dispatch_id=2");
|
||||
assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-2"));
|
||||
baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET vehicle_id=7,loading_completion_hash='different' WHERE dispatch_id=2");
|
||||
assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-2"));
|
||||
baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET loading_completion_hash='actual-load',loading_completed_at=? WHERE dispatch_id=2",OwnedBusinessFixtures.source().getLoadingCompletedAt().plusSeconds(1));
|
||||
assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-2"));
|
||||
String external=baseline.repository.source("DS-OUT-3").getResourcesJson().replace("\"vehicleId\":null","\"vehicleId\":99");
|
||||
baseline.jdbc.update("UPDATE ll_dispatch_order SET resources_json=? WHERE id=3",external);
|
||||
assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-3"));
|
||||
assertEquals(0,baseline.jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class));
|
||||
}
|
||||
/** 承运金额按两实托HALF_UP两位,零实托不能伪造单价或使用来源条数替代。 */
|
||||
@Test void roundingUsesActualDistinctPalletsAndZeroDenominatorStaysNull() {
|
||||
String load=baseline.repository.source("DS-OUT-2").getLoadingConfirmationJson().replace("\"pallets\":[", "\"pallets\":[{\"warehousePalletId\":99,\"palletSpaces\":5,\"weightKg\":10,\"volumeCbm\":1,\"deliveryIndex\":1},");
|
||||
baseline.jdbc.update("UPDATE ll_dispatch_order SET loading_confirmation_json=? WHERE id=2",load);
|
||||
baseline.jdbc.update("INSERT INTO ll_dispatch_item SELECT id+1000,dispatch_order_id,customer_id,customer_name,CONCAT(source_key,'-2'),order_no,inbound_code,'PAL-99',99,destination_type,destination_warehouse_code,destination_warehouse_name,fba_warehouse_code,delivery_address,postal_code,package_count FROM ll_dispatch_item WHERE id=211");
|
||||
var saved=outsourced.save(actor(),"DS-OUT-2",request("0","out-half-up-rounding",new OwnedBusinessModels.Money("USD","10.01"),List.of()));
|
||||
assertEquals(2,saved.row().actualPalletCount());assertEquals(3,saved.cargo().size());assertEquals("5.01",saved.row().unitPrice());
|
||||
var confirmation=baseline.codec.read(load,DispatchContracts.ConfirmedLoad.class);
|
||||
baseline.jdbc.update("UPDATE ll_dispatch_order SET loading_confirmation_json=? WHERE id=2",baseline.codec.write(new DispatchContracts.ConfirmedLoad(confirmation.manifestHash(),List.of(),confirmation.operatorId(),confirmation.confirmedAt())));
|
||||
var empty=outsourced.detail(actor(),"DS-OUT-2");assertEquals(0,empty.row().actualPalletCount());assertNull(empty.row().unitPrice());assertFalse(empty.row().sourceIssues().isEmpty());
|
||||
}
|
||||
/** 元数据暂不可读时原绑定体和fileId不丢失,恢复后不重复上传。 */
|
||||
@Test void failedBindRetainsOriginalFileAndKeyForExplicitRecovery() {
|
||||
var slot=OutsourcedBusinessModels.PhotoSlot.LOADING_LIST;
|
||||
var uploadedPhoto=outsourced.upload(actor(),"DS-OUT-2",slot,"out-bind-failure-upload",new MockMultipartFile("file","TEST.png","image/png",png));
|
||||
long fileId=Long.parseLong(uploadedPhoto.fileId());
|
||||
when(files.metadata(2,OwnedBusinessModels.PhotoSlot.LOADING_LIST,fileId)).thenThrow(new BizException(ResultCode.CONFLICT,"本机模拟元数据暂不可读")).thenReturn(uploaded.get(fileId));
|
||||
var body=new OutsourcedBusinessModels.BindPhoto("0","out-bind-failure-key",uploadedPhoto.fileId());
|
||||
assertThrows(BizException.class,()->outsourced.bind(actor(),"DS-OUT-2",slot,body));
|
||||
assertEquals("0",outsourced.detail(actor(),"DS-OUT-2").row().recordVersion());
|
||||
var bound=outsourced.bind(actor(),"DS-OUT-2",slot,body);assertEquals(uploadedPhoto.fileId(),bound.photos().get(0).fileId());
|
||||
verify(files,times(1)).upload(anyLong(),any(),anyString(),any());
|
||||
}
|
||||
/** 完整摘要严格匹配,同前缀末位、大小写和尾空格均不能误命中。 */
|
||||
@Test void queryHashComparesCompleteValueRatherThanDefaultBinaryWidth() {
|
||||
String prefix="a".repeat(63),exact=prefix+"1";
|
||||
baseline.jdbc.update("UPDATE ll_dispatch_order SET loading_completion_hash=? WHERE id=1",exact);
|
||||
baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET loading_completion_hash=? WHERE dispatch_id=1",exact);
|
||||
var query=new OwnedBusinessModels.Query(1,20,null,null,null,null,null);
|
||||
assertEquals(1,queries.list(OwnedBusinessFixtures.actor(),query).total());
|
||||
for(String different:List.of(prefix+"2","A"+exact.substring(1),prefix+" ")) {
|
||||
baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET loading_completion_hash=? WHERE dispatch_id=1",different);
|
||||
var result=queries.list(OwnedBusinessFixtures.actor(),query);assertEquals(0,result.total());assertTrue(result.records().isEmpty());
|
||||
}
|
||||
baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET loading_completion_hash=? WHERE dispatch_id=1",exact);
|
||||
assertEquals(1,queries.list(OwnedBusinessFixtures.actor(),query).total());
|
||||
}
|
||||
/** 历史投影统一字符比较规则,完整保留中文、原尾空格、动作和原操作账号。 */
|
||||
@Test void historyCharacterProjectionPreservesOriginalTextAndSnapshot() {
|
||||
// 原测试装配的原生历史采用较短替身列,这里只在隔离H2对齐实际原表上限。
|
||||
baseline.jdbc.execute("ALTER TABLE ll_dispatch_history ALTER COLUMN operator_id VARCHAR(128)");
|
||||
baseline.jdbc.execute("ALTER TABLE ll_dispatch_history ALTER COLUMN reason VARCHAR(2000)");
|
||||
baseline.jdbc.execute("ALTER TABLE ll_dispatch_history ALTER COLUMN action VARCHAR(40)");
|
||||
String nativeReason="原排车完整中文历史🌏"+"中".repeat(1200)+" ",operationReason="整车承运登记完整中文说明🌏 ",operator="o".repeat(127)+"X",nativeAction="A".repeat(39)+"X";
|
||||
baseline.jdbc.update("UPDATE ll_dispatch_history SET reason=?,operator_id=?,action=? WHERE dispatch_id=2",nativeReason,operator,nativeAction);
|
||||
var saved=outsourced.save(actor(),"DS-OUT-2",new OutsourcedBusinessModels.SaveOperations("0","history-character-original",
|
||||
new OutsourcedBusinessModels.Operations(new OwnedBusinessModels.Money("CNY","30"),List.of()),operationReason));
|
||||
var history=outsourced.history(actor(),"DS-OUT-2",1,20);assertEquals(2,history.total());
|
||||
var original=history.records().stream().filter(h->h.eventSource().equals("DISPATCH")).findFirst().orElseThrow();
|
||||
var operations=history.records().stream().filter(h->h.eventSource().equals("OPERATIONS")).findFirst().orElseThrow();
|
||||
assertEquals(nativeReason,original.reason());assertEquals(nativeAction,original.action());
|
||||
assertEquals(operationReason,operations.reason());assertEquals("SAVE_OPERATIONS",operations.action());
|
||||
assertEquals(saved.row().operations(),operations.operations());assertEquals("TEST外配操作员",operations.operatorName());
|
||||
var raw=baseline.repository.history(2,0,20).stream().filter(h->h.getEventSource().equals("DISPATCH")).findFirst().orElseThrow();
|
||||
assertEquals(operator,raw.getOperatorId());
|
||||
}
|
||||
}
|
||||
+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(); }
|
||||
|
||||
+19
-2
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user