2026-10-19 17:56:36 +08:00
package com.baiyue.lastleg.delivery ;
import com.baomidou.mybatisplus.annotation.TableName ;
import com.baomidou.mybatisplus.core.MybatisConfiguration ;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean ;
import com.baiyue.lastleg.delivery.entity.* ;
import com.baiyue.lastleg.delivery.mapper.* ;
import com.baiyue.lastleg.delivery.model.* ;
import com.baiyue.lastleg.delivery.repository.DispatchDeliveryRepository ;
import com.baiyue.lastleg.delivery.service.* ;
import com.baiyue.lastleg.dispatch.port.* ;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.* ;
import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.* ;
import com.baiyue.lastleg.dispatch.port.DispatchTypes.* ;
import com.baiyue.lastleg.entity.* ;
import com.baiyue.lastleg.mapper.* ;
import com.baiyue.lastleg.fleet.trip.FleetBookingFreight ;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator ;
import com.fasterxml.jackson.databind.ObjectMapper ;
import org.junit.jupiter.api.BeforeEach ;
import org.junit.jupiter.api.Test ;
import org.mybatis.spring.SqlSessionTemplate ;
import org.springframework.aop.framework.ProxyFactory ;
import org.springframework.jdbc.core.JdbcTemplate ;
import org.springframework.jdbc.datasource.* ;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource ;
import org.springframework.transaction.interceptor.TransactionInterceptor ;
import java.lang.reflect.Modifier ;
import java.time.LocalDateTime ;
import java.util.* ;
import java.util.stream.Collectors ;
import static com.baiyue.lastleg.delivery.DeliveryDispatchFixtures.* ;
import static org.junit.jupiter.api.Assertions.* ;
import static org.mockito.ArgumentMatchers.* ;
import static org.mockito.Mockito.* ;
/** 真实生产Mapper及事务验证任何客户或主单CAS失败时全车原子回滚。 */
class DispatchDeliveryArrangementPersistenceTest {
private JdbcTemplate db ;
private DispatchDeliveryService service ;
private final DispatchExecutionPort execution = mock ( DispatchExecutionPort . class );
private final DispatchDeliveryFees fees = mock ( DispatchDeliveryFees . class );
private final ObjectMapper json = new ObjectMapper (). findAndRegisterModules ();
private final DeliveryCargoGroups grouping = new DeliveryCargoGroups ( json );
2026-10-20 10:35:52 +08:00
private Context context = context ( List . of ( cargo ( 1 , "101" , "ONT8" , 51 , 2 ), cargo ( 2 , "102" , "ONT8" , 52 , 3 )), false );
2026-10-19 17:56:36 +08:00
private final Actor actor = new Actor ( "TEST" , ActorMode . PLATFORM , null );
/** 隔离测试数据库只用于生产Mapper验证,不连接环境或启动服务。 */
@BeforeEach void prepare () throws Exception {
var ds = new DriverManagerDataSource ( "jdbc:h2:mem:arrangement-" + UUID . randomUUID () + ";MODE=MySQL;DB_CLOSE_DELAY=-1" , "sa" , "" );
db = new JdbcTemplate ( ds );
for ( var type : List . of ( OutboundOrder . class , OutboundPalletItem . class , DeliveryOrder . class , LastlegOrder . class , LastlegOrderStatusHistory . class )) {
String columns = Arrays . stream ( type . getDeclaredFields ()). filter ( f ->! Modifier . isStatic ( f . getModifiers ())). map ( f -> {
String name = f . getName (). replaceAll ( "([A-Z])" , "_$1" ). toLowerCase ();
String sqlType = f . getType () == String . class ? "VARCHAR(8000)" : f . getType () == LocalDateTime . class ? "TIMESTAMP" :
f . getType () == java . math . BigDecimal . class ? "DECIMAL(24,6)" : "BIGINT" ;
return name + " " + sqlType + ( f . getName (). equals ( "id" ) ? ( type == LastlegOrderStatusHistory . class ? " AUTO_INCREMENT PRIMARY KEY" : " PRIMARY KEY" ): "" );
}). collect ( Collectors . joining ( "," ));
db . execute ( "CREATE TABLE " + type . getAnnotation ( TableName . class ). value () + "(" + columns + ")" );
}
db . execute ( "ALTER TABLE ll_outbound_order ADD UNIQUE(dispatch_order_id,customer_id)" );
db . execute ( "ALTER TABLE ll_delivery_order ADD UNIQUE(customer_id,outbound_order_id,delivery_group_key)" );
db . execute ( "ALTER TABLE ll_outbound_pallet_item ADD UNIQUE(customer_id,source_attribution_id)" );
db . execute ( """
CREATE TABLE ll_dispatch_delivery_arrangement(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT UNIQUE,
dispatch_no VARCHAR(128),arrangement_key VARCHAR(128) UNIQUE,business_hash VARCHAR(64),cargo_hash VARCHAR(64),
loading_completion_hash VARCHAR(64),original_command_key VARCHAR(255) UNIQUE,original_operator_id VARCHAR(128),
result_json CLOB,cargo_json CLOB,created_at TIMESTAMP)
""" );
db . update ( "INSERT INTO ll_order(id,customer_id,current_status,version) VALUES(1,'101','INBOUND_COMPLETED',0),(2,'102','INBOUND_COMPLETED',0)" );
var config = new MybatisConfiguration (); config . setMapUnderscoreToCamelCase ( true );
for ( var mapper : List . of ( DispatchDeliveryArrangementMapper . class , OutboundOrderMapper . class , OutboundPalletItemMapper . class ,
DeliveryOrderMapper . class , DeliveryOperationMapper . class , LastlegOrderMapper . class , LastlegOrderStatusHistoryMapper . class )) config . addMapper ( mapper );
var factory = new MybatisSqlSessionFactoryBean (); factory . setDataSource ( ds ); factory . setConfiguration ( config ); var session = new SqlSessionTemplate ( factory . getObject ());
var repository = new DispatchDeliveryRepository ( session . getMapper ( DispatchDeliveryArrangementMapper . class ), session . getMapper ( OutboundOrderMapper . class ),
session . getMapper ( OutboundPalletItemMapper . class ), session . getMapper ( DeliveryOrderMapper . class ), session . getMapper ( DeliveryOperationMapper . class ),
session . getMapper ( LastlegOrderMapper . class ), session . getMapper ( LastlegOrderStatusHistoryMapper . class ));
2026-10-21 07:53:31 +08:00
var numbers = mock ( LastlegOrderNoGenerator . class ); when ( numbers . next ( anyString ())). thenAnswer ( a -> a . getArgument ( 0 ) + "-" + com . baomidou . mybatisplus . core . toolkit . IdWorker . getId ());
2026-10-19 17:56:36 +08:00
var target = new DispatchDeliveryService ( execution , repository , grouping , fees , new NormalDeliveryCompletionPolicy ( grouping ), numbers , json );
var proxy = new ProxyFactory ( target ); proxy . setProxyTargetClass ( true ); proxy . addAdvice ( new TransactionInterceptor ( new DataSourceTransactionManager ( ds ), new AnnotationTransactionAttributeSource ()));
service = ( DispatchDeliveryService ) proxy . getProxy ();
2026-10-20 10:35:52 +08:00
when ( execution . lockForArrangement ( any (), eq ( "DP-10" ))). thenAnswer ( a -> context ); when ( execution . replay ( any (), any (), any ())). thenReturn ( Optional . empty ());
2026-10-19 17:56:36 +08:00
when ( fees . external ( any ())). thenReturn ( Map . of ( "101" , new FleetBookingFreight . Preview ( "freight" , true , List . of (), List . of ()),
"102" , new FleetBookingFreight . Preview ( "freight" , true , List . of (), List . of ())));
when ( execution . recordArrangement ( any (), any (), any (), any (), anyBoolean ())). thenThrow ( new IllegalStateException ( "主单CAS失败" ));
}
/** 第二客户写失败会回滚首客户,主单CAS失败也不能留下两张出库或来源绑定。 */
@Test void mainCasFailureRollsBackAllCustomersAndSources () {
assertThrows ( IllegalStateException . class ,() -> service . arrange ( actor , "DP-10" , request ()));
for ( var table : List . of ( "ll_outbound_order" , "ll_outbound_pallet_item" , "ll_delivery_order" , "ll_dispatch_delivery_arrangement" , "ll_loading_order_status_history" ))
assertEquals ( 0 , db . queryForObject ( "SELECT COUNT(*) FROM " + table , Integer . class ));
assertEquals ( 2 , db . queryForObject ( "SELECT COUNT(*) FROM ll_order WHERE outbound_order_no IS NULL AND version=0" , Integer . class ));
}
/** 一个客户费用失败不把另一客户已创建的单据单独提交。 */
@Test void laterCustomerFeeFailureRollsBackEveryDocument () {
doThrow ( new IllegalStateException ( "费用发布失败" )). when ( fees ). publish ( any (), argThat ( g -> g . customerId (). equals ( "102" )), any (), any (), any (), any (), anyString (), anyString ());
assertThrows ( IllegalStateException . class ,() -> service . arrange ( actor , "DP-10" , request ()));
assertEquals ( 0 , db . queryForObject ( "SELECT COUNT(*) FROM ll_outbound_order" , Integer . class ));
assertEquals ( 0 , db . queryForObject ( "SELECT COUNT(*) FROM ll_delivery_order" , Integer . class ));
verify ( execution , never ()). recordArrangement ( any (), any (), any (), any (), anyBoolean ());
}
2026-10-20 10:35:52 +08:00
/** 真实 Mapper 保存安排和逐次归属后,出库及派送均可独立定位原货源,不依赖可变履约单回填。 */
@Test void persistedAttributionAndDeliveryReferencesResolveImmutableRelabelOrigins () throws Exception {
var first = new SourceOrigin ( 10001L , 201L , 301L , 401L , 701L , "IN-701" , "CODE-701" , null );
var second = new SourceOrigin ( 10002L , 202L , 302L , 402L , 702L , "IN-702" , "CODE-702" , null );
var third = new SourceOrigin ( 10003L , 203L , 303L , 403L , 703L , "IN-703" , "CODE-703" , null );
var fourth = new SourceOrigin ( 10004L , 204L , 304L , 404L , 704L , "IN-704" , "CODE-704" , 6L );
context = context ( List . of ( relabelCargo ( 1 , "101" , "ONT8" , 51 , 2 , first ), relabelCargo ( 3 , "101" , "ONT8" , 51 , 3 , second ),
relabelCargo ( 2 , "102" , "ONT8" , 51 , 4 , third ), relabelCargo ( 4 , "101" , "LAX9" , 52 , 1 , fourth )), false );
db . update ( "INSERT INTO ll_order(id,customer_id,current_status,version) VALUES(3,'101','INBOUND_COMPLETED',0),(4,'101','INBOUND_COMPLETED',0)" );
// 成功用例覆盖默认 CAS 失败桩时不能调用旧桩;保留失败用例原有回滚语义。
doAnswer ( a -> {
ArrangementFact fact = a . getArgument ( 3 ); CommandIdentity identity = a . getArgument ( 2 );
return new CommandResult ( "DP-10" , Action . ARRANGE_DELIVERY , identity . idempotencyKey (), 4 , Status . ACTIVE , Stage . AFTER_LOADING ,
DEPARTED , Effect . APPLIED , context . counts (), fact . result (), false , false );
}). when ( execution ). recordArrangement ( any (), any (), any (), any (), anyBoolean ());
var result = service . arrange ( actor , "DP-10" , request ());
assertNotNull ( result );
assertEquals ( 4 , db . queryForObject ( "SELECT COUNT(*) FROM ll_outbound_pallet_item" , Integer . class ));
assertEquals ( 2 , db . queryForObject ( "SELECT COUNT(*) FROM ll_outbound_order" , Integer . class ));
assertEquals ( 3 , db . queryForObject ( "SELECT COUNT(*) FROM ll_delivery_order" , Integer . class ));
assertEquals ( 5 , db . queryForObject ( "SELECT package_count FROM ll_delivery_order WHERE customer_id='101' AND destination_warehouse_code='ONT8'" , Integer . class ));
assertEquals ( 1 , db . queryForObject ( "SELECT pallet_count FROM ll_delivery_order WHERE customer_id='101' AND destination_warehouse_code='ONT8'" , Integer . class ));
assertEquals ( 2 , db . queryForObject ( "SELECT pallet_count FROM ll_outbound_order WHERE customer_id='101'" , Integer . class ));
// 修改履约单展示事实后,仍只通过原安排快照和已保存的真实归属定位来源。
db . update ( "UPDATE ll_order SET order_no=CONCAT('CHANGED-',id)" );
var outboundRows = db . queryForList ( """
SELECT i.customer_id,i.source_attribution_id,i.source_order_id,i.dispatch_item_id,i.package_count,
i.gross_weight_kg,i.volume_cbm,CAST(a.cargo_json AS VARCHAR(20000)) AS cargo_json
FROM ll_outbound_pallet_item i
JOIN ll_outbound_order o ON o.id=i.outbound_order_id AND o.customer_id=i.customer_id
JOIN ll_dispatch_delivery_arrangement a ON a.dispatch_order_id=o.dispatch_order_id
ORDER BY i.source_attribution_id
""" );
for ( var item : outboundRows ) {
List < CargoLine > frozen = json . readValue ( item . get ( "CARGO_JSON" ). toString (), json . getTypeFactory (). constructCollectionType ( List . class , CargoLine . class ));
long attribution = (( Number ) item . get ( "SOURCE_ATTRIBUTION_ID" )). longValue ();
var row = frozen . stream (). filter ( c -> c . customerId (). equals ( item . get ( "CUSTOMER_ID" )) && c . attributionId () == attribution )
. findFirst (). orElseThrow ();
assertEquals ( context . cargo (). stream (). filter ( c -> c . attributionId () == attribution ). findFirst (). orElseThrow (). sourceOrigin (), row . sourceOrigin ());
assertEquals ( row . orderId (). longValue (),(( Number ) item . get ( "SOURCE_ORDER_ID" )). longValue ());
assertNotEquals ( row . sourceOrigin (). sourceInboundOrderId (). longValue (),(( Number ) item . get ( "SOURCE_ORDER_ID" )). longValue ());
assertEquals ( row . packages (),(( Number ) item . get ( "PACKAGE_COUNT" )). longValue ());
assertNull ( item . get ( "GROSS_WEIGHT_KG" )); assertNull ( item . get ( "VOLUME_CBM" ));
}
// 费用的 DELIVERY_ORDER.businessId 指向此真实派送行,可按客户及目的地取回其全部冻结来源。
var deliveryRows = db . queryForList ( """
SELECT d.id,d.customer_id,d.delivery_group_key,d.package_count,d.pallet_count,
CAST(a.cargo_json AS VARCHAR(20000)) AS cargo_json
FROM ll_delivery_order d
JOIN ll_dispatch_delivery_arrangement a ON a.dispatch_order_id=d.dispatch_order_id
ORDER BY d.customer_id,d.delivery_group_key
""" );
int referencedSources = 0 ;
for ( var delivery : deliveryRows ) {
List < CargoLine > frozen = json . readValue ( delivery . get ( "CARGO_JSON" ). toString (), json . getTypeFactory (). constructCollectionType ( List . class , CargoLine . class ));
var group = frozen . stream (). filter ( c -> c . customerId (). equals ( delivery . get ( "CUSTOMER_ID" ))
&& grouping . documentGroupKey ( c . destination ()). equals ( delivery . get ( "DELIVERY_GROUP_KEY" ))). toList ();
referencedSources += group . size ();
assertFalse ( group . isEmpty ()); assertTrue ( group . stream (). allMatch ( c -> c . sourceOrigin () != null ));
assertEquals ((( Number ) delivery . get ( "PACKAGE_COUNT" )). longValue (), group . stream (). mapToLong ( CargoLine :: packages ). sum ());
assertEquals ((( Number ) delivery . get ( "PALLET_COUNT" )). longValue (), group . stream (). map ( CargoLine :: warehousePalletId ). distinct (). count ());
if ( group . size () == 2 ) assertEquals ( List . of ( first , second ), group . stream (). map ( CargoLine :: sourceOrigin ). toList ());
}
assertEquals ( 4 , referencedSources );
List < CargoLine > frozen = json . readValue ( db . queryForObject ( "SELECT cargo_json FROM ll_dispatch_delivery_arrangement" , String . class ),
json . getTypeFactory (). constructCollectionType ( List . class , CargoLine . class ));
assertEquals ( 3 , frozen . stream (). filter ( c -> c . sourceOrigin (). productQuantity () == null ). count ());
assertEquals ( 6L , frozen . stream (). filter ( c -> c . attributionId () == 4 ). findFirst (). orElseThrow (). sourceOrigin (). productQuantity ());
}
2026-10-19 17:56:36 +08:00
/** 按真实共享记录构造完整客户目的地请求。 */
private ArrangeDelivery request () { return new ArrangeDelivery ( 3L , "ARRANGE-1" , "cargo-hash" , "loading-hash" , grouping . groups ( context ). stream (). map ( g ->
new ArrangeGroup ( g . key (), g . cargoHash (), DEPARTED , "TRUCK" , null , null , "WAREHOUSE_PICKUP" , "PALLET" , null , null , null , null , null ,
"freight" , g . cargoHash (), List . of (), List . of ())). toList ()); }
}