优化
This commit is contained in:
+16
@@ -268,12 +268,25 @@ public class DispatchAggregatePersistenceTest {
|
||||
var measurements=preview.pallets().stream().map(p->new DispatchContracts.LoadingMeasurement(p.warehousePalletId(),1,
|
||||
p.warehousePalletId()==501L?new BigDecimal("100"):new BigDecimal("200"),p.warehousePalletId()==501L?BigDecimal.ONE:new BigDecimal("2"),p.deliveryIndex())).toList();
|
||||
var request=new DispatchCommands.LoadingComplete(approved.recordVersion(),"explicit-loaded",preview.manifestHash(),measurements,"实际核对");
|
||||
// 捕获失败必须回滚同一原生装车事务,不能留下装车后状态或成功命令。
|
||||
org.mockito.Mockito.doThrow(new IllegalStateException("ownership-capture-failed")).doNothing()
|
||||
.when(f.ownedBusinessOwnership).capture(anyLong(),org.mockito.ArgumentMatchers.nullable(Long.class),
|
||||
anyString(),anyString(),any(java.time.Instant.class));
|
||||
assertThatThrownBy(()->f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),request)))
|
||||
.isInstanceOf(IllegalStateException.class).hasMessage("ownership-capture-failed");
|
||||
assertThat(f.db.queryForObject("SELECT stage FROM ll_dispatch_order",String.class)).isEqualTo("LOADING");
|
||||
assertThat(f.db.queryForObject("SELECT record_version FROM ll_dispatch_order",Long.class)).isEqualTo(approved.recordVersion());
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='explicit-loaded'",Long.class)).isZero();
|
||||
var loaded=f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),request));
|
||||
assertThat(loaded.stage()).isEqualTo(Stage.AFTER_LOADING);assertThat(loaded.result()).isInstanceOf(DispatchResults.Loaded.class);
|
||||
assertThat(f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED");
|
||||
var replay=f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),request));
|
||||
assertThat(replay.replayed()).isTrue();assertThat(replay.result()).isEqualTo(loaded.result());
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isEqualTo(2);
|
||||
org.mockito.Mockito.verify(f.ownedBusinessOwnership,org.mockito.Mockito.times(2)).capture(anyLong(),
|
||||
eq(context.resources().vehicle().vehicleId()),eq(context.resources().vehicle().mode().name()),
|
||||
anyString(),any(java.time.Instant.class));
|
||||
org.mockito.Mockito.verifyNoMoreInteractions(f.ownedBusinessOwnership);
|
||||
}
|
||||
|
||||
/** 合计相等但某项漏拣或归属缺失时,公共端口不能报告已核平。 */
|
||||
@@ -444,6 +457,8 @@ public class DispatchAggregatePersistenceTest {
|
||||
|
||||
/** 实际MyBatis和Spring本机内存事务;不使用返回成功的仓储替身。 */
|
||||
public static final class Fixture {
|
||||
final com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture ownedBusinessOwnership=
|
||||
mock(com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture.class);
|
||||
public final JdbcTemplate db; public final TransactionTemplate tx; public final DispatchAggregateService service;
|
||||
public final DataSourceTransactionManager transactions;public final ObjectMapper json;public final SqlSessionTemplate session;
|
||||
public final DispatchAggregateRepository repository;public final DispatchExecutionRepository executionReads;public final FleetTripExecutionService fleet;
|
||||
@@ -522,6 +537,7 @@ public class DispatchAggregatePersistenceTest {
|
||||
new DispatchSourceRepository(session.getMapper(DispatchCandidateMapper.class),session.getMapper(ReturnDispatchCandidateMapper.class),session.getMapper(DispatchSourceMapper.class)),rules,fleet,
|
||||
new DispatchCommandLedger(new DispatchCommandRepository(session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(json)),new DispatchCommandHasher(json),json);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"evidence",mock(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.class));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"ownedBusinessOwnership",ownedBusinessOwnership);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(gateway,"workflow",service);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"appointments",new DispatchAppointmentAllocations(mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class)));
|
||||
executionReads=new DispatchExecutionRepository(session.getMapper(DispatchExecutionReadMapper.class));
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package com.baiyue.lastleg.fleet.ownedbusiness;
|
||||
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.service.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Actor;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.util.Set;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 全车请求键唯一,原操作者、action、版本、载荷缺一不可。 */
|
||||
class OwnedBusinessCommandTest {
|
||||
/** 已有键不能按另一个operator维度重新执行或读取结果。 */
|
||||
@Test void anotherActorCannotBorrowOriginalKey() {
|
||||
var repo=mock(OwnedBusinessRepository.class);var service=new OwnedBusinessCommandService(repo,OwnedBusinessFixtures.codec());
|
||||
var row=service.create(1,OwnedBusinessFixtures.actor(),"original-key","SAVE_OPERATIONS",0L,"original","SUCCEEDED");
|
||||
when(repo.command(1,"original-key")).thenReturn(row);
|
||||
var denied=assertThrows(ResponseStatusException.class,()->service.existing(1,"original-key",new Actor("other","其他测试账号",Set.of())));
|
||||
assertEquals(403,denied.getStatusCode().value());verify(repo,never()).command(any());
|
||||
}
|
||||
/** 同一请求原版本0合法,改载荷或改版本不得借用原成功。 */
|
||||
@Test void fullOriginalIdentityIsRequiredForReplay() {
|
||||
var service=new OwnedBusinessCommandService(mock(OwnedBusinessRepository.class),OwnedBusinessFixtures.codec());
|
||||
var row=service.create(1,OwnedBusinessFixtures.actor(),"original-key","SAVE_OPERATIONS",0L,"original","SUCCEEDED");
|
||||
assertDoesNotThrow(()->service.same(row,"SAVE_OPERATIONS",0L,"original"));
|
||||
assertThrows(BizException.class,()->service.same(row,"SAVE_OPERATIONS",1L,"original"));
|
||||
assertThrows(BizException.class,()->service.same(row,"SAVE_OPERATIONS",0L,"changed"));
|
||||
assertThrows(BizException.class,()->service.same(row,"BIND_PHOTO",0L,"original"));
|
||||
}
|
||||
/** 未知上传只有原状态,不创建第二个File调用意图。 */
|
||||
@Test void unknownUploadReservationDoesNotBecomeNewWork() {
|
||||
var repo=mock(OwnedBusinessRepository.class);var service=new OwnedBusinessCommandService(repo,OwnedBusinessFixtures.codec());
|
||||
var row=service.create(1,OwnedBusinessFixtures.actor(),"upload-key","UPLOAD_PHOTO",null,"original","UNKNOWN_RESULT");
|
||||
when(repo.command(1,"upload-key")).thenReturn(row);
|
||||
var reservation=service.reserveUpload(1,OwnedBusinessFixtures.actor(),"upload-key","original");
|
||||
assertFalse(reservation.created());assertEquals("UNKNOWN_RESULT",reservation.command().getStatus());verify(repo,never()).command(any());
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.baiyue.lastleg.fleet.ownedbusiness;
|
||||
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.PhotoSlot;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.LocalDateTime;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** 本域消费者的文件归属及格式回归,不重复File提供者全量测试。 */
|
||||
class OwnedBusinessFileGatewayTest {
|
||||
/** 图片文本伪装、PDF及空内容均须在File调用前拒绝。 */
|
||||
@Test void invalidImageSignaturesAndMimeAreRejected() {
|
||||
assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(new OwnedBusinessFileGateway.Upload("x.png","image/png","not-image".getBytes())));
|
||||
assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(new OwnedBusinessFileGateway.Upload("x.pdf","application/pdf","%PDF".getBytes())));
|
||||
assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(new OwnedBusinessFileGateway.Upload("x.jpg","image/jpeg",new byte[0])));
|
||||
}
|
||||
/** 同一原dispatch内部ID、平台0与分类都匹配才接受元数据。 */
|
||||
@Test void metadataCannotCrossCustomerDispatchOrCategory() {
|
||||
var good=metadata(0L,"1","OVERSEAS_DISPATCH_LOADING_PHOTO");
|
||||
assertEquals(9,HttpOwnedBusinessFileGateway.verified(new HttpOwnedBusinessFileGateway.Envelope("200",good),1,PhotoSlot.LOADING_SCENE,9L).fileId());
|
||||
assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.verified(new HttpOwnedBusinessFileGateway.Envelope("200",metadata(101L,"1","OVERSEAS_DISPATCH_LOADING_PHOTO")),1,PhotoSlot.LOADING_SCENE,9L));
|
||||
assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.verified(new HttpOwnedBusinessFileGateway.Envelope("200",metadata(0L,"2","OVERSEAS_DISPATCH_LOADING_PHOTO")),1,PhotoSlot.LOADING_SCENE,9L));
|
||||
assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.verified(new HttpOwnedBusinessFileGateway.Envelope("200",good),1,PhotoSlot.FUEL_START,9L));
|
||||
}
|
||||
/** File所有权使用原dispatch而非trip或客户,照片框不能改变类型。 */
|
||||
private HttpOwnedBusinessFileGateway.Metadata metadata(Long tenant,String businessId,String type) {
|
||||
return new HttpOwnedBusinessFileGateway.Metadata(9L,tenant,type,businessId,"actual.png","image/png",20L,LocalDateTime.parse("2026-10-20T07:30:00"));
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package com.baiyue.lastleg.fleet.ownedbusiness;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Actor;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
|
||||
|
||||
/** 仅本地测试夹具,不操作业务环境或生成运行样本。 */
|
||||
final class OwnedBusinessFixtures {
|
||||
/** 夹具容器不允许实例化。 */
|
||||
private OwnedBusinessFixtures() {}
|
||||
/** Jackson包含实际时间类型,避免把日期当假字符串。 */
|
||||
static OwnedBusinessSupport codec(){return new OwnedBusinessSupport(new ObjectMapper().findAndRegisterModules());}
|
||||
/** 平台列表、编辑、上传权限保持独立。 */
|
||||
static Actor actor(){return new Actor("operator-1","TEST操作员",Set.of(LIST,EDIT,UPLOAD));}
|
||||
/** 装车完成但未发车,证明不依赖DEPART历史。 */
|
||||
static Source source() {
|
||||
var row=new Source();row.setId(1L);row.setDispatchNo("DS-OWN-ONE");row.setTripId(2L);row.setStatus("ACTIVE");row.setStage("AFTER_LOADING");
|
||||
row.setRecordVersion(8L);row.setOverseasWarehouseId("9");row.setOverseasWarehouseCode("WH-9");row.setOverseasWarehouseName("测试仓");
|
||||
row.setPlannedDepartureAt(LocalDateTime.parse("2026-10-20T08:00:00"));row.setLoadingCompletedAt(LocalDateTime.parse("2026-10-20T07:30:00"));
|
||||
row.setLoadingCompletionHash("actual-load");row.setOwnedLoadingCompletionHash("actual-load");row.setOwnedLoadingCompletedAt(row.getLoadingCompletedAt());
|
||||
row.setOwnedResourceMode("MASTER");row.setOwnedVehicleOwnership("OWNED");row.setOwnedVehicleId(7L);row.setOwnedVehicleRecordVersion(3L);
|
||||
row.setResourcesJson("""
|
||||
{"vehicle":{"mode":"MASTER","vehicleId":7,"plate":"TEST-OWN-7","carrierName":null,"vehicleStatus":"IN_SERVICE",
|
||||
"capacity":{"palletSpaces":10,"maxLoadKg":1000,"maxVolumeCbm":20},"evidenceFileIds":[]},
|
||||
"driver":{"mode":"MASTER","driverId":8,"name":"测试司机","phone":null,"evidence":null}}
|
||||
""");
|
||||
row.setLoadingConfirmationJson("""
|
||||
{"manifestHash":"actual-manifest","pallets":[{"warehousePalletId":31,"palletSpaces":3,"weightKg":100,"volumeCbm":2,"deliveryIndex":1}],
|
||||
"operatorId":"picker","confirmedAt":"2026-10-20T07:30:00Z"}
|
||||
""");return row;
|
||||
}
|
||||
/** 两个客户逻辑来源共享一个真实物理托盘,不合并客户归属。 */
|
||||
static List<DispatchItem> cargo() {
|
||||
var first=item(11L,"101","IN-A");var second=item(12L,"102","IN-B");return List.of(first,second);
|
||||
}
|
||||
/** 来源事实的目的地键与正式排车提供者一致。 */
|
||||
private static DispatchItem item(long id,String customer,String inbound) {
|
||||
var i=new DispatchItem();i.setId(id);i.setDispatchOrderId(1L);i.setCustomerId(customer);i.setCustomerName("测试客户"+customer);
|
||||
i.setSourceKey("SOURCE:"+inbound);i.setOrderNo("ORDER-"+inbound);i.setInboundCode(inbound);i.setWarehousePalletId(31L);i.setPalletNo("PAL-31");
|
||||
i.setDestinationType("FBA");i.setDestinationWarehouseCode("ONT8");i.setDestinationWarehouseName("ONT8仓");i.setPackageCount(1);return i;
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.baiyue.lastleg.fleet.ownedbusiness;
|
||||
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessOwnershipService;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Ownership;
|
||||
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import java.time.*;
|
||||
import java.util.Optional;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 归属来自真实装车时车辆,不从GET和当前主档补造历史。 */
|
||||
class OwnedBusinessOwnershipTest {
|
||||
/** 自有车辆无论承运何种货源,都按真实主档归属捕获。 */
|
||||
@Test void capturesRealOwnedVehicleAndOriginalLoadTime() {
|
||||
var repo=mock(OwnedBusinessRepository.class);var vehicles=mock(VehicleRepository.class);
|
||||
when(vehicles.findById(7)).thenReturn(Optional.of(vehicle("OWNED",3)));
|
||||
var service=new OwnedBusinessOwnershipService(repo,vehicles);var at=Instant.parse("2026-10-20T07:30:00Z");
|
||||
service.capture(1,7L,"MASTER","load-1",at);
|
||||
var saved=ArgumentCaptor.forClass(Ownership.class);verify(repo).capture(saved.capture());
|
||||
assertEquals("OWNED",saved.getValue().getVehicleOwnership());assertEquals(3L,saved.getValue().getVehicleRecordVersion());
|
||||
assertEquals(LocalDateTime.parse("2026-10-20T07:30:00"),saved.getValue().getLoadingCompletedAt());
|
||||
verify(vehicles,never()).lockById(anyLong());
|
||||
}
|
||||
/** 临时外配原模式EXTERNAL原样保存,不伪造车辆档案。 */
|
||||
@Test void externalHasNoMasterIdOrVersion() {
|
||||
var repo=mock(OwnedBusinessRepository.class);var vehicles=mock(VehicleRepository.class);
|
||||
new OwnedBusinessOwnershipService(repo,vehicles).capture(1,null,"EXTERNAL","load",Instant.parse("2026-10-20T07:30:00Z"));
|
||||
var saved=ArgumentCaptor.forClass(Ownership.class);verify(repo).capture(saved.capture());
|
||||
assertEquals("EXTERNAL",saved.getValue().getResourceMode());assertEquals("DISPATCHED",saved.getValue().getVehicleOwnership());
|
||||
assertNull(saved.getValue().getVehicleRecordVersion());verifyNoInteractions(vehicles);
|
||||
}
|
||||
/** 重放沿原证据,不读取后来可能变化的车辆归属。 */
|
||||
@Test void matchingCaptureReplaysWithoutReadingCurrentVehicle() {
|
||||
var repo=mock(OwnedBusinessRepository.class);var vehicles=mock(VehicleRepository.class);var owned=new Ownership();
|
||||
owned.setDispatchId(1L);owned.setVehicleId(7L);owned.setResourceMode("MASTER");owned.setVehicleOwnership("OWNED");
|
||||
owned.setLoadingCompletionHash("load");owned.setLoadingCompletedAt(LocalDateTime.parse("2026-10-20T07:30:00"));
|
||||
when(repo.ownership(1)).thenReturn(owned);
|
||||
new OwnedBusinessOwnershipService(repo,vehicles).capture(1,7L,"MASTER","load",Instant.parse("2026-10-20T07:30:00Z"));
|
||||
verifyNoInteractions(vehicles);verify(repo,never()).capture(any());
|
||||
assertThrows(BizException.class,()->new OwnedBusinessOwnershipService(repo,vehicles).capture(1,7L,"MASTER","different",Instant.parse("2026-10-20T07:30:00Z")));
|
||||
}
|
||||
/** MASTER没有真实车辆时阻断原装车事务,不退回外配。 */
|
||||
@Test void rejectsMissingVehicleInsteadOfInventingOwnership() {
|
||||
var repo=mock(OwnedBusinessRepository.class);var vehicles=mock(VehicleRepository.class);
|
||||
when(vehicles.findById(7)).thenReturn(Optional.empty());
|
||||
assertThrows(BizException.class,()->new OwnedBusinessOwnershipService(repo,vehicles).capture(1,7L,"MASTER","load",Instant.parse("2026-10-20T07:30:00Z")));
|
||||
verify(repo,never()).capture(any());
|
||||
}
|
||||
/** 真实车辆快照由端口返回,测试不依赖车辆存储实现。 */
|
||||
private VehicleRepository.VehicleSnapshot vehicle(String ownership,long version) {
|
||||
return new VehicleRepository.VehicleSnapshot(7,"TEST-7","TRUCK",null,0,null,null,"US",ownership,"IN_SERVICE",null,version,null,LocalDateTime.parse("2026-10-20T07:30:00"),10,null,null);
|
||||
}
|
||||
}
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
package com.baiyue.lastleg.fleet.ownedbusiness;
|
||||
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.mapper.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.service.*;
|
||||
import com.baiyue.lastleg.dispatch.service.DispatchAggregateService;
|
||||
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.junit.jupiter.api.*;
|
||||
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 org.springframework.transaction.support.TransactionTemplate;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
import java.time.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 本机H2/MyBatis真实四表回归,不调用环境或复用外部数据库。 */
|
||||
class OwnedBusinessPersistenceTest {
|
||||
JdbcTemplate jdbc;
|
||||
OwnedBusinessRepository repository;
|
||||
OwnedBusinessSupport codec;
|
||||
OwnedBusinessProjection projection;
|
||||
OwnedBusinessRecordService service;
|
||||
OwnedBusinessQueryService queries;
|
||||
OwnedBusinessCommandService commands;
|
||||
DataSourceTransactionManager manager;
|
||||
/** 精确读取根新增4表基线,测试只适配H2方言,不重写业务列或约束。 */
|
||||
@BeforeEach void setup() throws Exception {
|
||||
var ds=new DriverManagerDataSource("jdbc:h2:mem:owned-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000","sa","");
|
||||
jdbc=new JdbcTemplate(ds);manager=new DataSourceTransactionManager(ds);
|
||||
jdbc.execute("""
|
||||
CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,dispatch_no VARCHAR(128),trip_id BIGINT,status VARCHAR(32),
|
||||
stage VARCHAR(32),record_version BIGINT,planned_departure_at DATETIME(3),loading_completed_at DATETIME(3),
|
||||
loading_completion_hash VARCHAR(128),loading_confirmation_json LONGTEXT,resources_json LONGTEXT,
|
||||
vehicle_plate VARCHAR(64),overseas_warehouse_id VARCHAR(64),overseas_warehouse_code VARCHAR(64),overseas_warehouse_name VARCHAR(128))
|
||||
""");
|
||||
jdbc.execute("""
|
||||
CREATE TABLE ll_dispatch_item(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT,customer_id VARCHAR(64),
|
||||
customer_name VARCHAR(128),source_key VARCHAR(128),order_no VARCHAR(128),inbound_code VARCHAR(128),
|
||||
pallet_no VARCHAR(128),warehouse_pallet_id BIGINT,destination_type VARCHAR(32),
|
||||
destination_warehouse_code VARCHAR(128),destination_warehouse_name VARCHAR(128),fba_warehouse_code VARCHAR(128),
|
||||
delivery_address VARCHAR(256),postal_code VARCHAR(64),package_count INT)
|
||||
""");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_appointment_allocation(dispatch_id BIGINT,dispatch_item_id BIGINT,customer_id VARCHAR(64),batch_key VARCHAR(128),destination_key VARCHAR(128),package_count INT)");
|
||||
jdbc.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,appointment_no VARCHAR(64),confirmation_no VARCHAR(64),appointment_start_utc DATETIME(3))");
|
||||
jdbc.execute("""
|
||||
CREATE TABLE ll_dispatch_history(id BIGINT PRIMARY KEY,dispatch_id BIGINT,record_version BIGINT,action VARCHAR(32),
|
||||
operator_id VARCHAR(64),reason VARCHAR(1000),snapshot_json LONGTEXT,occurred_at DATETIME(3))
|
||||
""");
|
||||
String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
|
||||
for(String suffix:List.of("ownership","record","command","history")) {
|
||||
var matcher=Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_"+suffix+" \\(.*?;").matcher(schema);
|
||||
assertTrue(matcher.find(),"缺少根维护的"+suffix+"表");
|
||||
String ddl=matcher.group().replace(" JSON"," LONGTEXT").replace(" COLLATE utf8mb4_bin","").replaceFirst("(?s) ENGINE=InnoDB.*?;$",";");
|
||||
jdbc.execute(ddl);
|
||||
}
|
||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);
|
||||
config.addMapper(OwnedBusinessMapper.class);config.addMapper(OwnedBusinessQueryMapper.class);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
|
||||
var session=new SqlSessionTemplate(Objects.requireNonNull(factory.getObject()));
|
||||
repository=new OwnedBusinessRepository(session.getMapper(OwnedBusinessMapper.class),session.getMapper(OwnedBusinessQueryMapper.class));
|
||||
codec=OwnedBusinessFixtures.codec();projection=new OwnedBusinessProjection(codec);
|
||||
commands=new OwnedBusinessCommandService(repository,codec);queries=mock(OwnedBusinessQueryService.class);
|
||||
var source=OwnedBusinessFixtures.source();
|
||||
when(queries.requireOwned("DS-OWN-ONE")).thenReturn(source);
|
||||
when(queries.detailForWrite(any(),any())).thenAnswer(call->new Detail(
|
||||
projection.row(source,repository.record(1),repository.cargo(List.of(1L)),repository.batches(List.of(1L)),call.getArgument(0)),
|
||||
null,List.of(),projection.photos(repository.record(1))));
|
||||
service=tx(new OwnedBusinessRecordService(repository,queries,projection,commands,codec));
|
||||
jdbc.update("""
|
||||
INSERT INTO ll_dispatch_order(id,dispatch_no,trip_id,status,stage,record_version,planned_departure_at,
|
||||
loading_completed_at,loading_completion_hash,loading_confirmation_json,resources_json,vehicle_plate,
|
||||
overseas_warehouse_id,overseas_warehouse_code,overseas_warehouse_name)
|
||||
VALUES(1,'DS-OWN-ONE',2,'ACTIVE','AFTER_LOADING',8,?,?,?,?,?,'TEST-OWN-7','9','WH-9','测试仓')
|
||||
""",source.getPlannedDepartureAt(),source.getLoadingCompletedAt(),source.getLoadingCompletionHash(),source.getLoadingConfirmationJson(),source.getResourcesJson());
|
||||
for(var item:OwnedBusinessFixtures.cargo())jdbc.update("""
|
||||
INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,customer_name,source_key,order_no,inbound_code,
|
||||
pallet_no,warehouse_pallet_id,destination_type,destination_warehouse_code,destination_warehouse_name,package_count)
|
||||
VALUES(?,1,?,?,?,?,?,?,31,'FBA','ONT8','ONT8仓',1)
|
||||
""",item.getId(),item.getCustomerId(),item.getCustomerName(),item.getSourceKey(),item.getOrderNo(),item.getInboundCode(),item.getPalletNo());
|
||||
jdbc.update("INSERT INTO ll_dispatch_history VALUES(99,1,8,'LOADING_COMPLETE','picker',NULL,'{}',?)",source.getLoadingCompletedAt());
|
||||
var ownership=new OwnedBusinessRows.Ownership();ownership.setDispatchId(1L);ownership.setVehicleId(7L);
|
||||
ownership.setResourceMode("MASTER");ownership.setVehicleOwnership("OWNED");ownership.setVehicleRecordVersion(3L);
|
||||
ownership.setOwnershipCapturedAt(source.getLoadingCompletedAt());ownership.setLoadingCompletedAt(source.getLoadingCompletedAt());ownership.setLoadingCompletionHash("actual-load");
|
||||
repository.capture(ownership);
|
||||
}
|
||||
/** 明确NULL更新真实落库,原主单版本、货物和两个客户完全不改。 */
|
||||
@Test void savesThenClearsNullableValuesWithRealMapperCas() {
|
||||
var first=request("save-money-first",0,new Money("CNY","30"),"12.5","18.6",List.of(new Arrival("FBA:ONT8","2026-10-20T16:00:00+08:00")));
|
||||
var saved=service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",first);
|
||||
assertEquals("1",saved.row().recordVersion());assertEquals("30.00",saved.row().unitPrice());
|
||||
assertEquals("18.600",repository.record(1).getFuelConsumptionLitresPer100Km().toPlainString());
|
||||
assertEquals("2026-10-20T08:00:00Z",codec.list(repository.record(1).getArrivalsJson(),Arrival.class).get(0).actualArrivedAt());
|
||||
service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",request("clear-money-now",1,null,null,null,List.of()));
|
||||
var cleared=repository.record(1);assertNull(cleared.getCurrency());assertNull(cleared.getTotalAmount());
|
||||
assertNull(cleared.getActualDistanceMiles());assertNull(cleared.getFuelConsumptionLitresPer100Km());assertEquals(2L,cleared.getRecordVersion());
|
||||
assertEquals(8L,jdbc.queryForObject("SELECT record_version FROM ll_dispatch_order WHERE id=1",Long.class));
|
||||
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item",Integer.class));
|
||||
}
|
||||
/** 原成功载荷重放返回原版本,不能用新版本或不同操作者另起命令。 */
|
||||
@Test void replayAndStaleVersionDoNotCreateExtraRecordsOrHistory() {
|
||||
var first=request("first-operation",0,new Money("USD","0"),null,null,List.of());
|
||||
assertEquals("1",service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",first).row().recordVersion());
|
||||
assertEquals("1",service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",first).row().recordVersion());
|
||||
assertThrows(BizException.class,()->service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",request("stale-operation",0,null,null,null,List.of())));
|
||||
assertThrows(BizException.class,()->service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",request("first-operation",0,new Money("USD","1"),null,null,List.of())));
|
||||
assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_command",Integer.class));
|
||||
assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_history",Integer.class));
|
||||
}
|
||||
/** 非本车目的地和不规范单位输入不能产生附加行或命令。 */
|
||||
@Test void rejectsForeignDestinationBeforeAnyWrite() {
|
||||
assertThrows(BizException.class,()->service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",
|
||||
request("foreign-arrival",0,null,null,null,List.of(new Arrival("FBA:OTHER","2026-10-20T08:00:00Z")))));
|
||||
assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class));
|
||||
assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_command",Integer.class));
|
||||
}
|
||||
/** 原流水与附加流水用一次真实SQL分页,版本域不混用。 */
|
||||
@Test void historyMergesOriginalDispatchWithoutDuplicatingIt() {
|
||||
service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",request("history-register",0,null,null,"0",List.of()));
|
||||
var realQueries=new OwnedBusinessQueryService(repository,projection,codec,mock(DispatchAggregateService.class));
|
||||
var page=realQueries.history(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",1,20);
|
||||
assertEquals(2,page.total());assertEquals(2,page.records().size());
|
||||
var original=page.records().stream().filter(r->r.eventSource().equals("DISPATCH")).findFirst().orElseThrow();
|
||||
assertEquals("8",original.dispatchRecordVersion());assertNull(original.recordVersion());assertNull(original.operations());assertEquals(List.of(),original.photos());
|
||||
var added=page.records().stream().filter(r->r.eventSource().equals("OPERATIONS")).findFirst().orElseThrow();
|
||||
assertEquals("1",added.recordVersion());assertNull(added.dispatchRecordVersion());
|
||||
assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_history",Integer.class));
|
||||
}
|
||||
/** 当前页不是统计范围,装车后无DEPART也能入选;GET不建附加记录。 */
|
||||
@Test void listUsesFilteredSourceAndLeavesVirtualZeroUnwritten() {
|
||||
var realQueries=new OwnedBusinessQueryService(repository,projection,codec,mock(DispatchAggregateService.class));
|
||||
var list=realQueries.list(OwnedBusinessFixtures.actor(),new Query(1,20,"2026-10-20T00:00:00Z","2026-10-21T00:00:00Z","ONT8",null,"9"));
|
||||
assertEquals(1,list.total());assertEquals(1,list.summary().afterLoadingCount());assertEquals(1,list.summary().dispatchCount());
|
||||
assertEquals("0",list.records().get(0).recordVersion());assertEquals(1,list.records().get(0).actualPalletCount());
|
||||
assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class));
|
||||
var empty=realQueries.list(OwnedBusinessFixtures.actor(),new Query(1,20,"2026-10-20T08:00:00Z","2026-10-20T08:00:01Z",null,null,null));
|
||||
assertEquals(1,empty.total());
|
||||
var excluded=realQueries.list(OwnedBusinessFixtures.actor(),new Query(1,20,null,"2026-10-20T08:00:00Z",null,null,null));
|
||||
assertEquals(0,excluded.total());
|
||||
}
|
||||
/** 原车辆归属捕获与调用方事务同回滚,不留下假的历史所有权。 */
|
||||
@Test void ownershipCaptureRollsBackWithParentTransaction() {
|
||||
jdbc.update("DELETE FROM ll_fleet_owned_business_ownership");
|
||||
var vehicles=mock(VehicleRepository.class);
|
||||
when(vehicles.findById(7)).thenReturn(Optional.of(new VehicleRepository.VehicleSnapshot(7,"TEST-7","TRUCK",null,0,null,null,"US","OWNED","IN_SERVICE",null,3,null,LocalDateTime.now(),10,null,null)));
|
||||
var capture=tx(new OwnedBusinessOwnershipService(repository,vehicles));
|
||||
assertThrows(IllegalStateException.class,()->new TransactionTemplate(manager).execute(status->{
|
||||
capture.capture(1,7L,"MASTER","actual-load",Instant.parse("2026-10-20T07:30:00Z"));throw new IllegalStateException("模拟主装车回滚");
|
||||
}));
|
||||
assertNull(repository.ownership(1));verify(vehicles,never()).lockById(anyLong());
|
||||
}
|
||||
/** 两个真实数据库事务争用初始0版本,必须只有一个命令及历史成功。 */
|
||||
@Test void concurrentZeroVersionWritesHaveExactlyOneWinner() throws Exception {
|
||||
var pool=java.util.concurrent.Executors.newFixedThreadPool(2);
|
||||
var start=new java.util.concurrent.CountDownLatch(1);
|
||||
try {
|
||||
var first=pool.submit(()->compete(start,"concurrent-first"));
|
||||
var second=pool.submit(()->compete(start,"concurrent-second"));
|
||||
start.countDown();
|
||||
boolean a=first.get(10,java.util.concurrent.TimeUnit.SECONDS),b=second.get(10,java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertNotEquals(a,b);assertEquals(1L,repository.record(1).getRecordVersion());
|
||||
assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_command",Integer.class));
|
||||
assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_history",Integer.class));
|
||||
assertEquals(8L,jdbc.queryForObject("SELECT record_version FROM ll_dispatch_order WHERE id=1",Long.class));
|
||||
} finally {pool.shutdownNow();}
|
||||
}
|
||||
/** 只把真实版本冲突判为竞争失败,SQL和基础设施异常必须让回归失败。 */
|
||||
private boolean compete(java.util.concurrent.CountDownLatch start,String key) throws Exception {
|
||||
if(!start.await(5,java.util.concurrent.TimeUnit.SECONDS))throw new IllegalStateException("并发测试起点超时");
|
||||
try{service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",request(key,0,new Money("CNY","1"),null,null,List.of()));return true;}
|
||||
catch(BizException error){if(error.getResultCode()!=com.baiyue.common.core.api.ResultCode.CONFLICT)throw error;return false;}
|
||||
}
|
||||
/** 基于实际数据库事务代理调用,验证的不只是实现分支。 */
|
||||
@SuppressWarnings("unchecked")
|
||||
private <T>T tx(T value) {
|
||||
var proxy=new ProxyFactory(value);proxy.setProxyTargetClass(true);
|
||||
proxy.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource()));return (T)proxy.getProxy();
|
||||
}
|
||||
/** 每个请求保留原附加版本和明确量纲。 */
|
||||
private SaveOperations request(String key,long version,Money money,String distance,String fuel,List<Arrival> arrivals) {
|
||||
return new SaveOperations(String.valueOf(version),key,new Operations(money,distance,fuel,arrivals),"本机TEST合成记录");
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package com.baiyue.lastleg.fleet.ownedbusiness;
|
||||
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.service.*;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.util.*;
|
||||
import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 上传未知结果和绑定失败恢复回归,不发任何环境HTTP。 */
|
||||
class OwnedBusinessPhotoTest {
|
||||
OwnedBusinessPhotoService service;
|
||||
OwnedBusinessFileGateway files;
|
||||
OwnedBusinessRecordService records;
|
||||
OwnedBusinessQueryService queries;
|
||||
OwnedBusinessRepository repository;
|
||||
Map<String,OwnedBusinessRows.Command> ledger;
|
||||
/** 用独立命令集合记录原键,文件服务全部为明确的本机mock。 */
|
||||
@BeforeEach void setup() {
|
||||
var codec=OwnedBusinessFixtures.codec();repository=mock(OwnedBusinessRepository.class);queries=mock(OwnedBusinessQueryService.class);
|
||||
records=mock(OwnedBusinessRecordService.class);files=mock(OwnedBusinessFileGateway.class);ledger=new HashMap<>();
|
||||
when(queries.requireOwned("DS-OWN-ONE")).thenReturn(OwnedBusinessFixtures.source());
|
||||
when(repository.command(eq(1L),anyString())).thenAnswer(call->ledger.get(call.getArgument(1)));
|
||||
doAnswer(call->{OwnedBusinessRows.Command row=call.getArgument(0);ledger.put(row.getIdempotencyKey(),row);return null;}).when(repository).command(any(OwnedBusinessRows.Command.class));
|
||||
when(repository.finishUpload(any())).thenReturn(1);
|
||||
service=new OwnedBusinessPhotoService(queries,records,repository,new OwnedBusinessCommandService(repository,codec),
|
||||
new OwnedBusinessProjection(codec),codec,files);
|
||||
}
|
||||
/** 相同原上传请求只执行一次File,回执未绑定不能声称已经保存。 */
|
||||
@Test void successfulUploadReplaysOriginalFileWithoutUploadingAgain() {
|
||||
when(files.upload(eq(1L),eq(PhotoSlot.LOADING_SCENE),eq("operator-1"),any())).thenReturn(evidence());
|
||||
var first=service.upload(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,"upload-original",png());
|
||||
var replay=service.upload(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,"upload-original",png());
|
||||
assertEquals(first,replay);assertEquals("9",first.fileId());assertNull(first.savedAt());
|
||||
verify(files,times(1)).upload(anyLong(),any(),anyString(),any());verifyNoInteractions(records);
|
||||
}
|
||||
/** File响应未知必须保留原键并停写,原请求重试不能再次上传。 */
|
||||
@Test void unknownUploadStopsInsteadOfRepeatingFileWrite() {
|
||||
when(files.upload(anyLong(),any(),anyString(),any())).thenThrow(new IllegalStateException("模拟连接中断"));
|
||||
assertThrows(BizException.class,()->service.upload(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.FUEL_START,"upload-unknown",png()));
|
||||
assertThrows(BizException.class,()->service.upload(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.FUEL_START,"upload-unknown",png()));
|
||||
var recovered=service.command(OwnedBusinessFixtures.actor(),"DS-OWN-ONE","upload-unknown");
|
||||
assertEquals("UNKNOWN_RESULT",recovered.state());assertFalse(recovered.retryAllowed());assertNull(recovered.result());
|
||||
verify(files,times(1)).upload(anyLong(),any(),anyString(),any());
|
||||
}
|
||||
/** 相同键不能从装车框切换到油耗框,也不能由另一操作者复用。 */
|
||||
@Test void uploadIdentityIncludesActorSlotAndOriginalBytes() {
|
||||
when(files.upload(anyLong(),any(),anyString(),any())).thenReturn(evidence());
|
||||
service.upload(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,"upload-identity",png());
|
||||
assertThrows(BizException.class,()->service.upload(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.FUEL_START,"upload-identity",png()));
|
||||
var other=new Actor("other","其他测试账号",Set.of(LIST,UPLOAD));
|
||||
assertThrows(ResponseStatusException.class,()->service.command(other,"DS-OWN-ONE","upload-identity"));
|
||||
verify(files,times(1)).upload(anyLong(),any(),anyString(),any());
|
||||
}
|
||||
/** 绑定失败后仍消费原fileId/原绑定键,不触发新的上传。 */
|
||||
@Test void failedBindingRetainsOriginalFileForExplicitSameRequestRetry() {
|
||||
when(files.metadata(1,PhotoSlot.LOADING_SCENE,9)).thenReturn(evidence());
|
||||
when(records.bind(any(),eq("DS-OWN-ONE"),eq(PhotoSlot.LOADING_SCENE),any(),any()))
|
||||
.thenThrow(new BizException(ResultCode.CONFLICT,"模拟附加版本冲突")).thenReturn(new Detail(null,null,List.of(),List.of()));
|
||||
var request=new BindPhoto("0","bind-original","9");
|
||||
assertThrows(BizException.class,()->service.bind(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,request));
|
||||
assertNotNull(service.bind(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,request));
|
||||
verify(files,never()).upload(anyLong(),any(),anyString(),any());
|
||||
verify(files,times(2)).metadata(1,PhotoSlot.LOADING_SCENE,9);
|
||||
}
|
||||
/** 知道文件ID也不能越过当前/历史引用探测内容。 */
|
||||
@Test void unboundFileIdIsDeniedBeforeCallingFile() {
|
||||
assertThrows(BizException.class,()->service.content(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.RECEIPT,9,null));
|
||||
verifyNoInteractions(files);
|
||||
}
|
||||
/** 只读用户不能调用上传,File意图也不能落库。 */
|
||||
@Test void uploadPermissionIsIndependentFromList() {
|
||||
assertThrows(ResponseStatusException.class,()->service.upload(new Actor("read-only","只读测试账号",Set.of(LIST)),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,"upload-denied",png()));
|
||||
verifyNoInteractions(files);assertTrue(ledger.isEmpty());
|
||||
}
|
||||
/** 本机原始PNG签名用于消费者格式检查,不声称外部真实照片。 */
|
||||
private MockMultipartFile png(){return new MockMultipartFile("file","TEST.png","image/png",new byte[]{(byte)137,80,78,71,13,10,26,10,0});}
|
||||
/** 元数据归属在独立gateway测试核验,这里只验证命令生命周期。 */
|
||||
private OwnedBusinessFileGateway.Evidence evidence(){return new OwnedBusinessFileGateway.Evidence(9,"TEST.png","image/png",9,"2026-10-20T07:30:00");}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.baiyue.lastleg.fleet.ownedbusiness;
|
||||
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.service.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** 金额按物理托盘计算;原装车和来源客户事实不重新归并。 */
|
||||
class OwnedBusinessProjectionTest {
|
||||
/** 同一实托两客户仍是一托,不按3个标准托位或2个来源除价。 */
|
||||
@Test void sharedPhysicalPalletIsCountedOnceAndUnregisteredMoneyStaysNull() {
|
||||
var p=new OwnedBusinessProjection(OwnedBusinessFixtures.codec());var source=OwnedBusinessFixtures.source();
|
||||
var row=p.row(source,null,OwnedBusinessFixtures.cargo(),List.of(),OwnedBusinessFixtures.actor());
|
||||
assertEquals(1,row.actualPalletCount());assertEquals("0",row.recordVersion());assertEquals("8",row.dispatchRecordVersion());
|
||||
assertNull(row.operations().money());assertNull(row.unitPrice());assertNull(row.operations().fuelConsumptionLitresPer100Km());
|
||||
assertEquals(1,row.destinations().size());assertNull(row.departedAt());assertTrue(row.actions().edit().allowed());
|
||||
assertEquals(8L,source.getRecordVersion());
|
||||
}
|
||||
/** 明确0与未登记不同,单价只由服务端权威金额和实托计算。 */
|
||||
@Test void explicitZeroAndMoneyRoundingRemainAuthoritative() {
|
||||
var p=new OwnedBusinessProjection(OwnedBusinessFixtures.codec());var record=record();
|
||||
record.setCurrency("CNY");record.setTotalAmount(BigDecimal.ZERO);
|
||||
var zero=p.row(OwnedBusinessFixtures.source(),record,OwnedBusinessFixtures.cargo(),List.of(),OwnedBusinessFixtures.actor());
|
||||
assertEquals("0.00",zero.operations().money().amount());assertEquals("0.00",zero.unitPrice());
|
||||
record.setTotalAmount(new BigDecimal("10.01"));
|
||||
assertEquals("10.01",p.row(OwnedBusinessFixtures.source(),record,OwnedBusinessFixtures.cargo(),List.of(),OwnedBusinessFixtures.actor()).unitPrice());
|
||||
}
|
||||
/** 无实托不除零,不能用来源数或容量代替。 */
|
||||
@Test void emptyPhysicalManifestNeverDividesByZero() {
|
||||
var source=OwnedBusinessFixtures.source();source.setLoadingConfirmationJson("{\"manifestHash\":\"m\",\"pallets\":[],\"operatorId\":\"p\",\"confirmedAt\":\"2026-10-20T07:30:00Z\"}");
|
||||
var record=record();record.setCurrency("USD");record.setTotalAmount(BigDecimal.ONE);
|
||||
var row=new OwnedBusinessProjection(OwnedBusinessFixtures.codec()).row(source,record,OwnedBusinessFixtures.cargo(),List.of(),OwnedBusinessFixtures.actor());
|
||||
assertEquals(0,row.actualPalletCount());assertNull(row.unitPrice());assertFalse(row.sourceIssues().isEmpty());
|
||||
}
|
||||
/** null与0在每百公里油耗中同样保持区分,不沿用原型18.6。 */
|
||||
@Test void explicitFuelRateIsNotDerivedAsTotalFuel() {
|
||||
var record=record();record.setActualDistanceMiles(BigDecimal.ZERO);record.setFuelConsumptionLitresPer100Km(new BigDecimal("18.600"));
|
||||
var ops=new OwnedBusinessProjection(OwnedBusinessFixtures.codec()).operations(record);
|
||||
assertEquals("0",ops.actualDistanceMiles());assertEquals("18.600",ops.fuelConsumptionLitresPer100Km());
|
||||
}
|
||||
/** 虚拟记录和空照片保持真实空态。 */
|
||||
private Record record(){var r=new Record();r.setRecordVersion(1L);r.setArrivalsJson("[]");r.setPhotosJson("[]");return r;}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package com.baiyue.lastleg.fleet.ownedbusiness;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.lastleg.dispatch.service.DispatchAggregateService;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.service.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 同筛选完整统计、旧单不回填和权限隔离的直接回归。 */
|
||||
class OwnedBusinessQueryTest {
|
||||
/** 当前页只有一车时统计仍来自全量,日期边界转换为UTC且共用过滤。 */
|
||||
@Test void summaryDoesNotUseCurrentPageLengthAndDateUsesPlannedDeparture() {
|
||||
var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec();
|
||||
var service=new OwnedBusinessQueryService(repo,new OwnedBusinessProjection(codec),codec,mock(DispatchAggregateService.class));
|
||||
var after=new StageCount();after.setStage("AFTER_LOADING");after.setQuantity(2L);
|
||||
var completed=new StageCount();completed.setStage("DELIVERED");completed.setQuantity(1L);
|
||||
when(repo.summary(any())).thenReturn(List.of(after,completed));when(repo.page(any())).thenReturn(List.of(OwnedBusinessFixtures.source()));
|
||||
when(repo.records(any())).thenReturn(List.of());when(repo.cargo(any())).thenReturn(OwnedBusinessFixtures.cargo());when(repo.batches(any())).thenReturn(List.of());
|
||||
var result=service.list(OwnedBusinessFixtures.actor(),new Query(1,1,"2026-10-20T00:00:00+08:00","2026-10-21T00:00:00+08:00","ONT8",null,"9"));
|
||||
assertEquals(1,result.records().size());assertEquals(3,result.total());assertEquals(3,result.summary().dispatchCount());assertEquals(2,result.summary().afterLoadingCount());
|
||||
var search=ArgumentCaptor.forClass(Search.class);verify(repo).summary(search.capture());verify(repo).page(search.getValue());
|
||||
assertEquals(LocalDateTime.parse("2026-10-19T16:00:00"),search.getValue().from());
|
||||
assertEquals(LocalDateTime.parse("2026-10-20T16:00:00"),search.getValue().to());
|
||||
verify(repo,never()).lock(any());verify(repo,never()).capture(any());verify(repo,never()).update(any(),anyLong());
|
||||
}
|
||||
/** 无归属快照不可用当前主档补造,自有页失败不修改原排车。 */
|
||||
@Test void oldLoadedDispatchWithoutOwnershipIsNotReclassified() {
|
||||
var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec();var source=OwnedBusinessFixtures.source();
|
||||
source.setOwnedVehicleOwnership(null);when(repo.source("DS-OWN-ONE")).thenReturn(source);
|
||||
var dispatch=mock(DispatchAggregateService.class);var service=new OwnedBusinessQueryService(repo,new OwnedBusinessProjection(codec),codec,dispatch);
|
||||
assertThrows(BizException.class,()->service.detail(OwnedBusinessFixtures.actor(),"DS-OWN-ONE"));
|
||||
verifyNoInteractions(dispatch);verify(repo,never()).capture(any());assertEquals(8L,source.getRecordVersion());
|
||||
}
|
||||
/** 装车前、非自有归属、失配的原装车hash均不能作为本页可写来源。 */
|
||||
@Test void stageAndFrozenOwnershipFactsAreBothRequired() {
|
||||
var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec();
|
||||
var service=new OwnedBusinessQueryService(repo,new OwnedBusinessProjection(codec),codec,mock(DispatchAggregateService.class));
|
||||
var source=OwnedBusinessFixtures.source();when(repo.source("DS-OWN-ONE")).thenReturn(source);
|
||||
source.setStage("LOADING");assertThrows(BizException.class,()->service.requireOwned("DS-OWN-ONE"));
|
||||
source.setStage("AFTER_LOADING");source.setOwnedVehicleOwnership("DISPATCHED");assertThrows(BizException.class,()->service.requireOwned("DS-OWN-ONE"));
|
||||
source.setOwnedVehicleOwnership("OWNED");source.setOwnedLoadingCompletionHash("other");assertThrows(BizException.class,()->service.requireOwned("DS-OWN-ONE"));
|
||||
}
|
||||
/** 列表权限不自动授予编辑或上传,不暴露主流程写动作。 */
|
||||
@Test void readOnlyActorHasOnlyReadGates() {
|
||||
var actor=new Actor("reader","只读账号",Set.of(LIST));var row=new OwnedBusinessProjection(OwnedBusinessFixtures.codec())
|
||||
.row(OwnedBusinessFixtures.source(),null,OwnedBusinessFixtures.cargo(),List.of(),actor);
|
||||
assertFalse(row.actions().edit().allowed());assertFalse(row.actions().upload().allowed());
|
||||
}
|
||||
/** 客户Token即使携带同名权限也不成为平台运营身份。 */
|
||||
@Test void customerTokenCannotClaimPlatformThroughPermissions() {
|
||||
var codec=OwnedBusinessFixtures.codec();var user=new AuthenticatedUser(11L,101L,101L,1L,"客户账号","MERCHANT","WEB",Set.of(LIST,EDIT,UPLOAD));
|
||||
assertThrows(ResponseStatusException.class,()->codec.actor(user));
|
||||
var platform=new AuthenticatedUser(12L,0L,0L,2L,"平台账号","PLATFORM","WEB",Set.of(LIST));
|
||||
assertEquals("平台账号",codec.actor(platform).operatorName());assertEquals("12",codec.actor(platform).operatorId());
|
||||
}
|
||||
/** 缺版本和非规范值不能默认为合法0,超长Long也不能截断。 */
|
||||
@Test void versionsAndFuelRateNumbersAreStrict() {
|
||||
var codec=OwnedBusinessFixtures.codec();assertEquals(0,codec.version("0"));assertEquals("0.000",codec.decimal("0",9,3));
|
||||
for(String version:List.of("","-1","00","90071992547409919999"))assertThrows(BizException.class,()->codec.version(version));
|
||||
for(String value:List.of("-1","18.6001","NaN","18e1","01"))assertThrows(BizException.class,()->codec.decimal(value,9,3));
|
||||
assertThrows(BizException.class,()->codec.instant("2026-10-20T08:00:00"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user