Files
baiyue-firstleg/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryArrangementPersistenceTest.java
T
2026-10-21 07:53:31 +08:00

181 lines
14 KiB
Java

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<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());
}
/** 按真实共享记录构造完整客户目的地请求。 */
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()); }
}