修复费用记录、装柜完成及海外履约资料

This commit is contained in:
2026-10-21 23:20:09 +08:00
parent 5356a8c898
commit 2c3435adaf
62 changed files with 1622 additions and 298 deletions
@@ -26,7 +26,7 @@ class AppointmentImpactPersistenceTest {
return new AppointmentForecastClient.Fact(1L,revision,"CARRIER","REF",LocalDateTime.of(2026,10,10,12,0).plusMinutes(revision),LocalDateTime.of(2026,10,day,9,0),null);
}
static void observe(AnnotationConfigApplicationContext c,long revision,int day) {
when(c.getBean(AppointmentForecastClient.class).batch(anyString(),any())).thenReturn(ApiResponse.success(List.of(new AppointmentForecastClient.Forecast(1L,fact(revision,day),fact(1,15),"SOURCE_LOCAL_UNSPECIFIED"))));
when(c.getBean(AppointmentForecastClient.class).batch(anyString(),any())).thenReturn(ApiResponse.success(List.of(new AppointmentForecastClient.Forecast(1L,fact(revision,day),fact(1,15),"SOURCE_LOCAL_UNSPECIFIED",null))));
}
static AppointmentImpactService.Command command(AppointmentImpactService.View row,String key,String action) {
return new AppointmentImpactService.Command(row.version(),row.forecastToken(),key,action,"已联系收货方核实,保留原预约另行跟进","再次核对",null);
@@ -11,11 +11,21 @@ import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class WarehouseCalendarMapperTest {
public class WarehouseCalendarMapperTest {
/** H2 测试替身按真实 JSON 内容读取柜次,不把样例值写死到 SQL。 */
public static String jsonExtract(String json,String path) throws Exception {
return json==null?null:new com.fasterxml.jackson.databind.ObjectMapper().readTree(json).path(path.substring(2)).toString();
}
/** 测试替身复用 JSON 解码语义。 */
public static String jsonUnquote(String value) throws Exception {
return value==null?null:new com.fasterxml.jackson.databind.ObjectMapper().readTree(value).asText();
}
/** 同柜跨客户不重复,人工改期与已建计划日期分别保留。 */
@Test void deduplicatesCustomersPreservesManualAndFrozenDatesAndFindsCrossMonthRescheduling() throws Exception {
var ds = new JdbcDataSource(); ds.setURL("jdbc:h2:mem:warehouse-calendar;MODE=MySQL;DB_CLOSE_DELAY=-1");
try (var connection=ds.getConnection(); var sql=connection.createStatement()) {
sql.execute("CREATE ALIAS JSON_EXTRACT FOR 'com.baiyue.lastleg.calendar.WarehouseCalendarMapperTest.jsonExtract'");
sql.execute("CREATE ALIAS JSON_UNQUOTE FOR 'com.baiyue.lastleg.calendar.WarehouseCalendarMapperTest.jsonUnquote'");
sql.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,source_container_booking_id VARCHAR,container_no VARCHAR," +
"overseas_warehouse_id VARCHAR,overseas_warehouse_name VARCHAR,overseas_warehouse_code VARCHAR," +
"estimated_warehouse_at TIMESTAMP,arrival_time_version INT)");
@@ -26,6 +36,12 @@ class WarehouseCalendarMapperTest {
"(3,'A','2','CONT2','W','Warehouse','W','2026-11-10 09:00:00',2)," +
"(4,'A','3','CONT3','W','Warehouse','W','2026-11-12 09:00:00',0)");
sql.execute("INSERT INTO ll_inbound_plan VALUES(4,'A')");
sql.execute("ALTER TABLE ll_order ADD source_event_id VARCHAR");
sql.execute("ALTER TABLE ll_inbound_plan ADD planned_arrival_date DATE");
sql.execute("UPDATE ll_inbound_plan SET planned_arrival_date='2026-11-12'");
sql.execute("CREATE TABLE ll_loading_event_inbox(event_id VARCHAR,payload_json VARCHAR)");
sql.execute("INSERT INTO ll_loading_event_inbox VALUES('source-1','{\"cabinetNo\":\"D0001\"}')");
sql.execute("UPDATE ll_order SET source_event_id='source-1' WHERE source_container_booking_id='1'");
}
var config=new MybatisConfiguration(); config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));
config.addMapper(WarehouseCalendarMapper.class);
@@ -34,9 +50,11 @@ class WarehouseCalendarMapperTest {
var rows=session.getMapper(WarehouseCalendarMapper.class).calendar(LocalDateTime.parse("2026-11-01T00:00:00"),LocalDateTime.parse("2026-12-01T00:00:00"),List.of("1"));
assertEquals(3,rows.size());
assertEquals("1",rows.get(0).sourceContainerBookingId()); assertNull(rows.get(0).plannedAt());
assertEquals("D0001",rows.get(0).cabinetNo());
assertEquals(2,rows.get(1).version()); assertEquals("2026-11-10T09:00",rows.get(1).plannedAt().toString());
assertEquals(1,rows.get(2).planCount()); assertNotNull(rows.get(2).plannedAt());
// 清关提柜与日历必须读取相同的人工/冻结事实,默认日期由前端统一推算。
assertEquals(1,rows.get(2).planCount()); assertNull(rows.get(2).plannedAt());
assertEquals(java.time.LocalDate.of(2026,11,12),rows.get(2).plannedDate());
// 清关提柜与日历读取相同冻结事实,默认日期由共用服务端解析器返回。
var customs=session.getMapper(LastlegOrderMapper.class).findContainerArrivalTimes(List.of("1","2","3"));
assertEquals(3,customs.size());
for(var calendar:rows) {
@@ -10,10 +10,15 @@ import static org.mockito.Mockito.*;
class WarehouseCalendarServiceTest {
/** 读取资格不代替写权限,已有入库计划仍冻结时间。 */
@Test void keepsReadAccessSeparateFromWriteAndFreezesExistingPlans() {
var repository=mock(WarehouseCalendarRepository.class); var service=new WarehouseCalendarService(repository);
var repository=mock(WarehouseCalendarRepository.class);
var arrivals=mock(com.baiyue.lastleg.service.ContainerWarehouseArrivalService.class);
var service=new WarehouseCalendarService(repository,arrivals);
var query=new WarehouseCalendarModels.Query(LocalDate.of(2026,11,1),LocalDate.of(2026,12,1),List.of("1"));
var open=new WarehouseCalendarModels.Entry("1","CONT","Warehouse","W",null,null,0,0,false);
var frozen=new WarehouseCalendarModels.Entry("2","CONT2","Warehouse","W",null,null,1,1,false);
var open=new WarehouseCalendarModels.Entry("1","D0001","CONT","Warehouse","W",null,null,0,0,false,null,false);
var frozen=new WarehouseCalendarModels.Entry("2","D0002","CONT2","Warehouse","W",null,null,1,1,false,null,false);
when(arrivals.resolve(List.of("1","2"))).thenReturn(List.of(
new com.baiyue.lastleg.dto.ContainerArrivalTimeView("1",null,null,0,0,LocalDate.of(2026,11,2),true),
new com.baiyue.lastleg.dto.ContainerArrivalTimeView("2",null,null,1,1,LocalDate.of(2026,11,3),false)));
when(repository.calendar(query)).thenReturn(List.of(open,frozen));
assertTrue(service.calendar(query,true).get(0).canEdit()); assertFalse(service.calendar(query,true).get(1).canEdit());
assertTrue(service.calendar(query,false).stream().noneMatch(WarehouseCalendarModels.Entry::canEdit));
@@ -93,7 +93,7 @@ public class DispatchAggregatePersistenceTest {
var resources=e.f.json.readValue(row.getResourcesJson(),DispatchContracts.Resources.class);
var d=resources.driver();var proof=d.evidence();
var next=new DispatchContracts.Resources(resources.vehicle(),new DispatchContracts.DriverSnapshot(d.mode(),d.driverId(),d.name(),d.phone(),
new DispatchContracts.DriverEvidence(proof.licenseNo(),proof.licenseExpiresOn(),proof.fileIds(),proof.expectedTaskEndAt(),proof.photoFileIds(),81L)));
new DispatchContracts.DriverEvidence(proof.licenseNo(),proof.licenseExpiresOn(),proof.fileIds(),proof.expectedTaskEndAt(),proof.photoFileIds(),81L)), null);
e.f.db.update("UPDATE ll_dispatch_order SET task_type='PICKUP', resources_json=? WHERE dispatch_no=?",e.f.json.writeValueAsString(next),number);
var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER;
var uploaded=new com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File(91L,"pickup.png","image/png",3L);
@@ -167,7 +167,7 @@ public class DispatchAggregatePersistenceTest {
String dispatch(ResourceMode mode,Long driverId,List<Long> ids) throws Exception {
var created=f.tx.execute(t->f.service.create(f.actor,f.create("evidence-"+UUID.randomUUID(),List.of())));
var resource=new DispatchContracts.Resources(null,new DispatchContracts.DriverSnapshot(mode,driverId,"测试司机","123456789",
new DispatchContracts.DriverEvidence("TEST-LICENSE",LocalDate.of(2099,1,1),ids,Instant.parse("2098-01-01T00:00:00Z"),List.of())));
new DispatchContracts.DriverEvidence("TEST-LICENSE",LocalDate.of(2099,1,1),ids,Instant.parse("2098-01-01T00:00:00Z"),List.of())), null);
f.db.update("UPDATE ll_dispatch_order SET resources_json=? WHERE dispatch_no=?",f.json.writeValueAsString(resource),created.dispatchNo());return created.dispatchNo();
}
/** File内容固定为离线测试字节;真实复合归属由既有两个HTTP网关合同分别覆盖。 */
@@ -204,7 +204,7 @@ public class DispatchAggregatePersistenceTest {
/** 空单编辑保留零货量与版本命令,不借编辑隐式制造可装车来源。 */
@Test void emptyShellEditPreservesZeroCargoAndVersionedReplay() throws Exception {
var f=new Fixture();var created=f.tx.execute(t->f.service.create(f.actor,f.create("empty-edit",List.of())));
var request=new DispatchCommands.Update(0L,"empty-edit-original",null,null,null,null,"空单补充备注", true);
var request=new DispatchCommands.Update(0L,"empty-edit-original",null,null,null,null,"空单补充备注", true, null);
var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request));
assertThat(saved.stage()).isEqualTo(Stage.BEFORE_LOADING);assertThat(saved.counts().packageCount()).isZero();
assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion());
@@ -244,7 +244,7 @@ public class DispatchAggregatePersistenceTest {
}
@Test void updateSavesCurrentVehiclePlanAndReplayWithoutAnyVehicleAppointmentField() throws Exception {
var f=new Fixture();var input=f.configured("update-without-vehicle-appointment",List.of(f.source(1,"101",501,"ONT8",3)));var created=f.tx.execute(t->f.service.create(f.actor,input));
var arrival=input.plannedDepartureAt().plusSeconds(3600);var request=new DispatchCommands.Update(created.recordVersion(),"update-current-plan",input.vehicle(),input.driver(),input.plannedDepartureAt(),arrival,"调整本趟计划", true);
var arrival=input.plannedDepartureAt().plusSeconds(3600);var request=new DispatchCommands.Update(created.recordVersion(),"update-current-plan",input.vehicle(),input.driver(),input.plannedDepartureAt(),arrival,"调整本趟计划", true, null);
var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request));assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion());
var detail=f.tx.execute(t->f.service.detail(f.actor,created.dispatchNo()));assertThat(detail.dispatchInfo().estimatedArrivalAt()).isEqualTo(arrival);assertThat(detail.documents().remark()).isEqualTo("调整本趟计划");
var publicJson=f.json.valueToTree(detail);assertThat(publicJson.path("dispatchInfo").has("vehicleAppointment")).isFalse();assertThat(publicJson.path("dispatchInfo").has("fbaAppointments")).isTrue();
@@ -186,7 +186,7 @@ class DispatchBolReadPersistenceTest {
db.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT PRIMARY KEY,appointment_no VARCHAR(64),confirmation_no VARCHAR(128))");
db.execute("CREATE TABLE ll_inbound_fba_appointment_item(id BIGINT PRIMARY KEY,appointment_id BIGINT NOT NULL,source_key VARCHAR(160) NOT NULL,customer_id VARCHAR(128) NOT NULL,order_id BIGINT,fba_warehouse_code VARCHAR(128) NOT NULL,UNIQUE(appointment_id,source_key))");
var config=new MybatisConfiguration();config.addMapper(DispatchBolMapper.class);var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);factory.afterPropertiesSet();var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DispatchBolMapper.class);
var roots=mock(DispatchAggregateRepository.class);var row=new DispatchAggregate();row.setId(1L);row.setDispatchNo("DP-ONE");row.setOverseasWarehouseId("9");row.setResourcesJson(new ObjectMapper().writeValueAsString(new DispatchContracts.Resources(null,null)));when(roots.find("DP-ONE")).thenReturn(row);
var roots=mock(DispatchAggregateRepository.class);var row=new DispatchAggregate();row.setId(1L);row.setDispatchNo("DP-ONE");row.setOverseasWarehouseId("9");row.setResourcesJson(new ObjectMapper().writeValueAsString(new DispatchContracts.Resources(null,null, null)));when(roots.find("DP-ONE")).thenReturn(row);
service=new DispatchBolReadService(roots,new DispatchBolRepository(mapper),new ObjectMapper());
tx=new TransactionTemplate(new DataSourceTransactionManager(ds));tx.setReadOnly(true);tx.setIsolationLevel(TransactionDefinition.ISOLATION_REPEATABLE_READ);
}
@@ -17,7 +17,7 @@ class DispatchCapacityDemandPersistenceTest {
var service=new DispatchCapacityDemandService(f.repository,f.json);
assertThat(service.page(query()).records()).extracting(DispatchCapacityDemands.Demand::dispatchNo).containsExactly(created.dispatchNo());
assertThat(f.service.detail(f.actor,created.dispatchNo()).publishLooseboardDemand()).isTrue();
var update=new DispatchCommands.Update(created.recordVersion(),"pool-disable",null,null,null,null,null,false);
var update=new DispatchCommands.Update(created.recordVersion(),"pool-disable",null,null,null,null,null,false, null);
var saved=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),update));
assertThat(f.service.detail(f.actor,created.dispatchNo()).publishLooseboardDemand()).isFalse();
assertThat(service.page(query()).total()).isZero();
@@ -0,0 +1,178 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway;
import com.baiyue.lastleg.dispatch.port.DispatchCommands;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import com.baiyue.lastleg.dispatch.port.DispatchTypes.ResourceMode;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 维护资料与车辆照片经真实聚合、Fleet资源解析、持久化及审核往返。 */
class DispatchMaintenancePersistenceTest {
/** 主档和临时车辆均保留本趟照片与八项资料,不能被Fleet主档重建吞掉。 */
@ParameterizedTest
@ValueSource(strings = {"MASTER", "EXTERNAL"})
void maintenanceAndVehiclePhotosSurviveReplayAndBothReviews(String mode) throws Exception {
var f = new DispatchAggregatePersistenceTest.Fixture();
var input = f.configured("maintenance-create", List.of(f.source(1, "101", 501, "ONT8", 3)));
var created = f.tx.execute(t -> f.service.create(f.actor, input));
var vehicle = vehicle(f, mode, List.of(301L));
var maintenance = new Maintenance(LocalDate.of(2026, 11, 1), " FBA自提 ", "一提多卸", "混装",
" fleet@example.test ", "厢式货车", " account ", " appointment ");
var request = new DispatchCommands.Update(created.recordVersion(), "maintenance-save", vehicle, input.driver(),
input.plannedDepartureAt(), input.estimatedArrivalAt(), "保留原备注", true, maintenance);
var saved = f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), request));
assertThat(f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), request)).replayed()).isTrue();
f.approve(saved);
var detail = f.tx.execute(t -> f.service.detail(f.actor, created.dispatchNo()));
assertThat(detail.vehicleInfo().maintenance()).isEqualTo(maintenance);
assertThat(detail.vehicleInfo().maintenance().appointmentAccount()).isEqualTo("account");
assertThat(detail.vehicleInfo().vehicle().evidenceFileIds()).containsExactly(301L);
assertThat(detail.dispatchInfo().plannedDepartureAt()).isEqualTo(input.plannedDepartureAt());
assertThat(detail.dispatchInfo().departedAt()).isNull();
var persisted = f.json.readValue(f.repository.find(created.dispatchNo()).getResourcesJson(), Resources.class);
assertThat(persisted.maintenance()).isEqualTo(maintenance);
assertThat(persisted.vehicle().evidenceFileIds()).containsExactly(301L);
assertThat(f.repository.find(created.dispatchNo()).getRemark()).isEqualTo("保留原备注");
verify(files(f), atLeastOnce()).metadata(f.repository.find(created.dispatchNo()).getId(), DispatchEvidenceGateway.Kind.VEHICLE, 301L);
if (mode.equals("MASTER")) {
assertThat(detail.vehicleInfo().vehicle().plate()).isEqualTo("MASTER-71");
assertThat(detail.vehicleInfo().vehicle().capacity().palletSpaces()).isEqualTo(15);
}
}
/** 跨车、跨分类及失效照片的提供者拒绝必须整体回滚,MASTER同样不能绕过。 */
@ParameterizedTest
@ValueSource(strings = {"跨车", "错分类", "已失效"})
void invalidVehiclePhotoCannotChangeDraft(String reason) throws Exception {
var f = new DispatchAggregatePersistenceTest.Fixture();
var input = f.configured("invalid-vehicle-create", List.of());
var created = f.tx.execute(t -> f.service.create(f.actor, input));
var before = f.db.queryForMap("SELECT resources_json,record_version FROM ll_dispatch_order WHERE dispatch_no=?", created.dispatchNo());
var dispatchId = f.repository.find(created.dispatchNo()).getId();
doThrow(new IllegalArgumentException(reason)).when(files(f)).metadata(dispatchId, DispatchEvidenceGateway.Kind.VEHICLE, 301L);
var request = new DispatchCommands.Update(created.recordVersion(), "invalid-vehicle-save", vehicle(f, "MASTER", List.of(301L)),
input.driver(), input.plannedDepartureAt(), null, null, true, null);
assertThatThrownBy(() -> f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), request))).hasMessageContaining(reason);
assertThat(f.db.queryForMap("SELECT resources_json,record_version FROM ll_dispatch_order WHERE dispatch_no=?", created.dispatchNo())).isEqualTo(before);
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='invalid-vehicle-save'", Long.class)).isZero();
}
/** 维护整体为空或八字段为空均可保存空草稿,清空资料不会制造实际发车或正式预约。 */
@Test void emptyMaintenanceCanBeSavedAndCleared() throws Exception {
var f = new DispatchAggregatePersistenceTest.Fixture();
var created = f.tx.execute(t -> f.service.create(f.actor, f.create("empty-maintenance", List.of())));
var empty = f.json.readValue("{\"plannedDeliveryDate\":\" \",\"deliveryMethod\":\" \",\"pickupMethod\":\"\",\"loadType\":\" \",\"fleetEmail\":\" \",\"vehicleType\":\"\",\"appointmentAccount\":\" \",\"appointmentNo\":\"\"}", Maintenance.class);
assertThat(empty).isEqualTo(new Maintenance(null, null, null, null, null, null, null, null));
var saved = f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), new DispatchCommands.Update(
created.recordVersion(), "empty-maintenance-save", null, null, null, null, null, true, empty)));
f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), new DispatchCommands.Update(
saved.recordVersion(), "empty-maintenance-clear", null, null, null, null, null, true, null)));
var detail = f.tx.execute(t -> f.service.detail(f.actor, created.dispatchNo()));
assertThat(detail.vehicleInfo().maintenance()).isNull();
assertThat(detail.dispatchInfo().departedAt()).isNull();
}
/** 文本仅限约定选项和长度,ISO日期不能接受时间串或不存在的日期。 */
@Test void maintenanceRejectsUnknownOptionsAndOverlongText() throws Exception {
var json = new DispatchAggregatePersistenceTest.Fixture().json;
for (String field : List.of("deliveryMethod", "pickupMethod", "loadType", "vehicleType")) {
assertThatThrownBy(() -> json.treeToValue(json.createObjectNode().put(field, "未知"), Maintenance.class))
.hasRootCauseInstanceOf(IllegalArgumentException.class);
}
for (String field : List.of("fleetEmail", "appointmentAccount", "appointmentNo")) {
int max = field.equals("fleetEmail") ? 254 : 128;
assertThat(json.treeToValue(json.createObjectNode().put(field, "a".repeat(max)), Maintenance.class)).isNotNull();
assertThatThrownBy(() -> json.treeToValue(json.createObjectNode().put(field, "a".repeat(max + 1)), Maintenance.class))
.hasRootCauseInstanceOf(IllegalArgumentException.class);
}
for (String date : List.of("2026-10-32", "2026-02-29", "2026-10-21T10:00:00", "2026/10/21")) {
assertThatThrownBy(() -> json.treeToValue(json.createObjectNode().put("plannedDeliveryDate", date), Maintenance.class)).isInstanceOf(com.fasterxml.jackson.core.JsonProcessingException.class);
}
var parsed = json.treeToValue(json.createObjectNode().put("plannedDeliveryDate", "2026-10-21"), Maintenance.class);
assertThat(parsed.plannedDeliveryDate()).isEqualTo(LocalDate.of(2026, 10, 21));
assertThat(json.valueToTree(parsed).path("plannedDeliveryDate").asText()).isEqualTo("2026-10-21");
}
/** 取货资源投影和照片保存同样保留正式维护资料,不能在重建快照时掉字段。 */
@Test void transportResourceRebuildAndPhotoSaveKeepMaintenance() throws Exception {
var f = new DispatchAggregatePersistenceTest.Fixture();
var input = f.configured("projection-maintenance-create", List.of());
var created = f.tx.execute(t -> f.service.create(f.actor, input));
var maintenance = new Maintenance(LocalDate.of(2026, 11, 1), "外配", "一提一卸", "地板", null, "平板车", null, "AP-LOCAL");
f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), new DispatchCommands.Update(created.recordVersion(),
"projection-maintenance-save", vehicle(f, "MASTER", List.of(301L)), input.driver(), input.plannedDepartureAt(), null, null, true, maintenance)));
var projection = new com.baiyue.lastleg.dispatch.transport.TransportTaskProjection(f.repository,
mock(com.baiyue.lastleg.dispatch.transport.TransportTaskRepository.class),
mock(com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository.class), mock(DispatchNumberGenerator.class), f.json);
var resources = f.json.readValue(f.repository.find(created.dispatchNo()).getResourcesJson(), Resources.class);
f.tx.executeWithoutResult(t -> projection.savePhotos(created.dispatchNo(), f.actor.operatorId(), List.of(302L), resources.driver().evidence()));
var row = f.repository.find(created.dispatchNo());
assertThat(f.json.readValue(row.getResourcesJson(), Resources.class).maintenance()).isEqualTo(maintenance);
var trip = new com.baiyue.lastleg.fleet.trip.FleetTripEntity();
trip.setVehicleId(71L); trip.setVehiclePlate("MASTER-71");
ReflectionTestUtils.invokeMethod(projection, "resources", row, trip);
var rebuilt = f.json.readValue(row.getResourcesJson(), Resources.class);
assertThat(rebuilt.maintenance()).isEqualTo(maintenance);
assertThat(rebuilt.vehicle().evidenceFileIds()).containsExactly(302L);
}
/** 请求、资源与详情共用可空维护结构;文档中的每个选项都必须被正式DTO接受。 */
@Test void openApiPublishesSymmetricNullableMaintenanceContract() throws Exception {
var json = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules();
com.fasterxml.jackson.databind.JsonNode root;
try (var input = getClass().getResourceAsStream("/openapi/overseas-business-v1.yaml")) {
root = json.valueToTree(new org.yaml.snakeyaml.Yaml().load(input));
}
var schemas = root.path("components").path("schemas");
for (String name : List.of("DispatchUpdate", "DispatchResources", "DispatchVehicleInfo")) {
var field = schemas.path(name).path("properties").path("maintenance");
assertThat(field.path("nullable").asBoolean()).isTrue();
assertThat(field.path("allOf").get(0).path("$ref").asText()).isEqualTo("#/components/schemas/DispatchMaintenance");
for (var required : schemas.path(name).path("required")) assertThat(required.asText()).isNotEqualTo("maintenance");
}
var schema = schemas.path("DispatchMaintenance");
var fields = schema.path("properties");
assertThat(fields.size()).isEqualTo(Maintenance.class.getRecordComponents().length);
assertThat(schema.path("required").isMissingNode()).isTrue();
for (var field : Maintenance.class.getRecordComponents()) {
assertThat(fields.path(field.getName()).path("nullable").asBoolean()).isTrue();
for (var value : fields.path(field.getName()).path("enum")) {
assertThat(json.treeToValue(json.createObjectNode().put(field.getName(), value.asText()), Maintenance.class)).isNotNull();
}
}
assertThat(fields.path("plannedDeliveryDate").path("format").asText()).isEqualTo("date");
assertThat(fields.path("fleetEmail").path("maxLength").asInt()).isEqualTo(254);
assertThat(fields.path("appointmentAccount").path("maxLength").asInt()).isEqualTo(128);
assertThat(fields.path("appointmentNo").path("maxLength").asInt()).isEqualTo(128);
}
/** 主档请求只携带身份与本趟照片,名称及容量必须仍取权威主档。 */
private static DispatchCommands.Vehicle vehicle(DispatchAggregatePersistenceTest.Fixture f, String mode, List<Long> photos) {
if (mode.equals("MASTER")) {
var vehicles = (VehicleRepository) ReflectionTestUtils.getField(f.nativeFleetService, "vehicles");
var actual = new VehicleRepository.VehicleSnapshot(71L, "MASTER-71", null, null, 0L, "主档车队", null,
"OVERSEAS", "OWNED", "IN_SERVICE", null, 0L, null, null, 15, new BigDecimal("10000"), new BigDecimal("50"));
when(vehicles.findById(71L)).thenReturn(Optional.of(actual));
return new DispatchCommands.Vehicle(ResourceMode.MASTER, 71L, "不能覆盖主档", null, null, photos);
}
return new DispatchCommands.Vehicle(ResourceMode.EXTERNAL, null, "EXTERNAL-71", "本趟车队",
new Capacity(15, new BigDecimal("10000"), new BigDecimal("50")), photos);
}
/** 附件提供者仍执行本车及分类边界;只隔离外部File服务。 */
private static DispatchEvidenceGateway files(DispatchAggregatePersistenceTest.Fixture f) {
return (DispatchEvidenceGateway) ReflectionTestUtils.getField(f.service, "evidence");
}
}
@@ -83,7 +83,7 @@ class DispatchResourcePhotosPersistenceTest {
var e = d.evidence();
var driver = new DispatchCommands.Driver(d.mode(), d.driverId(), d.name(), d.phone(),
new DispatchContracts.DriverEvidence(e.licenseNo(), e.licenseExpiresOn(), e.fileIds(), e.expectedTaskEndAt(), photos));
return new DispatchCommands.Update(version, key, input.vehicle(), driver, input.plannedDepartureAt(), input.estimatedArrivalAt(), "维护照片", true);
return new DispatchCommands.Update(version, key, input.vehicle(), driver, input.plannedDepartureAt(), input.estimatedArrivalAt(), "维护照片", true, null);
}
/** 只替代外部附件提供者,资源、命令和事务仍走真实服务。 */
@@ -88,7 +88,7 @@ class DispatchReviewWorkflowPersistenceTest {
long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE plan_pallet_id=3",Long.class);
var removed=f.tx.execute(s->f.service.removeCargo(f.actor,created.dispatchNo(),item,new DispatchCommands.ReasonCommand(addedAgain.recordVersion(),"remove","调整目的仓")));
var resources=f.configured("updated-resources",List.of());
var updated=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(removed.recordVersion(),"update-vehicle",resources.vehicle(),resources.driver(),resources.plannedDepartureAt().plusSeconds(60),null,"客服更正", true)));
var updated=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(removed.recordVersion(),"update-vehicle",resources.vehicle(),resources.driver(),resources.plannedDepartureAt().plusSeconds(60),null,"客服更正", true, null)));
var detail=f.service.detail(f.actor,created.dispatchNo());
var retained=loading?detail.reviews().loadingReview():detail.reviews().dispatchReview();
assertThat(detail.reviewState()).isEqualTo(ReviewState.DRAFT);
@@ -55,13 +55,13 @@ class TransportReviewPhotosTest {
/** 换司机后旧人像不允许重复绑定。 */
@Test void rejectsPhotosCarriedFromAnotherDriver() throws Exception {
row.setResourcesJson(json.writeValueAsString(new Resources(null,new DriverSnapshot(ResourceMode.MASTER,5L,"原司机","000",
new DriverEvidence(null,null,List.of(23L),null,List.of(22L))))));
new DriverEvidence(null,null,List.of(23L),null,List.of(22L))), null)));
assertThatThrownBy(()->service.validate(row,trip,request(new ReviewAttachments(List.of(21L),List.of(22L),List.of(24L)),null))).hasMessageContaining("重新上传");
}
/** 审核复验已保存证据,照片后续失效也不能通过。 */
@Test void reviewRevalidatesPersistedPhotoOwnership() throws Exception {
row.setResourcesJson(json.writeValueAsString(new Resources(new VehicleSnapshot(ResourceMode.MASTER,1L,"TEST",null,null,null,List.of(21L)),
new DriverSnapshot(ResourceMode.MASTER,2L,"司机","000",new DriverEvidence(null,null,List.of(23L),null,List.of(22L))))));
new DriverSnapshot(ResourceMode.MASTER,2L,"司机","000",new DriverEvidence(null,null,List.of(23L),null,List.of(22L))), null)));
service.verifyReview(row,trip);verify(files).metadata(10L,VEHICLE,21L);
when(files.metadata(10L,DRIVER,23L)).thenThrow(new IllegalArgumentException("附件失效"));
assertThatThrownBy(()->service.verifyReview(row,trip)).hasMessageContaining("附件失效");
@@ -40,7 +40,7 @@ class OwnedBusinessScopePersistenceTest {
when(reader.detail(any(),anyString())).thenAnswer(call->{
var s=baseline.repository.source(call.getArgument(1));var r=baseline.codec.read(s.getResourcesJson(),DispatchContracts.Resources.class);
return new DispatchViews.Detail(s.getDispatchNo(),"DELIVERY",DispatchTypes.Status.ACTIVE,DispatchTypes.Stage.valueOf(s.getStage()),s.getRecordVersion(),
DispatchTypes.ReviewState.APPROVED,null,null,true,Map.of(),new DispatchViews.VehicleInfo(r.vehicle(),r.driver()),null,null,List.of(),true);
DispatchTypes.ReviewState.APPROVED,null,null,true,Map.of(),new DispatchViews.VehicleInfo(r.vehicle(),r.driver(), null),null,null,List.of(),true);
});
queries=tx(new OwnedBusinessQueryService(baseline.repository,baseline.projection,baseline.codec,reader));
var commands=tx(new OwnedBusinessCommandService(baseline.repository,baseline.codec));
@@ -67,7 +67,7 @@ class OwnedBusinessScopePersistenceTest {
var original=OwnedBusinessFixtures.source();
var resources=baseline.codec.read(original.getResourcesJson(),DispatchContracts.Resources.class);
var vehicle=new DispatchContracts.VehicleSnapshot(DispatchTypes.ResourceMode.valueOf(mode),"MASTER".equals(mode)?7L:null,"TEST-OUT-"+id,carrier,"IN_SERVICE",resources.vehicle().capacity(),List.of());
String snapshot=baseline.codec.write(new DispatchContracts.Resources(vehicle,resources.driver()));
String snapshot=baseline.codec.write(new DispatchContracts.Resources(vehicle,resources.driver(), null));
String load=original.getLoadingConfirmationJson().replace("\"warehousePalletId\":31","\"warehousePalletId\":"+(31+id));
baseline.jdbc.update("""
INSERT INTO ll_dispatch_order SELECT ?,?,trip_id,status,?,record_version,planned_departure_at,loading_completed_at,departed_at,delivery_completed_at,
@@ -58,12 +58,14 @@ class InboundPlanServiceTest {
@Mock WarehouseResourceGateway warehouseGateway;
@Mock LastlegOrderCommandRecordMapper commandMapper;
@Mock LastlegSourcePalletMapper sourcePalletMapper;
@Mock com.baiyue.lastleg.service.ContainerWarehouseArrivalService warehouseArrival;
private InboundPlanService service;
@BeforeEach
void setUp() {
service = new InboundPlanService();
ReflectionTestUtils.setField(service,"warehouseArrival",warehouseArrival);
ReflectionTestUtils.setField(service, "planMapper", planMapper);
ReflectionTestUtils.setField(service, "lineMapper", lineMapper);
ReflectionTestUtils.setField(service, "palletMapper", palletMapper);
@@ -95,8 +97,8 @@ class InboundPlanServiceTest {
"100", 2L, "BOOKING-1", "193000000000200003")));
org.mockito.Mockito.lenient().when(orderMapper.findSourceContainerBookingId(any(), anyLong()))
.thenReturn("BOOKING-1");
org.mockito.Mockito.lenient().when(orderMapper.containerEstimatedWarehouseTimes(any()))
.thenReturn(List.of(LocalDateTime.of(2026, 9, 15, 8, 0)));
org.mockito.Mockito.lenient().when(warehouseArrival.requiredDate(any()))
.thenReturn(java.time.LocalDate.of(2026, 9, 15));
}
@Test
@@ -176,15 +178,16 @@ class InboundPlanServiceTest {
void manualPlanRejectsMissingOrInconsistentContainerArrivalTime() {
ManualInboundPlanRequest request = new ManualInboundPlanRequest("100", 2L, 0,
2, "STANDARD", true, true, true, "优先收货");
when(orderMapper.containerEstimatedWarehouseTimes("BOOKING-1")).thenReturn(List.of());
when(warehouseArrival.requiredDate("BOOKING-1")).thenThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"来源柜预计到仓时间缺失,请先补齐柜级时间"));
ResponseStatusException missing = assertThrows(ResponseStatusException.class,
() -> service.convertManual(request, "manual-missing-arrival", "900"));
assertEquals(409, missing.getStatusCode().value());
assertEquals("来源柜预计到仓时间缺失,请先补齐柜级时间", missing.getReason());
when(orderMapper.containerEstimatedWarehouseTimes("BOOKING-1")).thenReturn(List.of(
LocalDateTime.of(2026, 9, 15, 8, 0), LocalDateTime.of(2026, 9, 16, 8, 0)));
org.mockito.Mockito.doThrow(new ResponseStatusException(
org.springframework.http.HttpStatus.CONFLICT,"来源柜预计到仓时间不一致,请先修正柜级时间"))
.when(warehouseArrival).requiredDate("BOOKING-1");
ResponseStatusException inconsistent = assertThrows(ResponseStatusException.class,
() -> service.convertManual(request, "manual-inconsistent-arrival", "900"));
assertEquals(409, inconsistent.getStatusCode().value());
@@ -0,0 +1,67 @@
package com.baiyue.lastleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.lastleg.appointmentplanning.AppointmentForecastClient;
import com.baiyue.lastleg.dto.ContainerArrivalTimeView;
import com.baiyue.lastleg.repository.ContainerWarehouseArrivalRepository;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class ContainerWarehouseArrivalServiceTest {
private final ContainerWarehouseArrivalRepository repository=mock(ContainerWarehouseArrivalRepository.class);
private final AppointmentForecastClient forecasts=mock(AppointmentForecastClient.class);
private final ContainerWarehouseArrivalService service=new ContainerWarehouseArrivalService(repository,forecasts,"internal-test");
/** 默认日期跨月按当地自然日推进,展示和计划创建必须读取同一结果。 */
@Test void defaultsAcrossMonthAndUsesExactlyTheSameDateForPlanning() {
when(repository.find(List.of("1"))).thenReturn(List.of(new ContainerArrivalTimeView("1",null,null,0,0,null,false)));
when(forecasts.batch(anyString(),any())).thenReturn(ApiResponse.success(List.of(
new AppointmentForecastClient.Forecast(1L,null,null,"SOURCE_LOCAL_UNSPECIFIED",LocalDateTime.of(2026,10,30,23,30)))));
var result=service.resolve(List.of("1")).get(0);
assertEquals(LocalDate.of(2026,11,1),result.plannedDate());
assertNull(result.estimatedWarehouseArrivalAt());
assertTrue(result.isDefault());
assertEquals(result.plannedDate(),service.requiredDate("1"));
}
/** 人工维护优先,已生成计划后的日期不再随到港变化重新计算。 */
@Test void manualAndFrozenDatesNeverReadNewForecasts() {
var manual=LocalDateTime.of(2026,11,3,9,30);
when(repository.find(List.of("1","2"))).thenReturn(List.of(
new ContainerArrivalTimeView("1",manual,null,1,0,null,false),
new ContainerArrivalTimeView("2",null,null,0,1,LocalDate.of(2026,11,4),false)));
var result=service.resolve(List.of("1","2"));
assertEquals(manual.toLocalDate(),result.get(0).plannedDate());
assertEquals(LocalDate.of(2026,11,4),result.get(1).plannedDate());
assertFalse(result.get(1).isDefault());
verifyNoInteractions(forecasts);
}
/** 真实未知日期不补零点,提供方漏行也不能当作完整空事实。 */
@Test void unknownAndIncompleteProviderResultsBlockPlanCreation() {
when(repository.find(List.of("1"))).thenReturn(List.of(new ContainerArrivalTimeView("1",null,null,0,0,null,false)));
when(forecasts.batch(anyString(),any())).thenReturn(ApiResponse.success(List.of(
new AppointmentForecastClient.Forecast(1L,null,null,"SOURCE_LOCAL_UNSPECIFIED",null))));
assertNull(service.resolve(List.of("1")).get(0).plannedDate());
assertEquals(409,assertThrows(ResponseStatusException.class,()->service.requiredDate("1")).getStatusCode().value());
when(forecasts.batch(anyString(),any())).thenReturn(ApiResponse.success(List.of()));
assertEquals(503,assertThrows(ResponseStatusException.class,()->service.requiredDate("1")).getStatusCode().value());
}
/** 同柜版本或已建计划日期冲突时不能被默认到港算法覆盖。 */
@Test void inconsistentOrMissingFrozenDatesRemainBlocked() {
for(var row:List.of(new ContainerArrivalTimeView("1",null,null,-1,0,null,false),
new ContainerArrivalTimeView("1",null,null,1,0,null,false),
new ContainerArrivalTimeView("1",null,null,0,2,null,false))) {
when(repository.find(List.of("1"))).thenReturn(List.of(row));
assertThrows(ResponseStatusException.class,()->service.requiredDate("1"));
}
verifyNoInteractions(forecasts);
}
}
@@ -8,7 +8,7 @@ class LastlegOrderNoGeneratorTest {
/** 各作业类型使用不同前缀,同类型第二次调用必须递增。 */
@Test void routesEveryOperationalTypeToItsDailySequence() {
var generator=TestDocumentNumbers.orders();
Map.ofEntries(Map.entry("OUTBOUND","CK"),Map.entry("ONE_CLICK_DROPSHIP","DFCK"),
Map.ofEntries(Map.entry("LASTLEG","WC"),Map.entry("OUTBOUND","CK"),Map.entry("ONE_CLICK_DROPSHIP","DFCK"),
Map.entry("RELABEL_OUTBOUND","HBCK"),Map.entry("RETURN_OUTBOUND","THCK"),
Map.entry("DELIVERY","DL"),Map.entry("TRUCK","TR"),Map.entry("LOOSEBOARD","LB"),
Map.entry("EXCEPTION","EX"),Map.entry("DISCARD","CD"),Map.entry("INBOUND_PLAN","IP"),
@@ -19,12 +19,4 @@ class LastlegOrderNoGeneratorTest {
});
assertThrows(IllegalArgumentException.class,()->generator.next("UNKNOWN"));
}
/** 头程来源尾程号未纳入独立改号,保持同柜分组关系。 */
@Test void actualContainerNumberRemainsStableAndShared() {
var generator=TestDocumentNumbers.orders();
assertTrue(generator.forContainerNo("MSCU6639870").matches("FL-WH-\\d{8}-\\d{6}"));
assertEquals(generator.forContainerNo("MSCU6639870"),TestDocumentNumbers.orders().forContainerNo("MSCU6639870"));
assertNotEquals(generator.forContainerNo("MSCU6639870"),generator.forContainerNo("CSQU3054383"));
assertThrows(IllegalArgumentException.class,()->generator.forContainerNo(" "));
}
}
@@ -117,7 +117,7 @@ class LastlegOrderServiceTest {
void setUp() {
com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
new org.apache.ibatis.builder.MapperBuilderAssistant(new org.apache.ibatis.session.Configuration(), ""), LastlegOrder.class);
service = new LastlegOrderService();
service = new LastlegOrderService(); ReflectionTestUtils.setField(service,"warehouseArrival", new ContainerWarehouseArrivalService(new com.baiyue.lastleg.repository.ContainerWarehouseArrivalRepository(orders), org.mockito.Mockito.mock(com.baiyue.lastleg.appointmentplanning.AppointmentForecastClient.class), "test"));
ReflectionTestUtils.setField(service, "transferQuantities", transferQuantities);
ReflectionTestUtils.setField(service, "returnOrderReferences", returnOrderReferences);
ReflectionTestUtils.setField(service, "orderMapper", orders);
@@ -148,7 +148,7 @@ class LastlegOrderServiceTest {
when(orders.countContainerPlans("BOOKING-1")).thenReturn(0L);
when(orders.updateContainerExpectedWarehouseArrival("BOOKING-1", estimatedAt, 0)).thenReturn(2);
when(orders.findContainerArrivalTimes(List.of("BOOKING-1"))).thenReturn(List.of(
new ContainerArrivalTimeView("BOOKING-1", estimatedAt, null, 1, 0)));
new ContainerArrivalTimeView("BOOKING-1", estimatedAt, null, 1, 0, estimatedAt.toLocalDate(), false)));
ContainerArrivalTimeView result = service.changeExpectedWarehouseArrival("BOOKING-1", "arrival-1",
new LastlegOrderRequests.ChangeExpectedWarehouseArrival(estimatedAt, 0, null), "admin");
@@ -2,11 +2,42 @@ package com.baiyue.lastleg.service;
import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com.baiyue.lastleg.mapper.*; import com.baiyue.lastleg.dropship.dto.DropshipRequests; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.fasterxml.jackson.databind.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.Instant; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*;
@ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class)
class LoadingCompletedConsumerTest {
/** 两个来源柜复用同一实箱时仍各自取号,不能把历史箱号当本次柜身份。 */
@Test void reusedPhysicalContainerKeepsDistinctSourceBookingNumbers() {
when(sequences.next("WC")).thenReturn("WC202610210001","WC202610210002");
EventEnvelope first=event(); service.consume(first);
com.fasterxml.jackson.databind.node.ObjectNode payload=first.payload().deepCopy();
payload.put("containerBookingId","book-2");
EventEnvelope second=new EventEnvelope("123e4567-e89b-12d3-a456-426614174099",first.eventType(),first.eventVersion(),
first.occurredAt(),first.tenantId(),first.aggregateType(),first.aggregateId(),first.aggregateVersion(),
first.handoverVersion(),first.traceId(),first.correlationId(),null,payload,
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
service.consume(second);
ArgumentCaptor<LastlegOrder> captured=ArgumentCaptor.forClass(LastlegOrder.class);
verify(orders,times(4)).insert(captured.capture());
assertEquals(2,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count());
verify(orders).containerOrderNumbers("book-1"); verify(orders).containerOrderNumbers("book-2");
}
/** 同柜后续客户交接沿用首票编号,不再次占用日流水。 */
@Test void reusesTheExistingContainerNumberWithoutAllocatingAgain() {
when(orders.containerOrderNumbers(anyString())).thenReturn(List.of("WC202610200001"));
service.consume(event());
ArgumentCaptor<LastlegOrder> captured=ArgumentCaptor.forClass(LastlegOrder.class);
verify(orders,times(2)).insert(captured.capture());
assertTrue(captured.getAllValues().stream().allMatch(order->"WC202610200001".equals(order.getOrderNo())));
verify(sequences,never()).next(anyString());
}
/** 同柜身份不一致必须停止,不能挑选任一旧号继续生成订单。 */
@Test void rejectsConflictingContainerBusinessNumbers() {
when(orders.containerOrderNumbers(anyString())).thenReturn(List.of("WC202610200001","WC202610200002"));
assertThrows(IllegalStateException.class,()->service.consume(event()));
verify(orders,never()).insert(any(LastlegOrder.class));
}
private final Map<String,String> frozenContainerNos=new HashMap<>();
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock com.baiyue.lastleg.inboundplan.pda.mapper.ContainerLoadingEvidenceMapper loadingEvidence; @Mock LastlegDocumentNumberGenerator sequences; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service;
@BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"loadingEvidence",loadingEvidence); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(orders.lockContainerNumbers(anyString())).thenReturn(List.of()); lenient().when(shippingGates.initialize(anyString(),anyString(),anyString(),anyLong(),anyLong())).thenAnswer(i->{ String key=i.getArgument(0); String value=i.getArgument(2); return frozenContainerNos.putIfAbsent(key,value)==null?1:0; }); lenient().when(shippingGates.lock(anyString())).thenAnswer(i->{ String key=i.getArgument(0); String value=frozenContainerNos.get(key); if(value==null)return null; var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate(); gate.setContainerBookingId(key); gate.setContainerNo(value); return gate; }); }
@Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(event).outcome()); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor<InventoryCommandRequests.InTransit> inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&"193000000000200003".equals(o.getOverseasWarehouseId())&&"US-LAX-01".equals(o.getOverseasWarehouseCode())&&"洛杉矶仓".equals(o.getOverseasWarehouseName())&&Integer.valueOf(0).equals(o.getReceivingWarehouseVersion()))); ArgumentCaptor<LastlegInboundOrder> snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("FL-WH-[0-9]{8}-[0-9]{6}")); }
@Test void oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(fifteen).outcome()); ArgumentCaptor<LastlegOrder> captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^FL-WH-\\d{8}-\\d{6}$")); verifyNoInteractions(sequences); }
@BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"orderNumbers",new com.baiyue.lastleg.repository.LastlegOrderNumberRepository(orders)); lenient().when(sequences.next("WC")).thenReturn("WC202610210001"); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"loadingEvidence",loadingEvidence); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(orders.lockContainerNumbers(anyString())).thenReturn(List.of()); lenient().when(shippingGates.initialize(anyString(),anyString(),anyString(),anyLong(),anyLong())).thenAnswer(i->{ String key=i.getArgument(0); String value=i.getArgument(2); return frozenContainerNos.putIfAbsent(key,value)==null?1:0; }); lenient().when(shippingGates.lock(anyString())).thenAnswer(i->{ String key=i.getArgument(0); String value=frozenContainerNos.get(key); if(value==null)return null; var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate(); gate.setContainerBookingId(key); gate.setContainerNo(value); return gate; }); }
@Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(event).outcome()); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor<InventoryCommandRequests.InTransit> inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&"193000000000200003".equals(o.getOverseasWarehouseId())&&"US-LAX-01".equals(o.getOverseasWarehouseCode())&&"洛杉矶仓".equals(o.getOverseasWarehouseName())&&Integer.valueOf(0).equals(o.getReceivingWarehouseVersion()))); ArgumentCaptor<LastlegInboundOrder> snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("WC[0-9]{8}[0-9]{4}")); }
@Test void oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(fifteen).outcome()); ArgumentCaptor<LastlegOrder> captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^WC\\d{8}\\d{4}$")); verify(sequences).next("WC"); }
@Test void rejectsMissingOwnerSnapshotBeforeAnyLastlegWrite(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0)).put("customerName"," "); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope missing=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(missing)); assertTrue(error.getMessage().contains("尾程事件负载无效")||error.getMessage().contains("货主快照")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); }
@Test void freezesFormalPlannedWarehouseArrivalFromLoadingEvent(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("estimatedWarehouseArrivalAt","2026-10-03T09:00"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); service.consume(changed); ArgumentCaptor<LastlegOrder> captor=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captor.capture()); assertTrue(captor.getAllValues().stream().allMatch(order->java.time.LocalDateTime.of(2026,10,3,9,0).equals(order.getEstimatedWarehouseAt()))); }
@Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","","MSCU6639870",0L,1L); }