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); private Context context=context(List.of(cargo(1,"101","ONT8",51,2),cargo(2,"102","ONT8",52,3)),false); 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)); var numbers=mock(LastlegOrderNoGenerator.class);when(numbers.next(anyString())).thenAnswer(a->a.getArgument(0)+"-"+com.baomidou.mybatisplus.core.toolkit.IdWorker.getId()); 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(); when(execution.lockForArrangement(any(),eq("DP-10"))).thenAnswer(a->context);when(execution.replay(any(),any(),any())).thenReturn(Optional.empty()); 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()); } /** 真实 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 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 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 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()); } /** 按真实共享记录构造完整客户目的地请求。 */ 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()); } }