fix(appointments): open canonical dispatch details from calendar

This commit is contained in:
2026-10-20 16:36:22 +08:00
parent da6b4685d7
commit 6f31fa2131
7 changed files with 62 additions and 33 deletions
@@ -0,0 +1,12 @@
package com.baiyue.lastleg.fleet.trip;
/** 日历专用只读查询投影,真实排车号不属于车次持久化字段。 */
public class FleetTripCalendarRow extends FleetTripEntity {
private String dispatchNo;
/** 返回关联整车主单的业务号。 */
public String getDispatchNo() { return dispatchNo; }
/** 仅供日历SQL结果映射,不写入车次表。 */
public void setDispatchNo(String dispatchNo) { this.dispatchNo = dispatchNo; }
}
@@ -11,10 +11,10 @@ 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,
public record Entry(String sourceType, String dispatchNo, String status, String originWarehouseId,
String originWarehouseCode, String originWarehouseName, String originCountryCode,
String originTimezone, String originWarehouseStatus, Instant plannedDepartureAt,
Instant expectedDriverEndAt, Instant departedAt, Instant completedAt,
@@ -31,12 +31,13 @@ public class FleetTripCalendarService {
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.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);
@@ -60,7 +61,7 @@ public class FleetTripCalendarService {
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(),
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));
@@ -7,17 +7,18 @@ import org.apache.ibatis.annotations.Select;
@Mapper
public interface FleetTripMapper extends BaseMapper<FleetTripEntity> {
/** 预约日历只读海外仓出库派送,取货入仓车次不属于排车类型。 */
/** 日历只读真实整车主单的出库派送,一单一条;独立车次及取消主单不进入排车日历。 */
@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 trip_purpose='DELIVERY' AND (
(departed_at IS NOT NULL AND departed_at>=#{from} AND departed_at<#{to})
OR (departed_at IS NULL AND planned_departure_at>=#{from} AND planned_departure_at<#{to}))
ORDER BY COALESCE(departed_at,planned_departure_at),trip_no LIMIT 2001
SELECT d.dispatch_no,t.trip_no,t.status,t.warehouse_id,t.warehouse_code,t.warehouse_name,t.planned_departure_at,
t.expected_driver_end_at,t.departed_at,t.completed_at,t.stops_json
FROM ll_dispatch_order d JOIN ll_fleet_trip t ON t.id=d.trip_id
WHERE d.status='ACTIVE' AND d.dispatch_no IS NOT NULL AND TRIM(d.dispatch_no)<>''
AND t.trip_purpose='DELIVERY' AND (
(t.departed_at IS NOT NULL AND t.departed_at>=#{from} AND t.departed_at<#{to})
OR (t.departed_at IS NULL AND t.planned_departure_at>=#{from} AND t.planned_departure_at<#{to}))
ORDER BY COALESCE(t.departed_at,t.planned_departure_at),d.dispatch_no LIMIT 2001
""")
java.util.List<FleetTripEntity> 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);
@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}")
@@ -18,13 +18,14 @@ import org.springframework.web.server.ResponseStatusException;
@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')")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
public ApiResponse<FleetTripCalendarService.Result> 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,"仅平台主体可读取车次日历");
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台主体可读取排车日历");
return ApiResponse.success(calendar.calendar(from,to));
}
@@ -48,13 +48,14 @@ paths:
available: {type: boolean}
/api/platform/v1/last-leg/fleet/trips/calendar:
get:
summary: 读取美国车次用车日历来源
summary: 读取美国真实整车排车日历来源
description: >-
仅PLATFORM且平台公共主体(tenantId=0),权限 platform:fleet:trip:list。from/to 是明确UTC时间点,已发车按实际发车时间、尚未发车按计划发车时间读取半开区间,最多44天;
仅PLATFORM且平台公共主体(tenantId=0),权限 platform:overseas-warehouse:dispatch:list,与排车列表及详情一致。from/to 是明确UTC时间点,已发车按实际发车时间、尚未发车按计划发车时间读取半开区间,最多44天;
该保护性窗口供界面最多42个当地日历日加时区边界使用,不扩大界面范围。
仅出库派送用途(DELIVERY);上门取货入仓用途(PICKUP)不属于此日历排车来源。
最多2000条候选,超限明确拒绝并要求缩小日期范围,不分页扫描、不静默截断。
一车一条,保留取消及完成状态;保留原计划与实际时间,已执行事实不随计划改期移动,不表示收货预约已确认。
仅关联ll_dispatch_order中ACTIVE真实主单,返回dispatchNo;独立旧车次和取消排车主单排除,一张排车主单一条。
保留原计划与实际时间,已执行事实不随计划改期移动,不表示收货预约已确认;日历跳排车详情,不使用内部车次号拼排车路径。
已知非美国仓排除;未知仓库/国家/地理IANA时区进入unresolvedRecords,不落入美国当地日槽。
一次车次查询加一次仓库批量资料请求,包含停用仓历史资料,资料服务失败返回错误而非空日历。
parameters:
@@ -358,10 +359,10 @@ components:
schemas:
TripCalendarEntry:
type: object
required: [sourceType, tripNo, status, plannedDepartureAt, expectedDriverEndAt, stops, incompleteReasons]
required: [sourceType, dispatchNo, status, plannedDepartureAt, expectedDriverEndAt, stops, incompleteReasons]
properties:
sourceType: {type: string, enum: [TRIP_PLAN]}
tripNo: {type: string}
sourceType: {type: string, enum: [DISPATCH]}
dispatchNo: {type: string, description: 真实整车主单排车号,用于排车详情路由}
status: {type: string, enum: [PLANNED, IN_TRANSIT, COMPLETED, CANCELLED]}
originWarehouseId: {type: string, nullable: true}
originWarehouseCode: {type: string, nullable: true}
@@ -11,11 +11,16 @@ import java.util.*;
import static org.assertj.core.api.Assertions.*;
class FleetTripCalendarMapperTest {
/** 同时验证计划和实发的半开范围,取货车次不能混入送仓日历。 */
/** 测试中为每个运输资源建立唯一整车主单,独立旧车次始终不绑定主单。 */
private void bindDispatches(JdbcTemplate jdbc) {
jdbc.execute("INSERT INTO ll_dispatch_order(dispatch_no,trip_id,status) SELECT CONCAT('DS-',t.trip_no),t.id,'ACTIVE' FROM ll_fleet_trip t WHERE t.trip_no<>'TR-UNBOUND' AND NOT EXISTS(SELECT 1 FROM ll_dispatch_order d WHERE d.trip_id=t.id)");
}
/** 验证真实主单关系、计划/实发半开区间及取货排除,不把资源车次号当排车号。 */
@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),trip_purpose VARCHAR(32) DEFAULT 'DELIVERY',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))");
jdbc.execute("CREATE TABLE ll_fleet_trip(id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,trip_no VARCHAR(64) UNIQUE,status VARCHAR(32),trip_purpose VARCHAR(32) DEFAULT 'DELIVERY',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))");
jdbc.execute("CREATE TABLE ll_dispatch_order(dispatch_no VARCHAR(80) PRIMARY KEY,trip_id BIGINT UNIQUE,status VARCHAR(32))");
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);
@@ -26,11 +31,17 @@ class FleetTripCalendarMapperTest {
jdbc.update(insert,"TR-PICKUP-ACTUAL","IN_TRANSIT",from.minusDays(2),to);
jdbc.update("UPDATE ll_fleet_trip SET trip_purpose='PICKUP' WHERE trip_no LIKE 'TR-PICKUP-%'");
jdbc.update("UPDATE ll_fleet_trip SET departed_at=? WHERE trip_no='TR-PICKUP-ACTUAL'",from.plusHours(1));
bindDispatches(jdbc);
jdbc.update(insert,"TR-UNBOUND","PLANNED",from,to);
jdbc.update(insert,"TR-CANCELLED-DISPATCH","PLANNED",from,to);
bindDispatches(jdbc);
jdbc.update("UPDATE ll_dispatch_order SET status='CANCELLED' WHERE dispatch_no='DS-TR-CANCELLED-DISPATCH'");
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(FleetTripCalendarRow::getDispatchNo).containsExactly("DS-TR-FROM","DS-TR-LAST");
assertThat(rows).extracting(FleetTripEntity::getStatus).containsExactly("CANCELLED","COMPLETED");
assertThat(rows.get(0).getPlannedDepartureAt()).isEqualTo(from);
// The actual month is authoritative even when the original plan is outside this window.
@@ -40,6 +51,7 @@ class FleetTripCalendarMapperTest {
jdbc.update("UPDATE ll_fleet_trip SET departed_at=? WHERE trip_no=?",to,"TR-ACTUAL-OUT");
jdbc.update(insert,"TR-ACTUAL-FROM","IN_TRANSIT",from.minusDays(2),to);
jdbc.update("UPDATE ll_fleet_trip SET departed_at=? WHERE trip_no=?",from,"TR-ACTUAL-FROM");
bindDispatches(jdbc);
assertThat(mapper.calendar(from,to)).extracting(FleetTripEntity::getTripNo)
.containsExactly("TR-ACTUAL-FROM","TR-FROM","TR-ACTUAL-IN","TR-LAST");
jdbc.update("UPDATE ll_fleet_trip SET planned_departure_at=? WHERE trip_no=?",to.plusDays(20),"TR-ACTUAL-IN");
@@ -47,6 +59,7 @@ class FleetTripCalendarMapperTest {
.containsExactly("TR-ACTUAL-FROM","TR-FROM","TR-ACTUAL-IN","TR-LAST");
var many=new ArrayList<Object[]>();for(int i=0;i<2002;i++)many.add(new Object[]{"TR-MANY-"+i,"PLANNED",from.plusHours(1),to});
jdbc.batchUpdate(insert,many);
bindDispatches(jdbc);
assertThat(mapper.calendar(from,to)).hasSize(2001);
}
}
@@ -15,8 +15,8 @@ class FleetTripCalendarServiceTest {
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");
FleetTripCalendarRow trip(String no,Long warehouse) {
var row=new FleetTripCalendarRow();row.setTripNo("INTERNAL-"+no);row.setDispatchNo(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;
@@ -44,13 +44,13 @@ class FleetTripCalendarServiceTest {
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.sourceType()).isEqualTo("DISPATCH");assertThat(row.dispatchNo()).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()).extracting(FleetTripCalendarService.Entry::dispatchNo).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");
@@ -78,7 +78,7 @@ class FleetTripCalendarServiceTest {
}
@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')");
assertThat(method.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class).value()).isEqualTo("hasAuthority('platform:overseas-warehouse:dispatch:list')");
}
@Test void systemVAndFixedOffsetsAreNotGeographicOriginTimezones() {
when(trips.calendar(any(),any())).thenReturn(List.of(trip("TR-1",1L)));
@@ -98,13 +98,13 @@ class FleetTripCalendarServiceTest {
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"))))
new com.baiyue.common.security.AuthenticatedUser(1L,0L,0L,1L,"client","MERCHANT","WEB",Set.of("platform:overseas-warehouse:dispatch:list")),
new com.baiyue.common.security.AuthenticatedUser(1L,9L,9L,1L,"client","PLATFORM","WEB",Set.of("platform:overseas-warehouse:dispatch:list")),
new com.baiyue.common.security.AuthenticatedUser(1L,null,0L,1L,"client","PLATFORM","WEB",Set.of("platform:overseas-warehouse:dispatch:list")),
new com.baiyue.common.security.AuthenticatedUser(null,0L,0L,1L,"client","PLATFORM","WEB",Set.of("platform:overseas-warehouse:dispatch: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"));
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);
}
}