fix(appointments): expose dispatch cabinet short numbers

This commit is contained in:
2026-10-22 18:03:45 +08:00
parent 9e0266b7c3
commit bfeeef130e
6 changed files with 88 additions and 39 deletions
@@ -7,7 +7,7 @@ import org.springframework.stereotype.Repository;
/** 日历只读访问保留真实主单范围,整批读取原货物快照而不逐车查询。 */ /** 日历只读访问保留真实主单范围,整批读取原货物快照而不逐车查询。 */
@Repository @Repository
public class FleetTripCalendarRepository { public class FleetTripCalendarRepository {
public record Source(String dispatchNo, String sourceSnapshotJson) { } public record Source(String dispatchNo, String sourceSnapshotJson, String loadingPayloadJson) { }
private final FleetTripMapper mapper; private final FleetTripMapper mapper;
/** 装配现有车次Mapper,日历服务不直接访问SQL。 */ /** 装配现有车次Mapper,日历服务不直接访问SQL。 */
@@ -18,10 +18,10 @@ public class FleetTripCalendarRepository {
return mapper.calendar(from, to); return mapper.calendar(from, to);
} }
/** 一次读取指定主单的全部货物快照,空窗口不发送空IN查询。 */ /** 一次读取指定主单的全部货物与原装柜快照,空窗口不发送空IN查询。 */
public List<Source> sourceSnapshots(List<String> dispatchNumbers) { public List<Source> sourceSnapshots(List<String> dispatchNumbers) {
if (dispatchNumbers.isEmpty()) return List.of(); if (dispatchNumbers.isEmpty()) return List.of();
return mapper.calendarSources(dispatchNumbers).stream() return mapper.calendarSources(dispatchNumbers).stream()
.map(row -> new Source(row.dispatchNo(), row.sourceSnapshotJson())).toList(); .map(row -> new Source(row.dispatchNo(), row.sourceSnapshotJson(), row.loadingPayloadJson())).toList();
} }
} }
@@ -19,8 +19,9 @@ public class FleetTripCalendarService {
String originWarehouseCode, String originWarehouseName, String originCountryCode, String originWarehouseCode, String originWarehouseName, String originCountryCode,
String originTimezone, String originWarehouseStatus, Instant plannedDepartureAt, String originTimezone, String originWarehouseStatus, Instant plannedDepartureAt,
Instant expectedDriverEndAt, Instant departedAt, Instant completedAt, Instant expectedDriverEndAt, Instant departedAt, Instant completedAt,
List<FleetTripModels.Stop> stops, List<String> containerNumbers, List<String> incompleteReasons) { } List<FleetTripModels.Stop> stops, List<String> containerNumbers, List<String> cabinetNumbers, List<String> incompleteReasons) { }
public record Result(List<Entry> records, List<Entry> unresolvedRecords) { } public record Result(List<Entry> records, List<Entry> unresolvedRecords) { }
private record ContainerReferences(List<String> containerNumbers, List<String> cabinetNumbers) { }
private static final Set<String> COUNTRIES = Set.of(Locale.getISOCountries()); private static final Set<String> COUNTRIES = Set.of(Locale.getISOCountries());
private final FleetTripCalendarRepository trips; private final FleetTripCalendarRepository trips;
private final FleetWarehouseClient warehouses; private final FleetWarehouseClient warehouses;
@@ -41,7 +42,7 @@ public class FleetTripCalendarService {
var rows=trips.calendar(LocalDateTime.ofInstant(from,ZoneOffset.UTC),LocalDateTime.ofInstant(to,ZoneOffset.UTC)); var rows=trips.calendar(LocalDateTime.ofInstant(from,ZoneOffset.UTC),LocalDateTime.ofInstant(to,ZoneOffset.UTC));
if (rows.size()>2000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"排车过多,请缩小查询日期范围"); if (rows.size()>2000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"排车过多,请缩小查询日期范围");
if (rows.isEmpty()) return new Result(List.of(),List.of()); if (rows.isEmpty()) return new Result(List.of(),List.of());
var containers=containerNumbers(rows); var containers=containerReferences(rows);
var ids=rows.stream().map(FleetTripEntity::getWarehouseId).filter(Objects::nonNull).filter(id->id>0).distinct().sorted().toList(); var ids=rows.stream().map(FleetTripEntity::getWarehouseId).filter(Objects::nonNull).filter(id->id>0).distinct().sorted().toList();
var metadata=metadata(ids); var metadata=metadata(ids);
var records=new ArrayList<Entry>(); var unresolved=new ArrayList<Entry>(); var records=new ArrayList<Entry>(); var unresolved=new ArrayList<Entry>();
@@ -64,36 +65,51 @@ public class FleetTripCalendarService {
throw new IllegalArgumentException("incomplete route"); throw new IllegalArgumentException("incomplete route");
stops=List.copyOf(stops); stops=List.copyOf(stops);
} catch (Exception invalid) { stops=List.of(); reasons.add("ROUTE_INCOMPLETE"); } } catch (Exception invalid) { stops=List.of(); reasons.add("ROUTE_INCOMPLETE"); }
var references=containers.getOrDefault(row.getDispatchNo(),new ContainerReferences(List.of(),List.of()));
var entry=new Entry("DISPATCH",row.getDispatchNo(),row.getStatus(),row.getWarehouseId()==null?null:row.getWarehouseId().toString(), var entry=new Entry("DISPATCH",row.getDispatchNo(),row.getStatus(),row.getWarehouseId()==null?null:row.getWarehouseId().toString(),
warehouse==null?row.getWarehouseCode():warehouse.warehouseCode(),warehouse==null?row.getWarehouseName():warehouse.warehouseName(), warehouse==null?row.getWarehouseCode():warehouse.warehouseCode(),warehouse==null?row.getWarehouseName():warehouse.warehouseName(),
country,zone,warehouse==null?null:warehouse.status(),instant(row.getPlannedDepartureAt()),instant(row.getExpectedDriverEndAt()), country,zone,warehouse==null?null:warehouse.status(),instant(row.getPlannedDepartureAt()),instant(row.getExpectedDriverEndAt()),
instant(row.getDepartedAt()),instant(row.getCompletedAt()),stops,containers.getOrDefault(row.getDispatchNo(),List.of()),List.copyOf(reasons)); instant(row.getDepartedAt()),instant(row.getCompletedAt()),stops,references.containerNumbers(),references.cabinetNumbers(),List.copyOf(reasons));
(unresolvedOrigin?unresolved:records).add(entry); (unresolvedOrigin?unresolved:records).add(entry);
} }
return new Result(List.copyOf(records),List.copyOf(unresolved)); return new Result(List.copyOf(records),List.copyOf(unresolved));
} }
/** 柜号只取排车建单时正式保存的SourceDisplay.containerNo,不反查或推测其他来源。 */ /** 完整柜号取排车货物快照,柜次短号取同客户真实来源的原装柜事件,分别去空去重。 */
private Map<String,List<String>> containerNumbers(List<FleetTripCalendarRow> rows) { private Map<String,ContainerReferences> containerReferences(List<FleetTripCalendarRow> rows) {
var numbers=rows.stream().map(FleetTripCalendarRow::getDispatchNo).distinct().sorted().toList(); var numbers=rows.stream().map(FleetTripCalendarRow::getDispatchNo).distinct().sorted().toList();
var requested=new HashSet<>(numbers);var grouped=new LinkedHashMap<String,LinkedHashSet<String>>(); var requested=new HashSet<>(numbers);
var containers=new LinkedHashMap<String,LinkedHashSet<String>>();
var cabinets=new LinkedHashMap<String,LinkedHashSet<String>>();
for(var source:trips.sourceSnapshots(numbers)) { for(var source:trips.sourceSnapshots(numbers)) {
if(source==null || !requested.contains(source.dispatchNo()) || source.sourceSnapshotJson()==null || source.sourceSnapshotJson().isBlank()) if(source==null || !requested.contains(source.dispatchNo()) || source.sourceSnapshotJson()==null || source.sourceSnapshotJson().isBlank())
throw sourceUnavailable(); throw sourceUnavailable();
var container=snapshotReference(source.sourceSnapshotJson(),"containerNo");
if(container!=null)containers.computeIfAbsent(source.dispatchNo(),ignored->new LinkedHashSet<>()).add(container);
// 非头程来源没有装柜事件,保持短号为空,不从完整柜号或内部ID猜测。
if(source.loadingPayloadJson()!=null) {
var cabinet=snapshotReference(source.loadingPayloadJson(),"cabinetNo");
if(cabinet!=null)cabinets.computeIfAbsent(source.dispatchNo(),ignored->new LinkedHashSet<>()).add(cabinet);
}
}
var result=new LinkedHashMap<String,ContainerReferences>();
for(var number:numbers)result.put(number,new ContainerReferences(
List.copyOf(containers.getOrDefault(number,new LinkedHashSet<>())),List.copyOf(cabinets.getOrDefault(number,new LinkedHashSet<>()))));
return Map.copyOf(result);
}
/** 只接受正式JSON对象中的文本业务标识,缺失留空,损坏资料明确报错。 */
private String snapshotReference(String payload,String field) {
try { try {
var snapshot=json.reader().with(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_TRAILING_TOKENS) var snapshot=json.reader().with(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readTree(payload);
.readTree(source.sourceSnapshotJson());
if(snapshot==null || !snapshot.isObject())throw sourceUnavailable(); if(snapshot==null || !snapshot.isObject())throw sourceUnavailable();
var value=snapshot.get("containerNo"); var value=snapshot.get(field);
if(value==null || value.isNull())continue; if(value==null || value.isNull())return null;
if(!value.isTextual())throw sourceUnavailable(); if(!value.isTextual())throw sourceUnavailable();
var number=value.textValue().strip(); var number=value.textValue().strip();
if(!number.isEmpty())grouped.computeIfAbsent(source.dispatchNo(),ignored->new LinkedHashSet<>()).add(number); return number.isEmpty()?null:number;
} catch(com.fasterxml.jackson.core.JsonProcessingException invalid) { throw sourceUnavailable(); } } catch(com.fasterxml.jackson.core.JsonProcessingException invalid) { throw sourceUnavailable(); }
} }
var result=new LinkedHashMap<String,List<String>>();
grouped.forEach((number,values)->result.put(number,List.copyOf(values)));return Map.copyOf(result);
}
/** 元数据批次必须完整覆盖请求身份,未知地点保持显式未核实。 */ /** 元数据批次必须完整覆盖请求身份,未知地点保持显式未核实。 */
private Map<Long,FleetWarehouseClient.Metadata> metadata(List<Long> ids) { private Map<Long,FleetWarehouseClient.Metadata> metadata(List<Long> ids) {
@@ -19,12 +19,14 @@ public interface FleetTripMapper extends BaseMapper<FleetTripEntity> {
ORDER BY COALESCE(t.departed_at,t.planned_departure_at),d.dispatch_no LIMIT 2001 ORDER BY COALESCE(t.departed_at,t.planned_departure_at),d.dispatch_no LIMIT 2001
""") """)
java.util.List<FleetTripCalendarRow> calendar(@Param("from") java.time.LocalDateTime from,@Param("to") java.time.LocalDateTime to); java.util.List<FleetTripCalendarRow> calendar(@Param("from") java.time.LocalDateTime from,@Param("to") java.time.LocalDateTime to);
record CalendarSource(String dispatchNo, String sourceSnapshotJson) { } record CalendarSource(String dispatchNo, String sourceSnapshotJson, String loadingPayloadJson) { }
/** 主查询限额之后批量读取原货物快照,不以货物JOIN放大整车记录或截断多柜。 */ /** 主查询限额后批读货物快照及同客户原装柜事件,柜次短号只沿真实订单关联读取。 */
@Select(""" @Select("""
<script> <script>
SELECT d.dispatch_no AS dispatchNo,i.source_snapshot_json AS sourceSnapshotJson SELECT d.dispatch_no AS dispatchNo,i.source_snapshot_json AS sourceSnapshotJson,e.payload_json AS loadingPayloadJson
FROM ll_dispatch_order d JOIN ll_dispatch_item i ON i.dispatch_order_id=d.id FROM ll_dispatch_order d JOIN ll_dispatch_item i ON i.dispatch_order_id=d.id
LEFT JOIN ll_order o ON o.id=i.order_id AND o.customer_id=i.customer_id
LEFT JOIN ll_loading_event_inbox e ON e.event_id=o.source_event_id AND e.customer_id=o.customer_id
WHERE d.status='ACTIVE' AND d.dispatch_no IN WHERE d.status='ACTIVE' AND d.dispatch_no IN
<foreach collection='numbers' item='number' open='(' separator=',' close=')'>#{number}</foreach> <foreach collection='numbers' item='number' open='(' separator=',' close=')'>#{number}</foreach>
ORDER BY d.dispatch_no,i.id ORDER BY d.dispatch_no,i.id
@@ -359,7 +359,7 @@ components:
schemas: schemas:
TripCalendarEntry: TripCalendarEntry:
type: object type: object
required: [sourceType, dispatchNo, status, plannedDepartureAt, expectedDriverEndAt, stops, containerNumbers, incompleteReasons] required: [sourceType, dispatchNo, status, plannedDepartureAt, expectedDriverEndAt, stops, containerNumbers, cabinetNumbers, incompleteReasons]
properties: properties:
sourceType: {type: string, enum: [DISPATCH]} sourceType: {type: string, enum: [DISPATCH]}
dispatchNo: {type: string, description: 真实整车主单排车号,用于排车详情路由} dispatchNo: {type: string, description: 真实整车主单排车号,用于排车详情路由}
@@ -380,6 +380,11 @@ components:
description: 本车全部货物正式来源快照中的真实柜号,去空去重;多客户、多柜保留完整数组,无柜号为空数组,不推测或补造。 description: 本车全部货物正式来源快照中的真实柜号,去空去重;多客户、多柜保留完整数组,无柜号为空数组,不推测或补造。
items: {type: string} items: {type: string}
uniqueItems: true uniqueItems: true
cabinetNumbers:
type: array
description: 本车货物通过同客户真实尾程订单关联的原装柜事件中的柜次短号(如D1821),去空去重;无装柜来源为空数组,不用完整柜号或内部ID推测。
items: {type: string}
uniqueItems: true
incompleteReasons: incompleteReasons:
type: array type: array
items: {type: string, enum: [ORIGIN_WAREHOUSE_UNKNOWN, ORIGIN_COUNTRY_UNKNOWN, ORIGIN_TIMEZONE_UNKNOWN, ROUTE_INCOMPLETE]} items: {type: string, enum: [ORIGIN_WAREHOUSE_UNKNOWN, ORIGIN_COUNTRY_UNKNOWN, ORIGIN_TIMEZONE_UNKNOWN, ROUTE_INCOMPLETE]}
@@ -62,16 +62,22 @@ class FleetTripCalendarMapperTest {
bindDispatches(jdbc); bindDispatches(jdbc);
assertThat(mapper.calendar(from,to)).hasSize(2001); assertThat(mapper.calendar(from,to)).hasSize(2001);
} }
/** 实际Mapper批读同单跨客户多柜,排除其他主单与取消主单,不用货物行放大日历。 */ /** 实际Mapper批读跨客户多柜及原装柜短号,隔离订单和事件客户,排除其他及取消主单。 */
@Test void batchReadsEverySourceSnapshotOnlyForTheRequestedActiveDispatches() throws Exception { @Test void batchReadsEverySourceSnapshotOnlyForTheRequestedActiveDispatches() throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
var jdbc=new JdbcTemplate(ds); var jdbc=new JdbcTemplate(ds);
jdbc.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,dispatch_no VARCHAR(80) UNIQUE,status VARCHAR(32))"); jdbc.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,dispatch_no VARCHAR(80) UNIQUE,status VARCHAR(32))");
jdbc.execute("CREATE TABLE ll_dispatch_item(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT,customer_id VARCHAR(64),source_snapshot_json VARCHAR(2000))"); jdbc.execute("CREATE TABLE ll_dispatch_item(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT,customer_id VARCHAR(64),source_snapshot_json VARCHAR(2000),order_id BIGINT)");
jdbc.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),source_event_id VARCHAR(64))");
jdbc.execute("CREATE TABLE ll_loading_event_inbox(event_id VARCHAR(64) PRIMARY KEY,customer_id VARCHAR(64),payload_json VARCHAR(2000))");
jdbc.update("INSERT INTO ll_dispatch_order VALUES(1,'PC-A','ACTIVE'),(2,'PC-B','ACTIVE'),(3,'PC-OTHER','ACTIVE'),(4,'PC-CANCELLED','CANCELLED')"); jdbc.update("INSERT INTO ll_dispatch_order VALUES(1,'PC-A','ACTIVE'),(2,'PC-B','ACTIVE'),(3,'PC-OTHER','ACTIVE'),(4,'PC-CANCELLED','CANCELLED')");
jdbc.update("INSERT INTO ll_dispatch_item VALUES(11,1,'101',?), (12,1,'102',?), (13,1,'101',?), (21,2,'103',?), (31,3,'101',?), (41,4,'101',?)", jdbc.update("INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,source_snapshot_json) VALUES(11,1,'101',?), (12,1,'102',?), (13,1,'101',?), (21,2,'103',?), (31,3,'101',?), (41,4,'101',?)",
"{\"containerNo\":\"TSTU1234567\"}","{\"containerNo\":\"TSTU1234567\"}","{\"containerNo\":\"TSTU7654321\"}", "{\"containerNo\":\"TSTU1234567\"}","{\"containerNo\":\"TSTU1234567\"}","{\"containerNo\":\"TSTU7654321\"}",
"{\"planNo\":\"PLAN-B\"}","{\"containerNo\":\"OTHER-CARGO\"}","{\"containerNo\":\"CANCELLED-CARGO\"}"); "{\"planNo\":\"PLAN-B\"}","{\"containerNo\":\"OTHER-CARGO\"}","{\"containerNo\":\"CANCELLED-CARGO\"}");
jdbc.update("INSERT INTO ll_order VALUES(1011,'101','E1'),(1012,'102','E2'),(1013,'101','E3'),(1021,'104','E4')");
jdbc.update("INSERT INTO ll_loading_event_inbox VALUES('E1','101',?),('E2','102',?),('E3','102',?),('E4','104',?)",
"{\"cabinetNo\":\"D1821\"}","{\"cabinetNo\":\"D1822\"}","{\"cabinetNo\":\"WRONG-EVENT-CUSTOMER\"}","{\"cabinetNo\":\"WRONG-ORDER-CUSTOMER\"}");
jdbc.update("UPDATE ll_dispatch_item SET order_id=CASE id WHEN 11 THEN 1011 WHEN 12 THEN 1012 WHEN 13 THEN 1013 WHEN 21 THEN 1021 END");
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(FleetTripMapper.class); var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(FleetTripMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(FleetTripMapper.class); var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(FleetTripMapper.class);
@@ -81,5 +87,10 @@ class FleetTripCalendarMapperTest {
assertThat(rows).extracting(FleetTripCalendarRepository.Source::dispatchNo).containsExactly("PC-A","PC-A","PC-A","PC-B"); assertThat(rows).extracting(FleetTripCalendarRepository.Source::dispatchNo).containsExactly("PC-A","PC-A","PC-A","PC-B");
assertThat(rows).extracting(FleetTripCalendarRepository.Source::sourceSnapshotJson).containsExactly( assertThat(rows).extracting(FleetTripCalendarRepository.Source::sourceSnapshotJson).containsExactly(
"{\"containerNo\":\"TSTU1234567\"}","{\"containerNo\":\"TSTU1234567\"}","{\"containerNo\":\"TSTU7654321\"}","{\"planNo\":\"PLAN-B\"}"); "{\"containerNo\":\"TSTU1234567\"}","{\"containerNo\":\"TSTU1234567\"}","{\"containerNo\":\"TSTU7654321\"}","{\"planNo\":\"PLAN-B\"}");
assertThat(rows).extracting(FleetTripCalendarRepository.Source::loadingPayloadJson).containsExactly(
"{\"cabinetNo\":\"D1821\"}","{\"cabinetNo\":\"D1822\"}",null,null);
jdbc.update("UPDATE ll_dispatch_item SET order_id=NULL WHERE id=11");
assertThat(repository.sourceSnapshots(List.of("PC-A"))).hasSize(3).first()
.extracting(FleetTripCalendarRepository.Source::loadingPayloadJson).isNull();
} }
} }
@@ -107,22 +107,26 @@ class FleetTripCalendarServiceTest {
var platform=new com.baiyue.common.security.AuthenticatedUser(1L,0L,0L,1L,"platform","PLATFORM","WEB",Set.of("platform:overseas-warehouse:dispatch:list")); var platform=new com.baiyue.common.security.AuthenticatedUser(1L,0L,0L,1L,"platform","PLATFORM","WEB",Set.of("platform:overseas-warehouse:dispatch:list"));
controller.calendar(platform,from,to);verify(calendar).calendar(from,to); controller.calendar(platform,from,to);verify(calendar).calendar(from,to);
} }
/** 同车全部来源只批读一次,跨客户重复柜号去重,多柜完整保留。 */ /** 同车全部来源只批读一次,跨客户重复柜号及短号去重,多柜完整保留。 */
@Test void readsAllContainerNumbersInOneBatchAndPreservesTheLocalDateFacts() { @Test void readsAllContainerNumbersInOneBatchAndPreservesTheLocalDateFacts() {
var first=trip("PC-A",1L);first.setPlannedDepartureAt(LocalDateTime.of(2026,10,22,16,0)); var first=trip("PC-A",1L);first.setPlannedDepartureAt(LocalDateTime.of(2026,10,22,16,0));
when(trips.calendar(any(),any())).thenReturn(List.of(first,trip("PC-B",1L))); when(trips.calendar(any(),any())).thenReturn(List.of(first,trip("PC-B",1L)));
when(trips.sourceSnapshots(List.of("PC-A","PC-B"))).thenReturn(List.of( when(trips.sourceSnapshots(List.of("PC-A","PC-B"))).thenReturn(List.of(
new FleetTripCalendarRepository.Source("PC-A","{\"containerNo\":\" TSTU1234567 \",\"planNo\":\"PLAN-A\"}"), new FleetTripCalendarRepository.Source("PC-A","{\"containerNo\":\" TSTU1234567 \",\"planNo\":\"PLAN-A\"}","{\"cabinetNo\":\" D1821 \"}"),
new FleetTripCalendarRepository.Source("PC-A","{\"containerNo\":\"TSTU1234567\"}"), new FleetTripCalendarRepository.Source("PC-A","{\"containerNo\":\"TSTU1234567\"}","{\"cabinetNo\":\"D1821\"}"),
new FleetTripCalendarRepository.Source("PC-A","{\"containerNo\":\"TSTU7654321\"}"), new FleetTripCalendarRepository.Source("PC-A","{\"containerNo\":\"TSTU7654321\"}","{\"cabinetNo\":\"D1822\"}"),
new FleetTripCalendarRepository.Source("PC-A","{\"containerNo\":null}"), new FleetTripCalendarRepository.Source("PC-A","{\"containerNo\":null}","{\"cabinetNo\":\"D1823\"}"),
new FleetTripCalendarRepository.Source("PC-B","{}"), new FleetTripCalendarRepository.Source("PC-B","{}",null),
new FleetTripCalendarRepository.Source("PC-B","{\"containerNo\":\" \"}"))); new FleetTripCalendarRepository.Source("PC-B","{\"containerNo\":\" \"}","{\"cabinetNo\":null}"),
new FleetTripCalendarRepository.Source("PC-B","{}","{}"),
new FleetTripCalendarRepository.Source("PC-B","{}","{\"cabinetNo\":\" \"}")));
metadata(List.of(warehouse(1,"US","America/Los_Angeles")),List.of()); metadata(List.of(warehouse(1,"US","America/Los_Angeles")),List.of());
var result=service.calendar(from,to); var result=service.calendar(from,to);
assertThat(result.records()).hasSize(2); assertThat(result.records()).hasSize(2);
assertThat(result.records().get(0).containerNumbers()).containsExactly("TSTU1234567","TSTU7654321"); assertThat(result.records().get(0).containerNumbers()).containsExactly("TSTU1234567","TSTU7654321");
assertThat(result.records().get(1).containerNumbers()).isEmpty(); assertThat(result.records().get(1).containerNumbers()).isEmpty();
assertThat(result.records().get(0).cabinetNumbers()).containsExactly("D1821","D1822","D1823");
assertThat(result.records().get(1).cabinetNumbers()).isEmpty();
assertThat(result.records().get(0).plannedDepartureAt()).isEqualTo(Instant.parse("2026-10-22T16:00:00Z")); assertThat(result.records().get(0).plannedDepartureAt()).isEqualTo(Instant.parse("2026-10-22T16:00:00Z"));
assertThat(result.records().get(0).originTimezone()).isEqualTo("America/Los_Angeles"); assertThat(result.records().get(0).originTimezone()).isEqualTo("America/Los_Angeles");
assertThat(result.unresolvedRecords()).isEmpty(); assertThat(result.unresolvedRecords()).isEmpty();
@@ -130,15 +134,16 @@ class FleetTripCalendarServiceTest {
verify(warehouses).platformMetadata("internal-test",new FleetWarehouseClient.MetadataRequest(List.of(1L))); verify(warehouses).platformMetadata("internal-test",new FleetWarehouseClient.MetadataRequest(List.of(1L)));
verifyNoMoreInteractions(warehouses); verifyNoMoreInteractions(warehouses);
} }
/** 缺时区的主单仍保留真实柜号,不将货物信息丢失误作起运地已核实。 */ /** 缺时区的主单仍保留真实柜号及短号,不将货物信息丢失误作起运地已核实。 */
@Test void keepsKnownContainersOnAnUnresolvedOrigin() { @Test void keepsKnownContainersOnAnUnresolvedOrigin() {
when(trips.calendar(any(),any())).thenReturn(List.of(trip("PC-UNKNOWN",1L))); when(trips.calendar(any(),any())).thenReturn(List.of(trip("PC-UNKNOWN",1L)));
when(trips.sourceSnapshots(List.of("PC-UNKNOWN"))).thenReturn(List.of( when(trips.sourceSnapshots(List.of("PC-UNKNOWN"))).thenReturn(List.of(
new FleetTripCalendarRepository.Source("PC-UNKNOWN","{\"containerNo\":\"TSTU1234567\"}"))); new FleetTripCalendarRepository.Source("PC-UNKNOWN","{\"containerNo\":\"TSTU1234567\"}","{\"cabinetNo\":\"D1821\"}")));
metadata(List.of(warehouse(1,"US",null)),List.of()); metadata(List.of(warehouse(1,"US",null)),List.of());
var result=service.calendar(from,to);assertThat(result.records()).isEmpty(); var result=service.calendar(from,to);assertThat(result.records()).isEmpty();
assertThat(result.unresolvedRecords()).singleElement().satisfies(row->{ assertThat(result.unresolvedRecords()).singleElement().satisfies(row->{
assertThat(row.containerNumbers()).containsExactly("TSTU1234567"); assertThat(row.containerNumbers()).containsExactly("TSTU1234567");
assertThat(row.cabinetNumbers()).containsExactly("D1821");
assertThat(row.incompleteReasons()).containsExactly("ORIGIN_TIMEZONE_UNKNOWN"); assertThat(row.incompleteReasons()).containsExactly("ORIGIN_TIMEZONE_UNKNOWN");
}); });
} }
@@ -146,12 +151,22 @@ class FleetTripCalendarServiceTest {
@Test void rejectsMalformedOrForeignSourceSnapshotsExplicitly() { @Test void rejectsMalformedOrForeignSourceSnapshotsExplicitly() {
when(trips.calendar(any(),any())).thenReturn(List.of(trip("PC-A",1L))); when(trips.calendar(any(),any())).thenReturn(List.of(trip("PC-A",1L)));
for(var invalid:Arrays.asList(null,"","not-json","null","[]","{\"containerNo\":123}","{\"containerNo\":[\"TSTU1234567\"]}","{} {}")) { for(var invalid:Arrays.asList(null,"","not-json","null","[]","{\"containerNo\":123}","{\"containerNo\":[\"TSTU1234567\"]}","{} {}")) {
when(trips.sourceSnapshots(List.of("PC-A"))).thenReturn(List.of(new FleetTripCalendarRepository.Source("PC-A",invalid))); when(trips.sourceSnapshots(List.of("PC-A"))).thenReturn(List.of(new FleetTripCalendarRepository.Source("PC-A",invalid,null)));
assertThatThrownBy(()->service.calendar(from,to)).isInstanceOf(ResponseStatusException.class) assertThatThrownBy(()->service.calendar(from,to)).isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("柜号资料暂不可用"); .hasMessageContaining("柜号资料暂不可用");
} }
when(trips.sourceSnapshots(List.of("PC-A"))).thenReturn(List.of(new FleetTripCalendarRepository.Source("OTHER-PC","{}"))); when(trips.sourceSnapshots(List.of("PC-A"))).thenReturn(List.of(new FleetTripCalendarRepository.Source("OTHER-PC","{}",null)));
assertThatThrownBy(()->service.calendar(from,to)).isInstanceOf(ResponseStatusException.class).hasMessageContaining("柜号资料暂不可用"); assertThatThrownBy(()->service.calendar(from,to)).isInstanceOf(ResponseStatusException.class).hasMessageContaining("柜号资料暂不可用");
verifyNoInteractions(warehouses); verifyNoInteractions(warehouses);
} }
/** 损坏短号来源明确失败,不伪装为没有关联柜次。 */
@Test void rejectsMalformedLoadingSnapshotsExplicitly() {
when(trips.calendar(any(),any())).thenReturn(List.of(trip("PC-A",1L)));
for(var invalid:List.of("","not-json","null","[]","{\"cabinetNo\":1821}","{\"cabinetNo\":[\"D1821\"]}","{} {}")) {
when(trips.sourceSnapshots(List.of("PC-A"))).thenReturn(List.of(new FleetTripCalendarRepository.Source("PC-A","{}",invalid)));
assertThatThrownBy(()->service.calendar(from,to)).isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("柜号资料暂不可用");
}
verifyNoInteractions(warehouses);
}
} }