diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/FleetWarehouseClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/FleetWarehouseClient.java index 82770b304..815a1e131 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/FleetWarehouseClient.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/FleetWarehouseClient.java @@ -5,10 +5,17 @@ import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import java.util.List; @FeignClient(name = "warehouse-service", contextId = "fleetWarehouseClient", url = "${baiyue.clients.domestic-warehouse.url:}") public interface FleetWarehouseClient { + @PostMapping("/internal/warehouses/platform-metadata/batch") + ApiResponse platformMetadata(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody MetadataRequest request); + record MetadataRequest(List warehouseIds) { } + record Metadata(Long id, String warehouseCode, String warehouseName, String warehouseType, String countryCode, String timezone, String status) { } + record MetadataResult(List records, List missingIds) { } @GetMapping("/internal/warehouses/order-catalog") ApiResponse> activeWarehouses(@RequestHeader("X-Internal-Service-Token") String token); @GetMapping("/internal/warehouses/0/{warehouseId}/active") 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 new file mode 100644 index 000000000..f183971b3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarService.java @@ -0,0 +1,89 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.lastleg.fleet.driver.FleetWarehouseClient; +import com.fasterxml.jackson.core.type.TypeReference; +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.Transactional; +import org.springframework.web.server.ResponseStatusException; +import java.time.*; +import java.util.*; + +/** Read-only transport plans. A trip is never evidence of a receiving appointment confirmation. */ +@Service +public class FleetTripCalendarService { + public record Entry(String sourceType, String tripNo, String status, String originWarehouseId, + String originWarehouseCode, String originWarehouseName, String originCountryCode, + String originTimezone, String originWarehouseStatus, Instant plannedDepartureAt, + Instant expectedDriverEndAt, Instant departedAt, Instant completedAt, + List stops, List incompleteReasons) { } + public record Result(List records, List unresolvedRecords) { } + private static final Set COUNTRIES = Set.of(Locale.getISOCountries()); + private final FleetTripMapper trips; + private final FleetWarehouseClient warehouses; + private final ObjectMapper json; + private final String token; + + public FleetTripCalendarService(FleetTripMapper 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) + 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 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(); + for (var row:rows) { + var warehouse=row.getWarehouseId()==null?null:metadata.get(row.getWarehouseId()); + var reasons=new ArrayList(); + String country=warehouse==null?null:warehouse.countryCode(); + if (country!=null && COUNTRIES.contains(country) && !"US".equals(country)) continue; + if (warehouse==null) reasons.add("ORIGIN_WAREHOUSE_UNKNOWN"); + if (country==null || !COUNTRIES.contains(country)) reasons.add("ORIGIN_COUNTRY_UNKNOWN"); + String zone=warehouse==null?null:warehouse.timezone(); + if (zone==null || !zone.contains("/") || zone.startsWith("Etc/") || zone.startsWith("SystemV/") || !ZoneId.getAvailableZoneIds().contains(zone)) { + zone=null; reasons.add("ORIGIN_TIMEZONE_UNKNOWN"); + } + boolean unresolvedOrigin=!reasons.isEmpty(); + List stops; + try { + stops=json.readValue(row.getStopsJson(),new TypeReference>() { }); + if (stops==null || stops.size()<2 || stops.stream().anyMatch(s->s==null || s.name()==null || s.name().isBlank())) + throw new IllegalArgumentException("incomplete route"); + stops=List.copyOf(stops); + } catch (Exception invalid) { stops=List.of(); reasons.add("ROUTE_INCOMPLETE"); } + var entry=new Entry("TRIP_PLAN",row.getTripNo(),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)); + (unresolvedOrigin?unresolved:records).add(entry); + } + return new Result(List.copyOf(records),List.copyOf(unresolved)); + } + + private Map metadata(List ids) { + if (ids.isEmpty()) return Map.of(); + try { + var response=warehouses.platformMetadata(token,new FleetWarehouseClient.MetadataRequest(ids)); + if (response==null || !response.successful() || response.data()==null || response.data().records()==null || response.data().missingIds()==null) + throw unavailable(); + var result=new HashMap(); var requested=new HashSet<>(ids); + for (var row:response.data().records()) { + if (row==null || !requested.contains(row.id()) || result.putIfAbsent(row.id(),row)!=null) throw unavailable(); + } + var missing=new HashSet<>(response.data().missingIds()); var expectedMissing=new HashSet<>(requested); expectedMissing.removeAll(result.keySet()); + if (missing.size()!=response.data().missingIds().size() || !missing.equals(expectedMissing)) throw unavailable(); + return result; + } catch (RuntimeException failure) { throw unavailable(); } + } + private static Instant instant(LocalDateTime value) { return value==null?null:value.toInstant(ZoneOffset.UTC); } + private static ResponseStatusException unavailable() { 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 430dcc49e..f9cf3a49d 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 @@ -7,6 +7,8 @@ import org.apache.ibatis.annotations.Select; @Mapper public interface FleetTripMapper extends BaseMapper { + @Select("SELECT trip_no,status,warehouse_id,warehouse_code,warehouse_name,planned_departure_at,expected_driver_end_at,departed_at,completed_at,stops_json FROM ll_fleet_trip WHERE planned_departure_at>=#{from} AND planned_departure_at<#{to} ORDER BY planned_departure_at,trip_no LIMIT 2001") + java.util.List calendar(@Param("from") java.time.LocalDateTime from,@Param("to") java.time.LocalDateTime to); @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/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripController.java index e21203694..de7d3c019 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripController.java @@ -17,6 +17,16 @@ import org.springframework.web.server.ResponseStatusException; @Validated @RequestMapping("/api/platform/v1/last-leg/fleet/trips") public class PlatformFleetTripController { + @jakarta.annotation.Resource private FleetTripCalendarService calendar; + @GetMapping("/calendar") + @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse calendar(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam java.time.Instant from,@RequestParam java.time.Instant to) { + operator(user); + if (!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId())) + throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台主体可读取车次日历"); + return ApiResponse.success(calendar.calendar(from,to)); + } @jakarta.annotation.Resource private FleetTripPreparationService preparation; @PostMapping("/prepare") @PreAuthorize("hasAuthority('platform:fleet:trip:edit')") 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 66262cf51..9af723579 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml @@ -27,6 +27,37 @@ info: security: - bearerAuth: [] paths: + /api/platform/v1/last-leg/fleet/trips/calendar: + get: + summary: 读取美国车次计划日历来源 + description: >- + 仅PLATFORM且平台公共主体(tenantId=0),权限 platform:fleet:trip:list。from/to 是明确UTC时间点,按计划发车时间读取半开区间,最多44天; + 该保护性窗口供界面最多42个当地日历日加时区边界使用,不扩大界面范围。 + 最多2000条候选,超限明确拒绝并要求缩小日期范围,不分页扫描、不静默截断。 + 一车一条,保留取消及完成状态;记录只表示用车计划,不表示收货预约已确认。 + 已知非美国仓排除;未知仓库/国家/地理IANA时区进入unresolvedRecords,不落入美国当地日槽。 + 一次车次查询加一次仓库批量资料请求,包含停用仓历史资料,资料服务失败返回错误而非空日历。 + parameters: + - {name: from, in: query, required: true, schema: {type: string, format: date-time}} + - {name: to, in: query, required: true, schema: {type: string, format: date-time}} + responses: + '200': + description: 统一业务响应;UTC事实保持原值,司机预计结束不是收货预约时间 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - properties: + data: + type: object + required: [records, unresolvedRecords] + properties: + records: {type: array, items: {$ref: '#/components/schemas/TripCalendarEntry'}} + unresolvedRecords: {type: array, items: {$ref: '#/components/schemas/TripCalendarEntry'}} + '400': {description: 时间范围无效或候选车次超过2000条} + '403': {description: 缺少车次查看权限} + '503': {description: 仓库元数据无法可靠读取} /api/platform/v1/last-leg/fleet/trips: get: summary: 分页查询车次 @@ -338,6 +369,27 @@ components: reason: {type: string} licenseExpiresOn: {type: string, format: date, nullable: true} schemas: + TripCalendarEntry: + type: object + required: [sourceType, tripNo, status, plannedDepartureAt, expectedDriverEndAt, stops, incompleteReasons] + properties: + sourceType: {type: string, enum: [TRIP_PLAN]} + tripNo: {type: string} + status: {type: string, enum: [PLANNED, IN_TRANSIT, COMPLETED, CANCELLED]} + originWarehouseId: {type: string, nullable: true} + originWarehouseCode: {type: string, nullable: true} + originWarehouseName: {type: string, nullable: true} + originCountryCode: {type: string, nullable: true} + originTimezone: {type: string, nullable: true, description: 地理IANA时区;未知、固定偏移、Etc及SystemV标记为空,不猜UTC} + originWarehouseStatus: {type: string, nullable: true} + plannedDepartureAt: {type: string, format: date-time} + expectedDriverEndAt: {type: string, format: date-time, description: 司机任务预计结束,不是收货预约时间} + departedAt: {type: string, format: date-time, nullable: true} + completedAt: {type: string, format: date-time, nullable: true} + stops: {type: array, items: {$ref: '#/components/schemas/Stop'}} + incompleteReasons: + type: array + items: {type: string, enum: [ORIGIN_WAREHOUSE_UNKNOWN, ORIGIN_COUNTRY_UNKNOWN, ORIGIN_TIMEZONE_UNKNOWN, ROUTE_INCOMPLETE]} BookingCargo: type: object required: [destinationKey] 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 new file mode 100644 index 000000000..9940bb7f0 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarMapperTest.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.time.LocalDateTime; +import java.util.*; +import static org.assertj.core.api.Assertions.*; + +class FleetTripCalendarMapperTest { + @Test void queriesAHalfOpenUtcWindowWithoutStatusFilteringAndCapsCandidateRows() 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_fleet_trip(trip_no VARCHAR(64) PRIMARY KEY,status VARCHAR(32),warehouse_id BIGINT,warehouse_code VARCHAR(64),warehouse_name VARCHAR(100),planned_departure_at TIMESTAMP,expected_driver_end_at TIMESTAMP,departed_at TIMESTAMP,completed_at TIMESTAMP,stops_json VARCHAR(2000))"); + var from=LocalDateTime.of(2026,11,1,0,0);var to=from.plusDays(1); + String insert="INSERT INTO ll_fleet_trip(trip_no,status,warehouse_id,planned_departure_at,expected_driver_end_at,stops_json) VALUES(?,?,1,?,?, '[]')"; + jdbc.update(insert,"TR-BEFORE","PLANNED",from.minusSeconds(1),to); + jdbc.update(insert,"TR-FROM","CANCELLED",from,to); + jdbc.update(insert,"TR-LAST","COMPLETED",to.minusSeconds(1),to); + jdbc.update(insert,"TR-TO","IN_TRANSIT",to,to.plusDays(1)); + 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 rows=mapper.calendar(from,to); + assertThat(rows).extracting(FleetTripEntity::getTripNo).containsExactly("TR-FROM","TR-LAST"); + assertThat(rows).extracting(FleetTripEntity::getStatus).containsExactly("CANCELLED","COMPLETED"); + assertThat(rows.get(0).getPlannedDepartureAt()).isEqualTo(from); + var many=new ArrayList();for(int i=0;i<2002;i++)many.add(new Object[]{"TR-MANY-"+i,"PLANNED",from.plusHours(1),to}); + jdbc.batchUpdate(insert,many); + assertThat(mapper.calendar(from,to)).hasSize(2001); + } +} 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 new file mode 100644 index 000000000..6f03e3c3c --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarServiceTest.java @@ -0,0 +1,110 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.lastleg.fleet.driver.FleetWarehouseClient; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import java.time.*; +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class FleetTripCalendarServiceTest { + final FleetTripMapper trips=mock(FleetTripMapper.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)); + FleetTripEntity trip(String no,Long warehouse) { + var row=new FleetTripEntity();row.setTripNo(no);row.setWarehouseId(warehouse);row.setStatus("PLANNED"); + row.setPlannedDepartureAt(LocalDateTime.ofInstant(from,ZoneOffset.UTC)); + row.setExpectedDriverEndAt(row.getPlannedDepartureAt().plusDays(2)); + row.setStopsJson("[{\"name\":\"Origin\"},{\"name\":\"ACY2\",\"destinationKey\":\"FBA:ACY2\"}]");return row; + } + FleetWarehouseClient.Metadata warehouse(long id,String country,String timezone) { + return new FleetWarehouseClient.Metadata(id,"W-"+id,"Warehouse "+id,"OVERSEAS",country,timezone,"INACTIVE"); + } + void metadata(List records,List missing) { + when(warehouses.platformMetadata(anyString(),any())).thenReturn(ApiResponse.success(new FleetWarehouseClient.MetadataResult(records,missing))); + } + @Test void validatesProtectiveUtcWindowAndDoesNotFetchMetadataForAnEmptyCalendar() { + assertThatThrownBy(()->service.calendar(null,to)).isInstanceOf(ResponseStatusException.class); + assertThatThrownBy(()->service.calendar(from,from)).isInstanceOf(ResponseStatusException.class); + assertThatThrownBy(()->service.calendar(to,from)).isInstanceOf(ResponseStatusException.class); + assertThatThrownBy(()->service.calendar(from,to.plusNanos(1))).isInstanceOf(ResponseStatusException.class); + verifyNoInteractions(trips,warehouses); + when(trips.calendar(any(),any())).thenReturn(List.of()); + assertThat(service.calendar(from,to).records()).isEmpty(); + verify(trips).calendar(LocalDateTime.of(2026,10,1,0,0),LocalDateTime.of(2026,11,14,0,0)); + verifyNoInteractions(warehouses); + } + @Test void preservesUtcFactsAndInactiveOriginsWhileSeparatingUnknownSourcesFromUsSlots() { + var us=trip("TR-US",1L);us.setStatus("CANCELLED");us.setDepartedAt(us.getPlannedDepartureAt().plusHours(1)); + when(trips.calendar(any(),any())).thenReturn(List.of(us,trip("TR-CA",2L),trip("TR-UTC",3L),trip("TR-COUNTRY",4L),trip("TR-MISSING",5L),trip("TR-NO-ORIGIN",null))); + metadata(List.of(warehouse(1,"US","America/Los_Angeles"),warehouse(2,"CA","America/Toronto"),warehouse(3,"US","UTC"),warehouse(4,null,"America/New_York")),List.of(5L)); + var result=service.calendar(from,to); + assertThat(result.records()).singleElement().satisfies(row->{ + assertThat(row.sourceType()).isEqualTo("TRIP_PLAN");assertThat(row.tripNo()).isEqualTo("TR-US"); + assertThat(row.status()).isEqualTo("CANCELLED");assertThat(row.originWarehouseStatus()).isEqualTo("INACTIVE"); + assertThat(row.plannedDepartureAt()).isEqualTo(from);assertThat(row.departedAt()).isEqualTo(from.plusSeconds(3600)); + assertThat(row.expectedDriverEndAt()).isEqualTo(from.plus(Duration.ofDays(2)));assertThat(row.completedAt()).isNull(); + assertThat(row.stops()).hasSize(2);assertThat(row.incompleteReasons()).isEmpty(); + }); + assertThat(result.unresolvedRecords()).extracting(FleetTripCalendarService.Entry::tripNo).containsExactly("TR-UTC","TR-COUNTRY","TR-MISSING","TR-NO-ORIGIN"); + assertThat(result.unresolvedRecords().get(0).originTimezone()).isNull(); + assertThat(result.unresolvedRecords().get(0).incompleteReasons()).containsExactly("ORIGIN_TIMEZONE_UNKNOWN"); + assertThat(result.unresolvedRecords().get(2).incompleteReasons()).contains("ORIGIN_WAREHOUSE_UNKNOWN"); + verify(warehouses).platformMetadata("internal-test",new FleetWarehouseClient.MetadataRequest(List.of(1L,2L,3L,4L,5L))); + verifyNoMoreInteractions(warehouses); + } + @Test void sharesOneMetadataReadAndFlagsMalformedRoutesWithoutInventingStops() { + var broken=trip("TR-BROKEN",1L);broken.setStopsJson("invalid"); + when(trips.calendar(any(),any())).thenReturn(List.of(trip("TR-1",1L),broken));metadata(List.of(warehouse(1,"US","America/New_York")),List.of()); + var result=service.calendar(from,to);assertThat(result.records()).hasSize(2); + assertThat(result.records().get(1).stops()).isEmpty();assertThat(result.records().get(1).incompleteReasons()).containsExactly("ROUTE_INCOMPLETE"); + verify(warehouses).platformMetadata("internal-test",new FleetWarehouseClient.MetadataRequest(List.of(1L)));verifyNoMoreInteractions(warehouses); + } + @Test void rejectsOverflowRatherThanReturningAPartialCalendar() { + when(trips.calendar(any(),any())).thenReturn(Collections.nCopies(2001,trip("TR-1",1L))); + assertThatThrownBy(()->service.calendar(from,to)).isInstanceOf(ResponseStatusException.class).hasMessageContaining("缩小查询日期");verifyNoInteractions(warehouses); + } + @Test void metadataFailureOrIncompleteProtocolCannotMasqueradeAsAnEmptyUsCalendar() { + when(trips.calendar(any(),any())).thenReturn(List.of(trip("TR-1",1L))); + metadata(List.of(),List.of());assertThatThrownBy(()->service.calendar(from,to)).isInstanceOf(ResponseStatusException.class).hasMessageContaining("仓库时区资料暂不可用"); + metadata(List.of(warehouse(1,"US","America/New_York"),warehouse(1,"US","America/New_York")),List.of()); + assertThatThrownBy(()->service.calendar(from,to)).isInstanceOf(ResponseStatusException.class); + when(warehouses.platformMetadata(anyString(),any())).thenThrow(new IllegalStateException("offline")); + assertThatThrownBy(()->service.calendar(from,to)).isInstanceOf(ResponseStatusException.class); + } + @Test void endpointUsesExistingReadPermission() throws Exception { + var method=PlatformFleetTripController.class.getMethod("calendar",com.baiyue.common.security.AuthenticatedUser.class,Instant.class,Instant.class); + assertThat(method.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class).value()).isEqualTo("hasAuthority('platform:fleet:trip:list')"); + } + @Test void systemVAndFixedOffsetsAreNotGeographicOriginTimezones() { + when(trips.calendar(any(),any())).thenReturn(List.of(trip("TR-1",1L))); + for (String zone:List.of("SystemV/EST5EDT","Etc/GMT+5","+08:00","UTC","not-a-zone")) { + metadata(List.of(warehouse(1,"US",zone)),List.of()); + var result=service.calendar(from,to); + assertThat(result.records()).isEmpty(); + assertThat(result.unresolvedRecords()).singleElement().satisfies(row->{ + assertThat(row.originTimezone()).isNull(); + assertThat(row.incompleteReasons()).containsExactly("ORIGIN_TIMEZONE_UNKNOWN"); + }); + } + } + @Test void nonPlatformSubjectsAreRejectedEvenWhenTheyHaveTheReadPermission() { + var controller=new PlatformFleetTripController(mock(FleetTripService.class),mock(FleetTripExecutionService.class),mock(FleetTripCompletionService.class)); + var calendar=mock(FleetTripCalendarService.class); + org.springframework.test.util.ReflectionTestUtils.setField(controller,"calendar",calendar); + assertThatThrownBy(()->controller.calendar(null,from,to)).isInstanceOf(ResponseStatusException.class); + for (var user:List.of( + new com.baiyue.common.security.AuthenticatedUser(1L,0L,0L,1L,"client","MERCHANT","WEB",Set.of("platform:fleet:trip:list")), + new com.baiyue.common.security.AuthenticatedUser(1L,9L,9L,1L,"client","PLATFORM","WEB",Set.of("platform:fleet:trip:list")), + new com.baiyue.common.security.AuthenticatedUser(1L,null,0L,1L,"client","PLATFORM","WEB",Set.of("platform:fleet:trip:list")), + new com.baiyue.common.security.AuthenticatedUser(null,0L,0L,1L,"client","PLATFORM","WEB",Set.of("platform:fleet:trip:list")))) + assertThatThrownBy(()->controller.calendar(user,from,to)).isInstanceOf(ResponseStatusException.class); + verifyNoInteractions(calendar); + var platform=new com.baiyue.common.security.AuthenticatedUser(1L,0L,0L,1L,"platform","PLATFORM","WEB",Set.of("platform:fleet:trip:list")); + controller.calendar(platform,from,to);verify(calendar).calendar(from,to); + } +}