Compare commits

...
Author SHA1 Message Date
ouyangxq a2c3d36c20 fix(calendar): publish isolated dispatch container lookup to TEST 2026-10-21 11:38:58 +08:00
6 changed files with 149 additions and 8 deletions
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.fleet.trip;
import java.time.LocalDateTime;
import java.util.List;
import org.springframework.stereotype.Repository;
/** 日历只读访问保留真实主单范围,整批读取原货物快照而不逐车查询。 */
@Repository
public class FleetTripCalendarRepository {
public record Source(String dispatchNo, String sourceSnapshotJson) { }
private final FleetTripMapper mapper;
/** 装配现有车次Mapper,日历服务不直接访问SQL。 */
public FleetTripCalendarRepository(FleetTripMapper mapper) { this.mapper = mapper; }
/** 按真实UTC时间窗口读取有唯一整车主单的运输记录。 */
public List<FleetTripCalendarRow> calendar(LocalDateTime from, LocalDateTime to) {
return mapper.calendar(from, to);
}
/** 一次读取指定主单的全部货物快照,空窗口不发送空IN查询。 */
public List<Source> sourceSnapshots(List<String> dispatchNumbers) {
if (dispatchNumbers.isEmpty()) return List.of();
return mapper.calendarSources(dispatchNumbers).stream()
.map(row -> new Source(row.dispatchNo(), row.sourceSnapshotJson())).toList();
}
}
@@ -6,6 +6,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.time.*; import java.time.*;
@@ -18,27 +19,29 @@ 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> incompleteReasons) { } List<FleetTripModels.Stop> stops, List<String> containerNumbers, List<String> incompleteReasons) { }
public record Result(List<Entry> records, List<Entry> unresolvedRecords) { } public record Result(List<Entry> records, List<Entry> unresolvedRecords) { }
private static final Set<String> COUNTRIES = Set.of(Locale.getISOCountries()); private static final Set<String> COUNTRIES = Set.of(Locale.getISOCountries());
private final FleetTripMapper trips; private final FleetTripCalendarRepository trips;
private final FleetWarehouseClient warehouses; private final FleetWarehouseClient warehouses;
private final ObjectMapper json; private final ObjectMapper json;
private final String token; private final String token;
public FleetTripCalendarService(FleetTripMapper trips, FleetWarehouseClient warehouses, ObjectMapper json, /** 装配日历持久化入口与真实仓库元数据提供者。 */
public FleetTripCalendarService(FleetTripCalendarRepository trips, FleetWarehouseClient warehouses, ObjectMapper json,
@Value("${baiyue.security.internal-service-token:}") String token) { @Value("${baiyue.security.internal-service-token:}") String token) {
this.trips=trips; this.warehouses=warehouses; this.json=json; this.token=token; this.trips=trips; this.warehouses=warehouses; this.json=json; this.token=token;
} }
/** 一次读取真实排车及一次仓库资料查询,按业务地点判定日历归属。 */ /** 同一快照批读真实主单和货物,仓库资料只查询一次,保留原业务地点时区。 */
@Transactional(readOnly=true) @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public Result calendar(Instant from, Instant to) { public Result calendar(Instant from, Instant to) {
if (from==null || to==null || !to.isAfter(from) || Duration.between(from,to).compareTo(Duration.ofDays(44))>0) if (from==null || to==null || !to.isAfter(from) || Duration.between(from,to).compareTo(Duration.ofDays(44))>0)
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"查询须为不超过44天的UTC时间范围"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"查询须为不超过44天的UTC时间范围");
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 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,12 +67,35 @@ public class FleetTripCalendarService {
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,List.copyOf(reasons)); instant(row.getDepartedAt()),instant(row.getCompletedAt()),stops,containers.getOrDefault(row.getDispatchNo(),List.of()),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) {
var numbers=rows.stream().map(FleetTripCalendarRow::getDispatchNo).distinct().sorted().toList();
var requested=new HashSet<>(numbers);var grouped=new LinkedHashMap<String,LinkedHashSet<String>>();
for(var source:trips.sourceSnapshots(numbers)) {
if(source==null || !requested.contains(source.dispatchNo()) || source.sourceSnapshotJson()==null || source.sourceSnapshotJson().isBlank())
throw sourceUnavailable();
try {
var snapshot=json.reader().with(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
.readTree(source.sourceSnapshotJson());
if(snapshot==null || !snapshot.isObject())throw sourceUnavailable();
var value=snapshot.get("containerNo");
if(value==null || value.isNull())continue;
if(!value.isTextual())throw sourceUnavailable();
var number=value.textValue().strip();
if(!number.isEmpty())grouped.computeIfAbsent(source.dispatchNo(),ignored->new LinkedHashSet<>()).add(number);
} 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) {
if (ids.isEmpty()) return Map.of(); if (ids.isEmpty()) return Map.of();
try { try {
@@ -85,6 +111,10 @@ public class FleetTripCalendarService {
return result; return result;
} catch (RuntimeException failure) { throw unavailable(); } } catch (RuntimeException failure) { throw unavailable(); }
} }
/** 持久UTC时间只转换为UTC瞬时,不套用浏览器日期。 */
private static Instant instant(LocalDateTime value) { return value==null?null:value.toInstant(ZoneOffset.UTC); } private static Instant instant(LocalDateTime value) { return value==null?null:value.toInstant(ZoneOffset.UTC); }
/** 仓库服务失败明确报错,不能把未知时区伪装成完整空日历。 */
private static ResponseStatusException unavailable() { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"仓库时区资料暂不可用,请重试"); } private static ResponseStatusException unavailable() { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"仓库时区资料暂不可用,请重试"); }
/** 损坏货物快照保留明确错误,不静默丢弃真实排车或柜号。 */
private static ResponseStatusException sourceUnavailable() { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"排车货物柜号资料暂不可用,请重试"); }
} }
@@ -19,6 +19,18 @@ 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) { }
/** 主查询限额之后批量读取原货物快照,不以货物JOIN放大整车记录或截断多柜。 */
@Select("""
<script>
SELECT d.dispatch_no AS dispatchNo,i.source_snapshot_json AS sourceSnapshotJson
FROM ll_dispatch_order d JOIN ll_dispatch_item i ON i.dispatch_order_id=d.id
WHERE d.status='ACTIVE' AND d.dispatch_no IN
<foreach collection='numbers' item='number' open='(' separator=',' close=')'>#{number}</foreach>
ORDER BY d.dispatch_no,i.id
</script>
""")
java.util.List<CalendarSource> calendarSources(@Param("numbers") java.util.List<String> dispatchNumbers);
@Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo} FOR UPDATE") @Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo} FOR UPDATE")
FleetTripEntity lockByNo(@Param("tripNo") String tripNo); FleetTripEntity lockByNo(@Param("tripNo") String tripNo);
@Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo}") @Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo}")
@@ -359,7 +359,7 @@ components:
schemas: schemas:
TripCalendarEntry: TripCalendarEntry:
type: object type: object
required: [sourceType, dispatchNo, status, plannedDepartureAt, expectedDriverEndAt, stops, incompleteReasons] required: [sourceType, dispatchNo, status, plannedDepartureAt, expectedDriverEndAt, stops, containerNumbers, incompleteReasons]
properties: properties:
sourceType: {type: string, enum: [DISPATCH]} sourceType: {type: string, enum: [DISPATCH]}
dispatchNo: {type: string, description: 真实整车主单排车号,用于排车详情路由} dispatchNo: {type: string, description: 真实整车主单排车号,用于排车详情路由}
@@ -375,6 +375,11 @@ components:
departedAt: {type: string, format: date-time, nullable: true} departedAt: {type: string, format: date-time, nullable: true}
completedAt: {type: string, format: date-time, nullable: true} completedAt: {type: string, format: date-time, nullable: true}
stops: {type: array, items: {$ref: '#/components/schemas/Stop'}} stops: {type: array, items: {$ref: '#/components/schemas/Stop'}}
containerNumbers:
type: array
description: 本车全部货物正式来源快照中的真实柜号,去空去重;多客户、多柜保留完整数组,无柜号为空数组,不推测或补造。
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,4 +62,24 @@ class FleetTripCalendarMapperTest {
bindDispatches(jdbc); bindDispatches(jdbc);
assertThat(mapper.calendar(from,to)).hasSize(2001); assertThat(mapper.calendar(from,to)).hasSize(2001);
} }
/** 实际Mapper批读同单跨客户多柜,排除其他主单与取消主单,不用货物行放大日历。 */
@Test void batchReadsEverySourceSnapshotOnlyForTheRequestedActiveDispatches() throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
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_item(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT,customer_id VARCHAR(64),source_snapshot_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_item 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\"}",
"{\"planNo\":\"PLAN-B\"}","{\"containerNo\":\"OTHER-CARGO\"}","{\"containerNo\":\"CANCELLED-CARGO\"}");
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(FleetTripMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(FleetTripMapper.class);
var repository=new FleetTripCalendarRepository(mapper);
assertThat(repository.sourceSnapshots(List.of())).isEmpty();
var rows=repository.sourceSnapshots(List.of("PC-A","PC-B","PC-CANCELLED"));
assertThat(rows).extracting(FleetTripCalendarRepository.Source::dispatchNo).containsExactly("PC-A","PC-A","PC-A","PC-B");
assertThat(rows).extracting(FleetTripCalendarRepository.Source::sourceSnapshotJson).containsExactly(
"{\"containerNo\":\"TSTU1234567\"}","{\"containerNo\":\"TSTU1234567\"}","{\"containerNo\":\"TSTU7654321\"}","{\"planNo\":\"PLAN-B\"}");
}
} }
@@ -11,7 +11,7 @@ import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
class FleetTripCalendarServiceTest { class FleetTripCalendarServiceTest {
final FleetTripMapper trips=mock(FleetTripMapper.class); final FleetTripCalendarRepository trips=mock(FleetTripCalendarRepository.class);
final FleetWarehouseClient warehouses=mock(FleetWarehouseClient.class); final FleetWarehouseClient warehouses=mock(FleetWarehouseClient.class);
final FleetTripCalendarService service=new FleetTripCalendarService(trips,warehouses,new ObjectMapper(),"internal-test"); final FleetTripCalendarService service=new FleetTripCalendarService(trips,warehouses,new ObjectMapper(),"internal-test");
final Instant from=Instant.parse("2026-10-01T00:00:00Z"),to=from.plus(Duration.ofDays(44)); final Instant from=Instant.parse("2026-10-01T00:00:00Z"),to=from.plus(Duration.ofDays(44));
@@ -107,4 +107,51 @@ 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() {
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.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\"}"),
new FleetTripCalendarRepository.Source("PC-A","{\"containerNo\":\"TSTU7654321\"}"),
new FleetTripCalendarRepository.Source("PC-A","{\"containerNo\":null}"),
new FleetTripCalendarRepository.Source("PC-B","{}"),
new FleetTripCalendarRepository.Source("PC-B","{\"containerNo\":\" \"}")));
metadata(List.of(warehouse(1,"US","America/Los_Angeles")),List.of());
var result=service.calendar(from,to);
assertThat(result.records()).hasSize(2);
assertThat(result.records().get(0).containerNumbers()).containsExactly("TSTU1234567","TSTU7654321");
assertThat(result.records().get(1).containerNumbers()).isEmpty();
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.unresolvedRecords()).isEmpty();
verify(trips).calendar(any(),any());verify(trips).sourceSnapshots(List.of("PC-A","PC-B"));verifyNoMoreInteractions(trips);
verify(warehouses).platformMetadata("internal-test",new FleetWarehouseClient.MetadataRequest(List.of(1L)));
verifyNoMoreInteractions(warehouses);
}
/** 缺时区的主单仍保留真实柜号,不将货物信息丢失误作起运地已核实。 */
@Test void keepsKnownContainersOnAnUnresolvedOrigin() {
when(trips.calendar(any(),any())).thenReturn(List.of(trip("PC-UNKNOWN",1L)));
when(trips.sourceSnapshots(List.of("PC-UNKNOWN"))).thenReturn(List.of(
new FleetTripCalendarRepository.Source("PC-UNKNOWN","{\"containerNo\":\"TSTU1234567\"}")));
metadata(List.of(warehouse(1,"US",null)),List.of());
var result=service.calendar(from,to);assertThat(result.records()).isEmpty();
assertThat(result.unresolvedRecords()).singleElement().satisfies(row->{
assertThat(row.containerNumbers()).containsExactly("TSTU1234567");
assertThat(row.incompleteReasons()).containsExactly("ORIGIN_TIMEZONE_UNKNOWN");
});
}
/** 损坏或非当前正式文本快照明确失败,不伪造空数组或静默隐藏整月。 */
@Test void rejectsMalformedOrForeignSourceSnapshotsExplicitly() {
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\"]}","{} {}")) {
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("柜号资料暂不可用");
}
when(trips.sourceSnapshots(List.of("PC-A"))).thenReturn(List.of(new FleetTripCalendarRepository.Source("OTHER-PC","{}")));
assertThatThrownBy(()->service.calendar(from,to)).isInstanceOf(ResponseStatusException.class).hasMessageContaining("柜号资料暂不可用");
verifyNoInteractions(warehouses);
}
} }