release: merge all current TEST changes into main

This commit is contained in:
2026-11-06 14:04:24 +08:00
53 changed files with 1452 additions and 111 deletions
@@ -11,7 +11,7 @@ import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
class MerchantTransportPhotoMapperTest {
/** 同编号、混装和撤销安排均不能扩大客户读取范围,实际查询使用正式Mapper。 */
/** 同单分托去重且分车全部保留;同编号、混装和撤销安排不扩大客户读取范围。 */
@Test void queriesOnlyExactCustomerOrderAndActiveDispatches() throws Exception {
var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
var db=new JdbcTemplate(data);
@@ -24,13 +24,13 @@ class MerchantTransportPhotoMapperTest {
db.update("INSERT INTO ll_local_looseboard_order VALUES('C1','LB1',1),('C2','LB1',2)");
db.update("INSERT INTO ll_inbound_plan VALUES(11,'C1',1),(12,'C2',2)");
db.update("INSERT INTO ll_inbound_plan_pallet VALUES(21,11,'C1'),(22,12,'C2')");
db.update("INSERT INTO ll_dispatch_order VALUES(31,'PC1','ACTIVE','DELIVERY'),(32,'PC2','CANCELLED','DELIVERY'),(33,'PC3','ACTIVE','LOCAL_TRUCK')");
db.update("INSERT INTO ll_dispatch_item VALUES(21,'C1',31),(22,'C2',31),(21,'C1',32),(22,'C1',33)");
db.update("INSERT INTO ll_dispatch_order VALUES(31,'PC1','ACTIVE','DELIVERY'),(32,'PC2','CANCELLED','DELIVERY'),(33,'PC3','ACTIVE','LOCAL_TRUCK'),(34,'PC4','ACTIVE','DELIVERY')");
db.update("INSERT INTO ll_dispatch_item VALUES(21,'C1',31),(21,'C1',31),(22,'C2',31),(21,'C1',32),(22,'C1',33),(21,'C1',34)");
db.update("INSERT INTO ll_dispatch_truck_task VALUES('PC3','C1','TR1','C1:TR1'),('PC2','C1','TR1','C1:TR1'),('PC1','C2','TR1','C2:TR1')");
var config=new MybatisConfiguration();config.addMapper(MerchantTransportPhotoMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(config);factory.afterPropertiesSet();
var repository=new MerchantTransportPhotoRepository(new SqlSessionTemplate(factory.getObject()).getMapper(MerchantTransportPhotoMapper.class));
assertThat(repository.looseboardDispatches("C1","LB1")).containsExactly("PC1");
assertThat(repository.looseboardDispatches("C1","LB1")).containsExactly("PC1","PC4");
assertThat(repository.looseboardDispatches("C3","LB1")).isEmpty();
assertThat(repository.truckDispatches("C1","TR1")).containsExactly("PC3");
assertThat(repository.truckDispatches("C2","TR1")).isEmpty();
@@ -8,12 +8,64 @@ import com.baiyue.lastleg.dispatch.port.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static org.assertj.core.api.Assertions.*;
class DispatchLooseboardDisplayServiceTest {
/** 维护车辆与排车详情共用订单投影,同单两托只展示一行且未知重量不补零。 */
@Test void looseboardOrderCombinesPalletsAndRetainsAllSourceKeys() {
var repository=mock(LooseboardCargoRepository.class);
when(repository.businessSources(any())).thenReturn(List.of(new BusinessSource("A","ZY1","LB202611050003",11L),new BusinessSource("A","ZY2","LB202611050002",13L)));
var first=item("A","ZY1",11L,"ZY1");var second=item("A","ZY1",12L,"ZY1");var other=item("A","ZY2",13L,"ZY2");
first.setVolumeCbm(new BigDecimal("5"));second.setVolumeCbm(new BigDecimal("5"));other.setVolumeCbm(new BigDecimal("4"));
var result=details(repository,List.of(first,second,other));
assertThat(result).hasSize(2);assertThat(result.get(0).orderNo()).isEqualTo("LB202611050003");
assertThat(result.get(0).palletCount()).isEqualTo(2);assertThat(result.get(0).volumeCbm()).isEqualByComparingTo("10");
assertThat(result.get(0).grossWeightKg()).isNull();assertThat(result.get(0).sourceKeys()).containsExactly("PLAN:11","PLAN:12");
assertThat(result.get(1).palletCount()).isEqualTo(1);assertThat(result.get(1).volumeCbm()).isEqualByComparingTo("4");
assertThat(first.getOrderNo()).isEqualTo("ZY1");assertThat(second.getVolumeCbm()).isEqualByComparingTo("5");
}
/** 同号跨客户不能合并,普通来源不能仅因编号类似散板而变成散板。 */
@Test void sameOrderNumberKeepsCustomerAndOrdinarySourceBoundaries() {
var repository=mock(LooseboardCargoRepository.class);
when(repository.businessSources(any())).thenReturn(List.of(new BusinessSource("A","ZY1","LB1",11L),new BusinessSource("B","ZY1","LB1",12L)));
var result=details(repository,List.of(item("A","ZY1",11L,"ZY1"),item("B","ZY1",12L,"ZY1"),item("C","LB1",13L,"REAL"),item("C","LB1",14L,"REAL")));
assertThat(result).hasSize(4);assertThat(result).extracting(DispatchViews.OrderDetail::customerId).containsExactly("A","B","C","C");
assertThat(result.get(2).sourceBusinessType()).isNull();assertThat(result.get(2).containerNo()).isEqualTo("REAL");
}
/** 同一实托关联多个来源只计一托和一次整托测量;缺测量的订单总量仍未知。 */
@Test void sharedPhysicalPalletIsCountedOnceAndPartialMeasurementRemainsUnknown() {
var repository=mock(LooseboardCargoRepository.class);
when(repository.businessSources(any())).thenReturn(List.of(new BusinessSource("A","ZY1","LB1",11L)));
var first=item("A","ZY1",11L,"ZY1");var second=item("A","ZY1",12L,"ZY1");
first.setWarehousePalletId(91L);second.setWarehousePalletId(91L);
first.setGrossWeightKg(new BigDecimal("30"));second.setGrossWeightKg(new BigDecimal("30.00"));
first.setVolumeCbm(new BigDecimal("5"));second.setVolumeCbm(new BigDecimal("5.0"));
var combined=details(repository,List.of(first,second)).get(0);
assertThat(combined.palletCount()).isEqualTo(1);assertThat(combined.volumeCbm()).isEqualByComparingTo("5");assertThat(combined.grossWeightKg()).isEqualByComparingTo("30");
second.setWarehousePalletId(92L);second.setVolumeCbm(null);
var partial=details(repository,List.of(first,second)).get(0);
assertThat(partial.palletCount()).isEqualTo(2);assertThat(partial.volumeCbm()).isNull();assertThat(partial.grossWeightKg()).isEqualByComparingTo("60");
second.setWarehousePalletId(91L);second.setGrossWeightKg(new BigDecimal("31"));
assertThat(details(repository,List.of(first,second)).get(0).grossWeightKg()).isNull();
}
/** 混托多订单无法分摊测量时,不给散板复制整托重量、体积或托数。 */
@Test void sharedPalletWithMultipleOrdersDoesNotInventPerOrderMeasurements() {
var repository=mock(LooseboardCargoRepository.class);
when(repository.businessSources(any())).thenReturn(List.of(new BusinessSource("A","ZY1","LB1",11L)));
var mixed=item("A","ZY1",11L,"ZY1");mixed.setOrderRefs("ZY1,OTHER");mixed.setGrossWeightKg(new BigDecimal("30"));mixed.setVolumeCbm(new BigDecimal("5"));
var result=details(repository,List.of(mixed));
assertThat(result).hasSize(1);assertThat(result.get(0).palletCount()).isNull();assertThat(result.get(0).grossWeightKg()).isNull();assertThat(result.get(0).volumeCbm()).isNull();
}
/** 调用正式排车关联订单投影,避免为聚合规则复制一份测试实现。 */
private List<DispatchViews.OrderDetail> details(LooseboardCargoRepository repository,List<DispatchItem> cargo) {
var aggregate=new DispatchAggregateService(null,null,null,null,null,null,new ObjectMapper());
ReflectionTestUtils.setField(aggregate,"looseboardDisplay",new DispatchLooseboardDisplayService(repository));
return ReflectionTestUtils.invokeMethod(aggregate,"orderDetails",cargo);
}
/** 历史排车仍按真实关联显示LB原单;不把内部ZY号代填为入仓码或集装箱号。 */
@Test void historicalDetailsUseBusinessOrderAndNoInventedContainer() {
var repository=mock(LooseboardCargoRepository.class);
@@ -64,6 +116,7 @@ class DispatchLooseboardDisplayServiceTest {
/** 构造旧排车保存的履约快照,验证读取时纠正且不改原来源。 */
private DispatchItem item(String customer,String number,long pallet,String container) {
var item=new DispatchItem();item.setCustomerId(customer);item.setOrderNo(number);item.setOrderRefs(number);item.setPlanPalletId(pallet);item.setInboundCode(number);
item.setSourceKey("PLAN:"+pallet);
item.setSourceSnapshotJson("{\"containerNo\":\""+container+"\",\"planNo\":\"plan\"}");return item;
}
}
@@ -207,7 +207,7 @@ class ExceptionOrderLifecycleTest {
var order = order("PENDING", 1);
order.setExceptionType("RETURN"); order.setDeliveryNo("DL-1");
order.setReturnContextJson("{\"deliveryStatus\":\"DELIVERED\",\"tailId\":null,\"tailStatus\":null,\"abnormal\":null}");
var delivery = new DeliveryOrder(); delivery.setStatus("RETURN_PENDING"); delivery.setVersion(3L);
var delivery = new DeliveryOrder(); delivery.setId(71L); delivery.setStatus("RETURN_PENDING"); delivery.setVersion(3L);
var signedAt = LocalDateTime.of(2026, 9, 1, 12, 0); delivery.setSignedAt(signedAt);
when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order);
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
@@ -36,7 +36,7 @@ class ReturnCartonReturnPersistenceTest {
var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
db=new JdbcTemplate(data);tx=new TransactionTemplate(new DataSourceTransactionManager(data));
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);
var mappers=List.of(ExceptionOrderMapper.class,ExceptionOrderCommandMapper.class,com.baiyue.lastleg.mapper.LastlegOrderMapper.class,com.baiyue.lastleg.mapper.LastlegInboundOrderMapper.class,ExceptionReturnItemMapper.class,ReturnCartonStockReceiptMapper.class,
var mappers=List.of(ExceptionWorkflowMapper.class,ExceptionOrderMapper.class,ExceptionOrderCommandMapper.class,com.baiyue.lastleg.mapper.LastlegOrderMapper.class,com.baiyue.lastleg.mapper.LastlegInboundOrderMapper.class,ExceptionReturnItemMapper.class,ReturnCartonStockReceiptMapper.class,
ReturnInventoryCartonMapper.class,ReturnInventoryCartonItemMapper.class,ReturnInventoryBalanceMapper.class,ReturnInventoryMovementMapper.class,
DeliveryOrderMapper.class,OutboundOrderMapper.class,OutboundPalletItemMapper.class,OutboundPdaPickMapper.class,OutboundPdaTaskMapper.class,DispatchAggregateMapper.class,DispatchReadMapper.class,DispatchItemMapper.class,DispatchFeeCargoMapper.class,
DispatchPdaTaskMapper.class,DispatchPdaPickMapper.class,DispatchPdaPickOrderMapper.class,DeliveryOperationMapper.class);
@@ -165,6 +165,75 @@ class ReturnCartonReturnPersistenceTest {
assertThatThrownBy(()->tx.execute(t->business.markReturn("C","OP","操作员","DL-1",foreign))).hasMessageContaining("主单不属于该派送单");
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_exception_order",Long.class)).isZero();
}
/** 同派送两张原单可一起退回,部分签收数量保留,同键重放不增异常。 */
@Test void multipleMastersOfOneDeliveryFreezeTogetherAndReplayWithoutDuplicateExceptions() {
var requests=prepareTwoMasterReturns();var business=business();
var created=tx.execute(t->business.markReturns("C","OP","操作员","DL-1",requests));
assertThat(created).hasSize(2);
assertThat(db.queryForList("SELECT last_mile_order_id FROM ll_exception_order ORDER BY last_mile_order_id",Long.class)).containsExactly(100L,101L);
assertThat(db.queryForObject("SELECT SUM(approved_return_packages) FROM ll_exception_return_item",Long.class)).isEqualTo(2);
assertThat(db.queryForObject("SELECT signed_packages FROM ll_delivery_order WHERE id=3",Long.class)).isEqualTo(2);
var replay=tx.execute(t->business.markReturns("C","OP","操作员","DL-1",requests));
assertThat(replay).allSatisfy(row->assertThat(row.replayed()).isTrue());
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_exception_order",Long.class)).isEqualTo(2);
}
/** 从排车适配器传入两张原单,真实服务分别建异常;各来源仍保持独立。 */
@Test void dispatchAdapterCreatesBothReturnsThroughRealService() {
var requests=prepareTwoMasterReturns();var business=business();
var repository=mock(com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository.class);
when(repository.deliveries(10L)).thenReturn(List.of(new com.baiyue.lastleg.exceptionorder.mapper.ExceptionWorkflowMapper.Delivery("C","DL-1")));
var adapter=new DispatchExceptionAdapter(repository,business);
var selections=requests.stream().map(r->new com.baiyue.lastleg.dispatch.port.DispatchExceptionPort.Selection("DL-1",r.lastMileOrderId(),
r.items().stream().map(i->new com.baiyue.lastleg.dispatch.port.DispatchExceptionPort.Item(i.outboundPalletItemId(),i.inventoryPositionId(),i.returnCartonId(),i.packageCount())).toList())).toList();
var command=new com.baiyue.lastleg.dispatch.port.DispatchCommands.MarkException(0L,"adapter-multiple",com.baiyue.lastleg.dispatch.port.DispatchTypes.ExceptionType.PARTIAL_RECEIPT,
com.baiyue.lastleg.dispatch.port.DispatchTypes.ReturnDirection.OVERSEAS_WAREHOUSE,"多单部分签收",List.of(),selections);
tx.executeWithoutResult(t->adapter.register(10L,"OP",command));
assertThat(db.queryForList("SELECT last_mile_order_id FROM ll_exception_order ORDER BY last_mile_order_id",Long.class)).containsExactly(100L,101L);
assertThat(db.queryForObject("SELECT SUM(approved_return_packages) FROM ll_exception_return_item",Long.class)).isEqualTo(2);
}
/** 多单退回可按任意顺序取消;最后一单取消才恢复批次前状态,签收历史保持不变。 */
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans={false,true})
void cancellingBothMastersRestoresOriginalStateInEitherOrder(boolean reversed) {
var requests=prepareTwoMasterReturns();var business=business();
var created=new ArrayList<>(tx.execute(t->business.markReturns("C","OP","操作员","DL-1",requests)));
if(reversed)Collections.reverse(created);
for(int index=0;index<created.size();index++) {
var row=created.get(index);
tx.executeWithoutResult(t->business.cancel("C","OP",row.exceptionNo(),new ExceptionOrderRequests.Cancel(0,"cancel-"+row.id(),"取消本单退回")));
assertThat(db.queryForObject("SELECT status FROM ll_delivery_order WHERE id=3",String.class)).isEqualTo(index==0?"RETURN_PENDING":"DELIVERING");
}
assertThat(db.queryForObject("SELECT signed_packages FROM ll_delivery_order WHERE id=3",Long.class)).isEqualTo(2);
assertThat(db.queryForList("SELECT abnormal FROM ll_order",String.class)).doesNotContain("true");
}
/** 第二单借用第一单原箱时,第一单异常和冻结数量也必须回滚。 */
@Test void invalidSecondMasterRollsBackTheWholeMultiOrderReturn() {
var requests=prepareTwoMasterReturns();var business=business();
var wrong=new ExceptionOrderRequests.MarkReturn(101L,"原因","OVERSEAS_WAREHOUSE",null,requests.get(0).items(),"batch-wrong");
assertThatThrownBy(()->tx.execute(t->business.markReturns("C","OP","操作员","DL-1",List.of(requests.get(0),wrong))))
.hasMessageContaining("不属于该派送单和尾程主单");
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_exception_order",Long.class)).isZero();
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_exception_return_item",Long.class)).isZero();
assertThat(db.queryForObject("SELECT status FROM ll_delivery_order WHERE id=3",String.class)).isEqualTo("DELIVERING");
}
/** 单笔数量均合法也不能让合计退回超过部分签收后的未签数量。 */
@Test void multiOrderTotalCannotIncludeAlreadySignedPackages() {
var requests=prepareTwoMasterReturns();var business=business();
db.update("UPDATE ll_delivery_order SET signed_packages=3 WHERE id=3");
assertThatThrownBy(()->tx.execute(t->business.markReturns("C","OP","操作员","DL-1",requests))).hasMessageContaining("退回总箱数");
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_exception_order",Long.class)).isZero();
}
/** 两张原单共享派送单,但各自只拥有一只已实际出库的原箱。 */
private List<ExceptionOrderRequests.MarkReturn> prepareTwoMasterReturns() {
prepareMarkReturn();
db.update("UPDATE ll_delivery_order SET status='DELIVERING',package_count=4,signed_packages=2 WHERE id=3");
db.update("INSERT INTO ll_order(id,customer_id,order_no,delivery_order_no,outbound_order_no,overseas_warehouse_id,package_count) VALUES(101,'C','LM-2','DL-1','OUT-1','92',1)");
db.update("INSERT INTO ll_dispatch_pda_pick(id,customer_id,task_id,dispatch_item_id,return_carton_id,packages,product_quantity) VALUES(104,'C',102,4,102,1,1)");
db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,packages) VALUES(106,'C',104,101,1)");
db.update("INSERT INTO ll_return_inventory_carton(id,customer_id,overseas_warehouse_id,carton_mark,status,total_quantity) VALUES(102,'C','92','BOX-2','OUTBOUNDED',1)");
return List.of(new ExceptionOrderRequests.MarkReturn(100L,"多单部分签收","OVERSEAS_WAREHOUSE",null,List.of(new ExceptionOrderRequests.ReturnItem(5L,null,1,101L)),"batch-master-1"),
new ExceptionOrderRequests.MarkReturn(101L,"多单部分签收","OVERSEAS_WAREHOUSE",null,List.of(new ExceptionOrderRequests.ReturnItem(5L,null,1,102L)),"batch-master-2"));
}
/** 将真实已出库换标主单置为拒签,不添加普通入仓关系。 */
private void prepareMarkReturn() {
db.update("DELETE FROM ll_exception_return_item");db.update("DELETE FROM ll_exception_order");
@@ -174,7 +243,8 @@ class ReturnCartonReturnPersistenceTest {
/** 使用真实Mapper组装异常业务服务,资源和文件网关维持隔离。 */
private ExceptionOrderService business() {
var business=new ExceptionOrderService();
org.springframework.test.util.ReflectionTestUtils.setField(business,"workflow",org.mockito.Mockito.mock(com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"workflow",new com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository(
map(ExceptionWorkflowMapper.class),map(ExceptionOrderMapper.class),map(com.baiyue.lastleg.mapper.LastlegOrderMapper.class)));
org.springframework.test.util.ReflectionTestUtils.setField(business,"exceptionOrders",map(ExceptionOrderMapper.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"returnItems",map(ExceptionReturnItemMapper.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"commands",map(ExceptionOrderCommandMapper.class));
@@ -18,6 +18,28 @@ import static org.mockito.Mockito.*;
/** 同筛选完整统计、旧单不回填和权限隔离的直接回归。 */
class OwnedBusinessQueryTest {
/** 自有、外配共享散板订单明细,跨客户同号不合并,目的地和托盘都不丢失。 */
@Test void detailsGroupLooseboardSourcesUsingDispatchOrderAuthorityInBothScopes() {
var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec();var dispatch=mock(DispatchDetailReader.class);
var original=mock(com.baiyue.lastleg.dispatch.port.DispatchViews.Detail.class);
var first=OwnedBusinessFixtures.cargo().get(0);var second=OwnedBusinessFixtures.cargo().get(1);var other=OwnedBusinessFixtures.cargo().get(0);
second.setCustomerId(first.getCustomerId());second.setWarehousePalletId(32L);second.setPalletNo("PAL-32");second.setDestinationWarehouseCode("LAX9");second.setPackageCount(8);
other.setCustomerId("102");other.setOrderNo("LB-SAME-NUMBER");
when(repo.cargo(any())).thenReturn(List.of(first,second,other));when(repo.batches(any())).thenReturn(List.of());
when(dispatch.detail(any(),any())).thenReturn(original);
when(original.orderDetails()).thenReturn(List.of(new com.baiyue.lastleg.dispatch.port.DispatchViews.OrderDetail("101","客户A","LB1",null,null,2,null,null,null,first.getSourceKey(),"LOCAL_LOOSEBOARD","LB1",List.of(first.getSourceKey(),second.getSourceKey())),
new com.baiyue.lastleg.dispatch.port.DispatchViews.OrderDetail("102","客户B","LB1",null,null,1,null,null,null,other.getSourceKey(),"LOCAL_LOOSEBOARD","LB1",List.of(other.getSourceKey()))));
var service=new OwnedBusinessQueryService(repo,new OwnedBusinessProjection(codec),codec,dispatch);
for(var scope:List.of(com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope.OWNED,com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope.OUTSOURCED)) {
var source=OwnedBusinessFixtures.source();source.setPooledCargo(scope.isPooledCargo());
var result=service.detailForWrite(OwnedBusinessFixtures.actor(),source,scope);
assertEquals(2,result.cargo().size());var order=result.cargo().get(0);
assertEquals("LB1",order.orderNo());assertNull(order.inboundCode());assertEquals("9",order.packages());
assertEquals("PAL-31、PAL-32",order.palletNo());assertEquals(List.of("FBA:ONT8","FBA:LAX9"),order.destinationKeys());
assertEquals("102",result.cargo().get(1).customerId());assertEquals("LB1",result.cargo().get(1).orderNo());
}
assertEquals("ORDER-IN-A",first.getOrderNo());verify(repo,never()).update(any(),anyLong());
}
/** 当前页只有一车时统计仍来自全量,日期边界转换为UTC且共用过滤。 */
@Test void summaryDoesNotUseCurrentPageLengthAndDateUsesPlannedDeparture() {
var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec();
@@ -11,6 +11,33 @@ import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class TripOperationsServiceTest {
/** 照片直送没有仓内装载;与普通出库一起查询时仍保留真实车次及箱数。 */
@Test void photoLooseboardDepartureDoesNotBreakTheWholeReport() {
var repo=mock(TripOperationsRepository.class);
var service=new TripOperationsService(repo,new ObjectMapper().registerModule(new JavaTimeModule()),mock(FleetTripCostService.class));
String snapshot="""
{"trip":{"tripNo":"TR-PHOTO","tripPurpose":"DELIVERY","departedAt":"2026-10-01T00:00:00Z",
"vehiclePlate":"PHOTO-PLATE","stops":[{"name":"取货地"},{"name":"目的仓","destinationKey":"FBA:ONT8"}]}}
""";
var query=new TripOperationsModels.Query(1,20,Instant.parse("2026-10-01T00:00:00Z"),Instant.parse("2026-10-03T00:00:00Z"),null,null,null,null);
var photo=new TripOperationsRepository.Departure("TR-PHOTO","IN_TRANSIT",null,snapshot,null,"{\"taskType\":\"PHOTO_LOOSEBOARD\",\"packages\":6}");
when(repo.departures(query)).thenReturn(List.of(photo,new TripOperationsRepository.Departure("TR-A","COMPLETED",null,DEPARTURE,null)));
var result=service.report(query,false);
assertThat(result.page().records()).hasSize(2);
assertThat(result.summary().trips()).isEqualTo(2);
assertThat(result.summary().ownPackages()).isEqualTo(17);
assertThat(result.summary().physicalPallets()).isEqualTo(1);
var row=result.page().records().get(0);
assertThat(row.physicalPallets()).isNull();assertThat(row.palletSpaces()).isNull();
assertThat(row.destinations().get(0).label()).isEqualTo("目的仓");
// 普通车次仍不能因缺少装载而被静默当成照片直送。
assertThatThrownBy(()->service.project(new TripOperationsRepository.Departure("TR-PHOTO","IN_TRANSIT",null,snapshot,null)))
.hasMessageContaining("缺少装载");
for(String quantity:List.of("null","0","-1","1.5","\"6\"")) {
assertThatThrownBy(()->service.project(new TripOperationsRepository.Departure("TR-PHOTO","IN_TRANSIT",null,snapshot,null,
"{\"taskType\":\"PHOTO_LOOSEBOARD\",\"packages\":"+quantity+"}"))).hasMessageContaining("实际箱数");
}
}
static final String DEPARTURE="""
{"trip":{"tripNo":"TR-A","departedAt":"2026-10-01T00:00:00Z","vehiclePlate":"OLD-PLATE","driverName":"发车司机","warehouseName":"起运仓"},
"confirmedLoad":{"cargo":[
@@ -0,0 +1,220 @@
package com.baiyue.lastleg.looseboard;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries;
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardHistoryMapper;
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper;
import com.baiyue.lastleg.looseboard.port.LooseboardDispatches;
import com.baiyue.lastleg.looseboard.repository.LocalLooseboardRepository;
import com.baiyue.lastleg.looseboard.service.LocalLooseboardService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class LocalLooseboardMerchantStatusTest {
private JdbcTemplate jdbc;
private LocalLooseboardRepository repository;
private LocalLooseboardService service;
/** 在隔离库中执行真实 Mapper、分页计数和客户服务,不依赖运行环境数据。 */
@BeforeEach
void setUp() throws Exception {
var ds = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
jdbc = new JdbcTemplate(ds);
String baseline;
try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) {
baseline = new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
int start = baseline.indexOf("CREATE TABLE IF NOT EXISTS ll_local_looseboard_order (");
String ddl = baseline.substring(start, baseline.indexOf(';', start))
.replaceAll("(?m)^\\s*(?:UNIQUE KEY|KEY) [^\\n]*\\n?", "");
ddl = ddl.substring(0, ddl.indexOf(") ENGINE=")) + ")";
jdbc.execute(ddl.replaceAll(",\\s*\\)$", ")"));
jdbc.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY, source_order_id BIGINT, customer_id VARCHAR(64))");
jdbc.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT PRIMARY KEY, plan_id BIGINT, customer_id VARCHAR(64))");
jdbc.execute("CREATE TABLE ll_dispatch_item(plan_pallet_id BIGINT, dispatch_order_id BIGINT, customer_id VARCHAR(64))");
jdbc.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY, status VARCHAR(32), stage VARCHAR(32))");
jdbc.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT PRIMARY KEY, transfer_no VARCHAR(64), customer_id VARCHAR(64))");
jdbc.execute("CREATE TABLE ll_fleet_pickup_task(transfer_order_id BIGINT, trip_no VARCHAR(64), customer_id VARCHAR(64), status VARCHAR(32))");
jdbc.execute("CREATE TABLE ll_fleet_trip(trip_no VARCHAR(64) PRIMARY KEY, status VARCHAR(32))");
var config = new MybatisConfiguration();
config.setMapUnderscoreToCamelCase(true);
config.addMapper(LocalLooseboardOrderMapper.class);
var pagination = new MybatisPlusInterceptor();
pagination.addInnerInterceptor(new PaginationInnerInterceptor(com.baomidou.mybatisplus.annotation.DbType.H2));
config.addInterceptor(pagination);
var factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(ds); factory.setConfiguration(config);
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(LocalLooseboardOrderMapper.class);
repository = new LocalLooseboardRepository();
ReflectionTestUtils.setField(repository, "orders", mapper);
ReflectionTestUtils.setField(repository, "histories", mock(LocalLooseboardHistoryMapper.class));
service = new LocalLooseboardService();
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "fleetPool", mock(FleetPoolOrderQueries.class));
ReflectionTestUtils.setField(service, "dispatches", mock(LooseboardDispatches.class));
}
/** 审核及资金状态优先,审核通过后内部仓储阶段统一等待排车。 */
@Test
void approvedOrdersWaitForDispatchWhileFundingReviewAndTerminalStatesRemainDistinct() {
String[] statuses = {"FUNDING_PENDING", "FUNDING_FAILED", "PENDING_ACCEPTANCE", "WAITING_INBOUND",
"RECEIVING", "WAITING_CONSOLIDATION", "PENDING_DISPATCH", "DISPATCHED", "IN_TRANSIT", "COMPLETED", "CANCELLED"};
for (int index = 0; index < statuses.length; index++) order(index + 1, "C1", statuses[index]);
var waiting = service.page("C1", query(1, 20, "PENDING_DISPATCH"));
assertThat(waiting.total()).isEqualTo(4);
assertThat(waiting.records()).allMatch(row -> row.status().equals("PENDING_DISPATCH"));
for (long id : List.of(1L, 2L, 3L, 8L, 9L, 10L, 11L)) {
assertThat(service.merchantDetail("C1", "LB" + id).summary().status()).isEqualTo(statuses[(int) id - 1]);
}
assertThat(service.detail("C1", "LB4").summary().status()).isEqualTo("WAITING_INBOUND");
}
/** 上门取货到仓依次等待排车、等待取货和取货中,到仓后等待下一段排车。 */
@Test
void warehousePickupUsesTripAndPickupFactsWithoutCompletingTheDeliveryEarly() {
order(1, "C1", "WAITING_INBOUND");
assertStatus(1, "PENDING_DISPATCH");
jdbc.update("INSERT INTO ll_warehouse_transfer_order VALUES(1,'W1','C1')");
jdbc.update("INSERT INTO ll_fleet_trip VALUES('T1','PLANNED')");
jdbc.update("INSERT INTO ll_fleet_pickup_task VALUES(1,'T1','C1','ASSIGNED')");
assertStatus(1, "DISPATCHED");
jdbc.update("UPDATE ll_fleet_trip SET status='CANCELLED'");
assertStatus(1, "PENDING_DISPATCH");
jdbc.update("UPDATE ll_fleet_trip SET status='PLANNED'");
jdbc.update("UPDATE ll_fleet_pickup_task SET trip_no=NULL,status='WAITING'");
assertStatus(1, "PENDING_DISPATCH");
jdbc.update("UPDATE ll_fleet_pickup_task SET trip_no='T1',status='ASSIGNED'");
jdbc.update("UPDATE ll_fleet_trip SET status='IN_TRANSIT'");
assertStatus(1, "PICKING_UP");
jdbc.update("UPDATE ll_fleet_pickup_task SET status='PICKED'");
assertStatus(1, "PICKING_UP");
jdbc.update("UPDATE ll_fleet_pickup_task SET status='ARRIVED'");
assertStatus(1, "PENDING_DISPATCH");
plan(1, "C1"); dispatch(1, 11, "C1", "ACTIVE", "BEFORE_LOADING");
assertStatus(1, "DISPATCHED");
jdbc.update("UPDATE ll_dispatch_order SET stage='DELIVERING'");
assertStatus(1, "IN_TRANSIT");
assertThat(repository.findNo("C1", "LB1").getStatus()).isEqualTo("WAITING_INBOUND");
jdbc.update("UPDATE ll_local_looseboard_order SET status='COMPLETED'");
assertStatus(1, "COMPLETED");
}
/** 取货直送及分车派送按有效排车判断,取消排车和重复托盘关联不污染状态或总数。 */
@Test
void directDeliveryIgnoresCancelledDispatchesAndKeepsSplitDeliveryInTransit() {
order(1, "C1", "WAITING_INBOUND"); plan(1, "C1");
dispatch(1, 11, "C1", "CANCELLED", "DELIVERING");
assertStatus(1, "PENDING_DISPATCH");
dispatch(1, 12, "C1", "ACTIVE", "LOADING");
jdbc.update("INSERT INTO ll_dispatch_item VALUES(1,12,'C1')");
assertStatus(1, "DISPATCHED");
jdbc.update("UPDATE ll_dispatch_order SET stage='DELIVERED' WHERE id=12");
dispatch(1, 13, "C1", "ACTIVE", "BEFORE_LOADING");
assertStatus(1, "IN_TRANSIT");
jdbc.update("UPDATE ll_dispatch_order SET status='CANCELLED'");
assertStatus(1, "PENDING_DISPATCH");
jdbc.update("UPDATE ll_local_looseboard_order SET status='CANCELLED'");
jdbc.update("UPDATE ll_dispatch_order SET status='ACTIVE'");
assertStatus(1, "CANCELLED");
}
/** 原单、来源计划、托盘、排车项及取货任务均保持客户隔离。 */
@Test
void foreignSourceAndPickupFactsCannotAdvanceAnotherCustomersOrder() {
order(1, "C1", "WAITING_INBOUND"); order(2, "C2", "WAITING_INBOUND");
plan(1, "C2"); dispatch(1, 11, "C2", "ACTIVE", "DELIVERING");
assertStatus(1, "PENDING_DISPATCH");
jdbc.update("UPDATE ll_inbound_plan SET customer_id='C1'");
assertStatus(1, "PENDING_DISPATCH");
jdbc.update("UPDATE ll_inbound_plan_pallet SET customer_id='C1'");
assertStatus(1, "PENDING_DISPATCH");
jdbc.update("INSERT INTO ll_warehouse_transfer_order VALUES(1,'W1','C2')");
jdbc.update("INSERT INTO ll_fleet_trip VALUES('T1','IN_TRANSIT')");
jdbc.update("INSERT INTO ll_fleet_pickup_task VALUES(1,'T1','C2','PICKED')");
assertStatus(1, "PENDING_DISPATCH");
assertThat(repository.merchantStatuses("C2", List.of(1L))).isEmpty();
assertThatThrownBy(() -> service.merchantDetail("C2", "LB1"))
.isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
jdbc.update("UPDATE ll_dispatch_item SET customer_id='C1'");
assertStatus(1, "IN_TRANSIT");
}
/** 筛选发生在分页之前;筛选总数、列表状态及详情一致并拒绝废弃筛选值。 */
@Test
void merchantPaginationCountsProjectedStatusAndRejectsUnusedFilters() {
order(1, "C1", "WAITING_INBOUND"); order(2, "C1", "RECEIVING");
order(3, "C1", "PENDING_ACCEPTANCE"); order(4, "C2", "WAITING_INBOUND");
var first = service.page("C1", query(1, 1, " pending_dispatch "));
var second = service.page("C1", query(2, 1, "PENDING_DISPATCH"));
assertThat(first.total()).isEqualTo(2); assertThat(first.pages()).isEqualTo(2);
assertThat(second.total()).isEqualTo(2);
assertThat(first.records()).hasSize(1); assertThat(second.records()).hasSize(1);
assertThat(first.records().get(0).id()).isNotEqualTo(second.records().get(0).id());
for (var row : List.of(first.records().get(0), second.records().get(0))) {
assertThat(row.customerId()).isEqualTo("C1");
assertThat(row.status()).isEqualTo(service.merchantDetail("C1", row.orderNo()).summary().status());
}
for (var status : List.of("APPROVED", "REJECTED", "PROCESSING", "WAITING_INBOUND", "PENDING_REVIEW")) {
assertThatThrownBy(() -> service.page("C1", query(1, 20, status)))
.isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
}
}
/** 构造有正式来源标识的原单,取送时间保持本地业务时间。 */
private void order(long id, String customer, String status) {
var order = new LocalLooseboardOrder();
order.setId(id); order.setCustomerId(customer); order.setOrderNo("LB" + id);
order.setTransferNo("W" + id); order.setLastlegOrderId(id + 1000);
order.setOrigin("LA"); order.setDestination("ONT"); order.setDeliveryMode("WAREHOUSE_PICKUP");
order.setPickupAt(LocalDateTime.of(2026, 11, 6, 9, 0)); order.setDeliveryAt(order.getPickupAt().plusHours(5));
order.setTotalWeightKg(BigDecimal.ZERO); order.setTotalVolumeCbm(BigDecimal.ZERO);
order.setCurrency("USD"); order.setStatus(status); order.setIdempotencyKey("K" + id); order.setRequestHash("0".repeat(64));
order.setCreatedAt(LocalDateTime.of(2026, 11, 6, 8, 0));
repository.insertOrder(order);
}
/** 建立原单到申报托盘的正式链路。 */
private void plan(long id, String customer) {
jdbc.update("INSERT INTO ll_inbound_plan VALUES(?,?,?)", id, id + 1000, customer);
jdbc.update("INSERT INTO ll_inbound_plan_pallet VALUES(?,?,?)", id, id, customer);
}
/** 建立可独立取消或推进的分车排车事实。 */
private void dispatch(long pallet, long id, String customer, String status, String stage) {
jdbc.update("INSERT INTO ll_dispatch_order VALUES(?,?,?)", id, status, stage);
jdbc.update("INSERT INTO ll_dispatch_item VALUES(?,?,?)", pallet, id, customer);
}
/** 每个节点同时核对列表筛选总数和客户详情。 */
private void assertStatus(long id, String status) {
assertThat(service.merchantDetail("C1", "LB" + id).summary().status()).isEqualTo(status);
var page = service.page("C1", query(1, 20, status));
assertThat(page.total()).isEqualTo(1);
assertThat(page.records().get(0).orderNo()).isEqualTo("LB" + id);
}
/** 用同一路线和日期边界查询客户运输进度。 */
private LocalLooseboardRequests.MerchantQuery query(long page, long size, String status) {
return new LocalLooseboardRequests.MerchantQuery(page, size, null, "LA", status,
LocalDate.of(2026, 11, 6), LocalDate.of(2026, 11, 6));
}
}
@@ -47,13 +47,24 @@ class LocalLooseboardServiceTest {
@Mock LocalLooseboardOperationMapper operations;
@Mock LastlegOrderMapper lastlegOrders;
@Mock LastlegOrderStatusHistoryMapper lastlegHistories;
@Mock com.baiyue.lastleg.looseboard.port.LooseboardDispatches dispatches;
@Mock com.baiyue.lastleg.wallet.budget.OrderBudgetService budgets;
@Spy LastlegOrderNoGenerator lastlegOrderNoGenerator = com.baiyue.lastleg.service.TestDocumentNumbers.orders();
private LocalLooseboardService service;
private final Map<String, LocalLooseboardOperation> operationRows = new HashMap<>();
/** 注入真实仓储组合及客户隔离的排车查询端口。 */
@BeforeEach
void setUp() {
service = new LocalLooseboardService();
ReflectionTestUtils.setField(service, "orderBudget", budgets);
org.mockito.Mockito.lenient().when(budgets.submit(anyString(),anyString(),anyString(),any(),anyString(),any(),any()))
.thenAnswer(call -> ((java.util.function.Supplier<?>)call.getArgument(5)).get());
org.mockito.Mockito.lenient().when(budgets.update(anyString(),anyString(),anyString(),any(),anyString(),any()))
.thenAnswer(call -> ((java.util.function.Supplier<?>)call.getArgument(5)).get());
org.mockito.Mockito.lenient().when(budgets.withOrder(anyString(),anyString(),anyString(),any()))
.thenAnswer(call -> ((java.util.function.Supplier<?>)call.getArgument(3)).get());
ReflectionTestUtils.setField(service, "dispatches", dispatches);
org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries.class));
var repository = new LocalLooseboardRepository();
ReflectionTestUtils.setField(service, "repository", repository);
@@ -77,6 +88,19 @@ class LocalLooseboardServiceTest {
});
}
/** 无车队预订的普通散板也返回全部排车,客户和原单必须原样限定。 */
@Test
void detailIncludesAllDispatchesWithoutFleetBooking() {
when(orders.findNo("100", "LB1")).thenReturn(order(LocalLooseboardService.IN_TRANSIT, 1L));
when(dispatches.looseboardDispatches("100", "LB1")).thenReturn(List.of("PC1", "PC2"));
var detail = service.detail("100", "LB1");
assertEquals(List.of("PC1", "PC2"), detail.dispatchNos());
org.junit.jupiter.api.Assertions.assertNull(detail.summary().fleetArrangement());
verify(dispatches).looseboardDispatches("100", "LB1");
}
@Test
void createIsTenantScopedIdempotentAndStartsPendingAcceptance() {
AtomicReference<LocalLooseboardOrder> saved = new AtomicReference<>();
@@ -90,7 +114,7 @@ class LocalLooseboardServiceTest {
var detail = service.create("100", "7", create("key-1"));
assertEquals("100", detail.summary().customerId());
assertEquals(LocalLooseboardService.PENDING_ACCEPTANCE, detail.summary().status());
assertEquals(LocalLooseboardService.FUNDING_PENDING, detail.summary().status());
assertEquals("Alice", detail.summary().originContactName());
assertEquals("Bob", detail.summary().destinationContactName());
assertEquals("家居用品", detail.summary().productName());
@@ -53,6 +53,18 @@ class OrderBudgetAuthorizationTest {
verify(service).retryOrder("9","trucking","KC1");
assertThatThrownBy(()->controller.retry(writer,"procurement","CG1")).isInstanceOf(AccessDeniedException.class);
}
@Test void looseboardFundingUsesOnlyItsOwnCustomerAndPermissions() {
var controller=context.getBean(MerchantOrderBudgetController.class);
var service=context.getBean(OrderBudgetService.class);
var reader=login(9,"MERCHANT","merchant:cross-border:pallet-shipping:list");
controller.readOrder(reader,"looseboard","LB1");verify(service).readOrder("9","looseboard","LB1");
assertThatThrownBy(()->controller.retry(reader,"looseboard","LB1")).isInstanceOf(AccessDeniedException.class);
var writer=login(9,"MERCHANT","merchant:cross-border:pallet-shipping:create");
controller.retry(writer,"looseboard","LB1");verify(service).retryOrder("9","looseboard","LB1");
assertThatThrownBy(()->controller.retry(writer,"trucking","LB1")).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->controller.readOrder(writer,"procurement","LB1")).isInstanceOf(AccessDeniedException.class);
}
private AuthenticatedUser login(long customer,String app,String permission) {
var user=new AuthenticatedUser(7L,customer,customer,8L,"operator",app,"WEB",Set.of(permission));
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,"",List.of(new SimpleGrantedAuthority(permission))));return user;
@@ -12,6 +12,11 @@ import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.UUID;
import java.util.regex.Pattern;
import com.baiyue.lastleg.looseboard.mapper.*;
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
import com.baiyue.lastleg.looseboard.service.LocalLooseboardService;
import com.baiyue.lastleg.looseboard.port.LooseboardReceiving;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.*;
/** 用真实 Mapper 验证客户隔离、持久化及到期/终态释放候选。 */
@@ -31,7 +36,7 @@ class OrderBudgetPersistenceTest {
var outbox = Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS ll_event_outbox \\(.*?;").matcher(sql);
assertThat(outbox.find()).isTrue();jdbc.execute(outbox.group());
jdbc.execute("CREATE TABLE ll_overseas_procurement_order(customer_id VARCHAR(64),order_no VARCHAR(64),status VARCHAR(32))");
for (String table : new String[]{"ll_us_truck_order", "ll_us_truck_order_pallet", "ll_us_truck_order_operation", "ll_us_truck_order_status_history"}) {
for (String table : new String[]{"ll_us_truck_order", "ll_us_truck_order_pallet", "ll_us_truck_order_operation", "ll_us_truck_order_status_history", "ll_local_looseboard_order", "ll_local_looseboard_operation", "ll_local_looseboard_history"}) {
var ddl = Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS " + table + " \\(.*?;").matcher(sql);
assertThat(ddl.find()).isTrue(); jdbc.execute(ddl.group());
}
@@ -41,6 +46,9 @@ class OrderBudgetPersistenceTest {
config.addMapper(com.baiyue.lastleg.trucking.mapper.UsTruckOrderPalletMapper.class);
config.addMapper(com.baiyue.lastleg.trucking.mapper.UsTruckOrderOperationMapper.class);
config.addMapper(com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper.class);
config.addMapper(LocalLooseboardOrderMapper.class);
config.addMapper(LocalLooseboardOperationMapper.class);
config.addMapper(LocalLooseboardHistoryMapper.class);
var factory = new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
sessions = new SqlSessionTemplate(factory.getObject());
repository = new OrderBudgetRepository(sessions.getMapper(OrderBudgetMapper.class));
@@ -60,10 +68,11 @@ class OrderBudgetPersistenceTest {
}
/** 两类业务创建失败时,真实预算、订单及Outbox必须整体回滚。 */
@Test void bothBusinessesRollbackOrderBudgetAndOutboxTogether() {
for (String kind : new String[]{"procurement","trucking"}) {
String table = "procurement".equals(kind) ? "ll_overseas_procurement_order" : "ll_us_truck_order";
for (String kind : new String[]{"procurement","trucking","looseboard"}) {
String table = switch(kind) { case "procurement" -> "ll_overseas_procurement_order"; case "trucking" -> "ll_us_truck_order"; default -> "ll_local_looseboard_order"; };
assertThatThrownBy(() -> service.submit("9",kind,"rollback",BigDecimal.TEN,"CNY",() -> {
if ("trucking".equals(kind)) jdbc.update("INSERT INTO ll_us_truck_order(id,customer_id,order_no,origin,destination,status,idempotency_key,request_hash) VALUES(1,'9','FAIL','A','B','FUNDING_PENDING','rollback',?)", "a".repeat(64));
else if ("looseboard".equals(kind)) jdbc.update("INSERT INTO ll_local_looseboard_order(id,customer_id,order_no,origin,destination,status,idempotency_key,request_hash) VALUES(1,'9','FAIL','A','B','FUNDING_PENDING','rollback',?)", "a".repeat(64));
else jdbc.update("INSERT INTO " + table + " VALUES('9','FAIL','FUNDING_PENDING')");
throw new IllegalStateException("订单明细写入失败");
}, value -> String.valueOf(value))).hasMessageContaining("订单明细写入失败");
@@ -74,12 +83,113 @@ class OrderBudgetPersistenceTest {
}
/** 成功受理原子提交并可同键重放,资金指令不能重复。 */
@Test void acceptsAndReplaysWithoutDuplicateFreeze() {
for (String kind : new String[]{"procurement","trucking"}) {
for (String kind : new String[]{"procurement","trucking","looseboard"}) {
service.submit("9",kind,"accept",BigDecimal.TEN,"CNY",()->"ORDER1",v->v);
service.submit("9",kind,"accept",BigDecimal.TEN,"CNY",()->"ORDER1",v->v);
assertThat(repository.lockOrder("9",kind,"ORDER1").getStatus()).isEqualTo("FREEZE_PENDING");
}
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(3);
}
/** 散板原单、资金回执、审核和释放使用真实Mapper,重复请求不重复占款或建履约。 */
@Test void looseboardCreationFreezesAndApprovalReleasesTheSameBudget() {
var harness=looseboards();var looseboards=harness.orders();var request=looseboardRequest();
var created=looseboards.create("9","customer",request);
String number=created.summary().orderNo();
assertThat(created.summary().status()).isEqualTo("FUNDING_PENDING");
assertThatThrownBy(()->looseboards.review("9",number,"reviewer",new LocalLooseboardRequests.Review(0L,"APPROVE","early-review",null)))
.hasMessageContaining("冻结成功");
looseboards.create("9","customer",request);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(1);
var budget=repository.lockOrder("9","looseboard",number);
service.handle(truckReceipt(budget,"FREEZE","FROZEN"));
var ready=looseboards.detail("9",number);
assertThat(ready.summary().status()).isEqualTo("PENDING_ACCEPTANCE");
var review=new LocalLooseboardRequests.Review(ready.summary().version(),"APPROVE","review-key",null);
assertThat(looseboards.review("9",number,"reviewer",review).summary().status()).isEqualTo("WAITING_INBOUND");
looseboards.review("9",number,"reviewer",review);
org.mockito.Mockito.verify(harness.receiving(),org.mockito.Mockito.times(1)).approve(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.eq("reviewer"));
budget=repository.lockOrder("9","looseboard",number);
assertThat(budget.getStatus()).isEqualTo("RELEASE_PENDING");assertThat(budget.getReleaseRequested()).isTrue();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(2);
service.handle(truckReceipt(budget,"RELEASE","RELEASED"));
assertThat(repository.lockOrder("9","looseboard",number).getStatus()).isEqualTo("RELEASED");
assertThat(looseboards.create("9","customer",request).summary().orderNo()).isEqualTo(number);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(2);
}
/** 冻结失败不能审核,重试仍绑定原单;驳回同键重放只释放一次。 */
@Test void looseboardInsufficientFundsCanRetryAndRejectionReleasesOnce() {
var harness=looseboards();var looseboards=harness.orders();
String number=looseboards.create("9","customer",looseboardRequest()).summary().orderNo();
var budget=repository.lockOrder("9","looseboard",number);
service.handle(new com.baiyue.common.event.WalletCommandCompletedV1(budget.getCommandEventId(),"FREEZE",9L,OrderBudgetService.BUSINESS_TYPE,
budget.getId().toString(),"FAILED",null,budget.getAmountCny(),null,"可用余额和授信额度不足",java.time.Instant.now(),null,null));
assertThat(looseboards.detail("9",number).summary().status()).isEqualTo("FUNDING_FAILED");
assertThatThrownBy(()->looseboards.review("9",number,"reviewer",new LocalLooseboardRequests.Review(1L,"APPROVE","early",null)))
.hasMessageContaining("冻结成功");
assertThat(service.readOrder("9","looseboard",number).message()).contains("授信额度不足");
service.retryOrder("9","looseboard",number);
budget=repository.lockOrder("9","looseboard",number);service.handle(truckReceipt(budget,"FREEZE","FROZEN"));
var review=new LocalLooseboardRequests.Review(looseboards.detail("9",number).summary().version(),"REJECT","reject-key","路线不可承运");
looseboards.review("9",number,"reviewer",review);looseboards.review("9",number,"reviewer",review);
assertThat(looseboards.detail("9",number).summary().status()).isEqualTo("CANCELLED");
assertThat(repository.lockOrder("9","looseboard",number).getStatus()).isEqualTo("RELEASE_PENDING");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(3);
org.mockito.Mockito.verifyNoInteractions(harness.receiving());
}
/** 取消时冻结在途,成功回执只能触发释放,不能将原单重新改成待审核。 */
@Test void looseboardCancellationReleasesLateFreezeWithoutReopeningOrder() {
var looseboards=looseboards().orders();String number=looseboards.create("9","customer",looseboardRequest()).summary().orderNo();
var cancel=new LocalLooseboardRequests.ChangeStatus(0L,"CANCELLED","cancel-key",null);
looseboards.changeStatus("9",number,"operator",cancel);looseboards.changeStatus("9",number,"operator",cancel);
var budget=repository.lockOrder("9","looseboard",number);assertThat(budget.getReleaseRequested()).isTrue();
service.handle(truckReceipt(budget,"FREEZE","FROZEN"));
assertThat(repository.lockOrder("9","looseboard",number).getStatus()).isEqualTo("RELEASE_PENDING");
assertThat(looseboards.detail("9",number).summary().status()).isEqualTo("CANCELLED");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(2);
}
/** 审核履约创建失败回滚,不提前释放预算或保存已审核状态。 */
@Test void looseboardApprovalFailureKeepsOriginalHold() {
var harness=looseboards();var looseboards=harness.orders();String number=looseboards.create("9","customer",looseboardRequest()).summary().orderNo();
var budget=repository.lockOrder("9","looseboard",number);service.handle(truckReceipt(budget,"FREEZE","FROZEN"));
org.mockito.Mockito.when(harness.receiving().approve(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.anyString())).thenThrow(new IllegalStateException("履约创建失败"));
assertThatThrownBy(()->looseboards.review("9",number,"reviewer",new LocalLooseboardRequests.Review(1L,"APPROVE","failed-review",null)))
.hasMessageContaining("履约创建失败");
assertThat(looseboards.detail("9",number).summary().status()).isEqualTo("PENDING_ACCEPTANCE");
assertThat(repository.lockOrder("9","looseboard",number).getStatus()).isEqualTo("FROZEN");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(1);
}
private record Looseboards(LocalLooseboardService orders,LooseboardReceiving receiving) {}
/** 散板原单及历史使用生产持久化,仅隔离真正的收货履约入口。 */
private Looseboards looseboards() {
var orders=sessions.getMapper(LocalLooseboardOrderMapper.class);var history=sessions.getMapper(LocalLooseboardHistoryMapper.class);
var repo=new com.baiyue.lastleg.looseboard.repository.LocalLooseboardRepository();
ReflectionTestUtils.setField(repo,"orders",orders);ReflectionTestUtils.setField(repo,"histories",history);
ReflectionTestUtils.setField(repo,"operations",sessions.getMapper(LocalLooseboardOperationMapper.class));
var looseboards=new LocalLooseboardService();ReflectionTestUtils.setField(looseboards,"repository",repo);
ReflectionTestUtils.setField(looseboards,"objectMapper",new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules());
ReflectionTestUtils.setField(looseboards,"orderBudget",service);
ReflectionTestUtils.setField(looseboards,"dispatches",org.mockito.Mockito.mock(com.baiyue.lastleg.looseboard.port.LooseboardDispatches.class));
ReflectionTestUtils.setField(looseboards,"fleetPool",org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries.class));
var numbers=org.mockito.Mockito.mock(com.baiyue.lastleg.service.LastlegOrderNoGenerator.class);
org.mockito.Mockito.when(numbers.next("LOOSEBOARD")).thenReturn("LB-BUDGET-1");ReflectionTestUtils.setField(looseboards,"lastlegOrderNoGenerator",numbers);
var receiving=org.mockito.Mockito.mock(LooseboardReceiving.class);ReflectionTestUtils.setField(looseboards,"receiving",receiving);
org.mockito.Mockito.when(receiving.approve(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.anyString())).thenReturn(new LooseboardReceiving.Created("TRANSFER1",99L));
var actualFunding=new com.baiyue.lastleg.procurement.funding.OrderFundingService(
new com.baiyue.lastleg.procurement.funding.OrderFundingRepository(null,null,null,null,orders,history));
org.mockito.Mockito.doAnswer(call->{actualFunding.apply(call.getArgument(0),call.getArgument(1),call.getArgument(2));return null;})
.when(funding).apply(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.any());
return new Looseboards(looseboards,receiving);
}
/** 明确金额和币种,避免测试把未填金额冒充零价。 */
private LocalLooseboardRequests.Create looseboardRequest() {
return new LocalLooseboardRequests.Create("9","FBA",null,"WAREHOUSE_PICKUP",null,null,null,null,"LA","Street A","ONT8",null,
"货物",2,10,BigDecimal.TEN,BigDecimal.ONE,new BigDecimal("100.00"),"CNY","发货人","10086","收货人","10010",
LocalDateTime.of(2026,11,2,9,0),LocalDateTime.of(2026,11,3,9,0),"原始备注","looseboard-budget-key");
}
/** 本地真实建卡车单,核对冻结、审核释放、回执和重复审核的持久化结果。 */
@@ -99,7 +209,7 @@ class OrderBudgetPersistenceTest {
org.mockito.Mockito.when(numbers.next("TRUCK")).thenReturn("TRUCK-LOCAL-BUDGET-1");
org.springframework.test.util.ReflectionTestUtils.setField(trucks, "orderNoGenerator", numbers);
var actualFunding = new com.baiyue.lastleg.procurement.funding.OrderFundingService(
new com.baiyue.lastleg.procurement.funding.OrderFundingRepository(null, null, orders, histories));
new com.baiyue.lastleg.procurement.funding.OrderFundingRepository(null, null, orders, histories, null, null));
org.mockito.Mockito.doAnswer(call -> {
actualFunding.apply(call.getArgument(0), call.getArgument(1), call.getArgument(2)); return null;
}).when(funding).apply(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any());
@@ -158,7 +268,11 @@ class OrderBudgetPersistenceTest {
repository.insert(row(4,"FROZEN"));
repository.insert(row(5,"FREEZE_PENDING"));
var recentFailure = row(6,"RELEASE_FAILED");recentFailure.setReleaseRequested(true);repository.insert(recentFailure);
assertThat(repository.releasable()).extracting(OrderBudget::getId).containsExactly(1L,2L,3L);
var acceptedLooseboard=row(7,"FROZEN");acceptedLooseboard.setKind("looseboard");acceptedLooseboard.setOrderNo("LB1");repository.insert(acceptedLooseboard);
jdbc.update("INSERT INTO ll_local_looseboard_order(id,customer_id,order_no,origin,destination,status,idempotency_key,request_hash) VALUES(7,'9','LB1','A','B','WAITING_INBOUND','accepted',?)", "a".repeat(64));
var pendingLooseboard=row(8,"FROZEN");pendingLooseboard.setKind("looseboard");pendingLooseboard.setOrderNo("LB2");repository.insert(pendingLooseboard);
jdbc.update("INSERT INTO ll_local_looseboard_order(id,customer_id,order_no,origin,destination,status,idempotency_key,request_hash) VALUES(8,'9','LB2','A','B','PENDING_ACCEPTANCE','pending',?)", "a".repeat(64));
assertThat(repository.releasable()).extracting(OrderBudget::getId).containsExactly(1L,2L,3L,7L);
}
/** 测试数据与生产预占使用同一 UTC 时间,数据库会话保持东八区以覆盖时区差异。 */
private OrderBudget row(long id,String status) {
@@ -68,7 +68,7 @@ class OrderBudgetServiceTest {
assertThat(service.read("9","procurement","key").usable()).isFalse();
assertThat(service.read("9","procurement","key").message()).contains("过期");
}
@ParameterizedTest @ValueSource(strings={"procurement","trucking"})
@ParameterizedTest @ValueSource(strings={"procurement","trucking","looseboard"})
void reviewReleaseImmediatelyReleasesFrozenBudget(String kind) {
row.setKind(kind);
row.setOrderNo("CG-1"); row.setStatus("FROZEN");
@@ -15,6 +15,24 @@ class OrderFundingServiceTest {
OrderFundingRepository repository = mock(OrderFundingRepository.class);
OrderFundingService service = new OrderFundingService(repository);
/** 散板成功后进入原待审核状态,失败可重试;重复或取消后的回执不复活原单。 */
@Test void looseboardFundingPreservesAuditGateAndIgnoresLateResults() {
var budget = budget("looseboard");
var order = new com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder();
order.setId(1L);order.setCustomerId("9");order.setStatus("FUNDING_PENDING");order.setVersion(0L);order.setRemark("客户备注");
when(repository.looseboard("9","NO1")).thenReturn(order);
service.apply(budget,"FUNDING_FAILED","可用余额和授信额度不足");
assertThat(order.getStatus()).isEqualTo("FUNDING_FAILED");
service.apply(budget,"FUNDING_PENDING",null);
service.apply(budget,"PENDING_REVIEW",null);
service.apply(budget,"PENDING_REVIEW",null);
assertThat(order.getStatus()).isEqualTo("PENDING_ACCEPTANCE");
assertThat(order.getVersion()).isEqualTo(3L);assertThat(order.getRemark()).isEqualTo("客户备注");
order.setStatus("CANCELLED");service.apply(budget,"PENDING_REVIEW",null);
assertThat(order.getStatus()).isEqualTo("CANCELLED");
verify(repository,times(3)).history(eq(order),anyString(),anyString(),nullable(String.class),any());
}
/** 采购冻结成功后才待审核,重复成功无第二条历史。 */
@Test void purchaseSuccessIsIdempotentAndPreservesRemark() {
var budget = budget("procurement");