diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarRepository.java new file mode 100644 index 000000000..3e877bc27 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarRepository.java @@ -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 calendar(LocalDateTime from, LocalDateTime to) { + return mapper.calendar(from, to); + } + + /** 一次读取指定主单的全部货物快照,空窗口不发送空IN查询。 */ + public List sourceSnapshots(List dispatchNumbers) { + if (dispatchNumbers.isEmpty()) return List.of(); + return mapper.calendarSources(dispatchNumbers).stream() + .map(row -> new Source(row.dispatchNo(), row.sourceSnapshotJson())).toList(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarService.java index e2c86612c..caeabfde1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarService.java @@ -6,6 +6,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; import java.time.*; @@ -18,27 +19,29 @@ public class FleetTripCalendarService { String originWarehouseCode, String originWarehouseName, String originCountryCode, String originTimezone, String originWarehouseStatus, Instant plannedDepartureAt, Instant expectedDriverEndAt, Instant departedAt, Instant completedAt, - List stops, List incompleteReasons) { } + List stops, List containerNumbers, List incompleteReasons) { } public record Result(List records, List unresolvedRecords) { } private static final Set COUNTRIES = Set.of(Locale.getISOCountries()); - private final FleetTripMapper trips; + private final FleetTripCalendarRepository trips; private final FleetWarehouseClient warehouses; private final ObjectMapper json; 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) { 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) { 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时间范围"); 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.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 metadata=metadata(ids); var records=new ArrayList(); var unresolved=new ArrayList(); @@ -64,12 +67,35 @@ public class FleetTripCalendarService { 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(), 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); } return new Result(List.copyOf(records),List.copyOf(unresolved)); } + /** 柜号只取排车建单时正式保存的SourceDisplay.containerNo,不反查或推测其他来源。 */ + private Map> containerNumbers(List rows) { + var numbers=rows.stream().map(FleetTripCalendarRow::getDispatchNo).distinct().sorted().toList(); + var requested=new HashSet<>(numbers);var grouped=new LinkedHashMap>(); + 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>(); + grouped.forEach((number,values)->result.put(number,List.copyOf(values)));return Map.copyOf(result); + } + + /** 元数据批次必须完整覆盖请求身份,未知地点保持显式未核实。 */ private Map metadata(List ids) { if (ids.isEmpty()) return Map.of(); try { @@ -85,6 +111,10 @@ public class FleetTripCalendarService { return result; } catch (RuntimeException failure) { throw unavailable(); } } + /** 持久UTC时间只转换为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 sourceUnavailable() { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"排车货物柜号资料暂不可用,请重试"); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripMapper.java index 1d40b80b7..391401a36 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripMapper.java @@ -19,6 +19,18 @@ public interface FleetTripMapper extends BaseMapper { ORDER BY COALESCE(t.departed_at,t.planned_departure_at),d.dispatch_no LIMIT 2001 """) java.util.List calendar(@Param("from") java.time.LocalDateTime from,@Param("to") java.time.LocalDateTime to); + record CalendarSource(String dispatchNo, String sourceSnapshotJson) { } + /** 主查询限额之后批量读取原货物快照,不以货物JOIN放大整车记录或截断多柜。 */ + @Select(""" + + """) + java.util.List calendarSources(@Param("numbers") java.util.List dispatchNumbers); @Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo} FOR UPDATE") FleetTripEntity lockByNo(@Param("tripNo") String tripNo); @Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo}") diff --git a/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml index 9a62a6835..fcfcd23db 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml @@ -359,7 +359,7 @@ components: schemas: TripCalendarEntry: type: object - required: [sourceType, dispatchNo, status, plannedDepartureAt, expectedDriverEndAt, stops, incompleteReasons] + required: [sourceType, dispatchNo, status, plannedDepartureAt, expectedDriverEndAt, stops, containerNumbers, incompleteReasons] properties: sourceType: {type: string, enum: [DISPATCH]} dispatchNo: {type: string, description: 真实整车主单排车号,用于排车详情路由} @@ -375,6 +375,11 @@ components: departedAt: {type: string, format: date-time, nullable: true} completedAt: {type: string, format: date-time, nullable: true} stops: {type: array, items: {$ref: '#/components/schemas/Stop'}} + containerNumbers: + type: array + description: 本车全部货物正式来源快照中的真实柜号,去空去重;多客户、多柜保留完整数组,无柜号为空数组,不推测或补造。 + items: {type: string} + uniqueItems: true incompleteReasons: type: array items: {type: string, enum: [ORIGIN_WAREHOUSE_UNKNOWN, ORIGIN_COUNTRY_UNKNOWN, ORIGIN_TIMEZONE_UNKNOWN, ROUTE_INCOMPLETE]} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarMapperTest.java index 1e373681c..00f4bf394 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarMapperTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarMapperTest.java @@ -62,4 +62,24 @@ class FleetTripCalendarMapperTest { bindDispatches(jdbc); 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\"}"); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarServiceTest.java index 94c798b88..7b55e53b9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarServiceTest.java @@ -11,7 +11,7 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; class FleetTripCalendarServiceTest { - final FleetTripMapper trips=mock(FleetTripMapper.class); + final FleetTripCalendarRepository trips=mock(FleetTripCalendarRepository.class); final FleetWarehouseClient warehouses=mock(FleetWarehouseClient.class); 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)); @@ -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")); 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); + } }