修复费用记录、装柜完成及海外履约资料
This commit is contained in:
+1
-1
@@ -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);
|
||||
|
||||
+21
-3
@@ -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) {
|
||||
|
||||
+8
-3
@@ -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));
|
||||
|
||||
+4
-4
@@ -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();
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
|
||||
+1
-1
@@ -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();
|
||||
|
||||
+178
@@ -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");
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
|
||||
/** 只替代外部附件提供者,资源、命令和事务仍走真实服务。 */
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+2
-2
@@ -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("附件失效");
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+8
-5
@@ -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());
|
||||
|
||||
+67
@@ -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);
|
||||
}
|
||||
}
|
||||
+1
-9
@@ -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(" "));
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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");
|
||||
|
||||
+34
-3
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user