181 lines
14 KiB
Java
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()); }
|
|
}
|