feat(appointments): expose authoritative arrival forecast observations

This commit is contained in:
2026-10-10 14:10:17 +08:00
parent 71111bf21b
commit 4b5b639adc
6 changed files with 199 additions and 0 deletions
@@ -0,0 +1,28 @@
package com.baiyue.firstleg.portschedule.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.portschedule.service.AppointmentForecastService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.List;
@RestController
@RequestMapping("/internal/firstleg/appointment-forecasts")
public class InternalAppointmentForecastController {
public record Request(List<Long> containerBookingIds) { }
private final AppointmentForecastService service;
private final String token;
public InternalAppointmentForecastController(AppointmentForecastService service,
@Value("${baiyue.security.internal-service-token:}") String token){this.service=service;this.token=token;}
@PostMapping("/batch")
public ApiResponse<List<AppointmentForecastService.Forecast>> batch(
@RequestHeader(value="X-Internal-Service-Token",required=false) String supplied,@RequestBody Request request) {
if(token==null || token.isBlank() || supplied==null || !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8),supplied.getBytes(StandardCharsets.UTF_8)))
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,"内部身份无效");
return ApiResponse.success(service.batch(request==null?null:request.containerBookingIds()));
}
}
@@ -0,0 +1,26 @@
package com.baiyue.firstleg.portschedule.mapper;
import org.apache.ibatis.annotations.*;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface AppointmentForecastMapper {
record Fact(Long containerBookingId,Long factId,String providerCode,String sourceReference,
LocalDateTime observedAt,LocalDateTime estimatedAt,LocalDateTime actualAt) { }
@Select("""
<script>
SELECT container_booking_id,fact_id,provider_code,source_reference,observed_at,estimated_at,actual_at
FROM (
SELECT f.container_booking_id,f.id AS fact_id,f.provider_code,f.source_reference,
f.observed_at,f.estimated_at,f.actual_at,
ROW_NUMBER() OVER (PARTITION BY f.container_booking_id ORDER BY f.observed_at DESC,f.id DESC) AS position
FROM fl_carrier_schedule_node_fact f JOIN fl_container_booking c ON c.id=f.container_booking_id
WHERE c.record_status='ACTIVE' AND f.standard_node='ARRIVED_DESTINATION_PORT'
AND f.container_booking_id IN
<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>
) ranked WHERE position&lt;=2 ORDER BY container_booking_id,position
</script>
""")
List<Fact> latest(@Param("ids") List<Long> containerIds);
}
@@ -0,0 +1,26 @@
package com.baiyue.firstleg.portschedule.service;
import com.baiyue.firstleg.portschedule.mapper.AppointmentForecastMapper;
import org.springframework.stereotype.Service;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.util.*;
import java.util.stream.Collectors;
/** Forecast facts are read from their owning service, never inferred from an order snapshot. */
@Service
public class AppointmentForecastService {
public record Forecast(Long containerBookingId,AppointmentForecastMapper.Fact latest,
AppointmentForecastMapper.Fact previous,String timeBasis) { }
private final AppointmentForecastMapper mapper;
public AppointmentForecastService(AppointmentForecastMapper mapper){this.mapper=mapper;}
public List<Forecast> batch(List<Long> ids) {
if(ids==null || ids.isEmpty() || ids.size()>200 || ids.stream().anyMatch(id->id==null || id<=0)
|| new HashSet<>(ids).size()!=ids.size()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"每批须提供1至200个不重复的柜次");
var facts=mapper.latest(ids).stream().collect(Collectors.groupingBy(AppointmentForecastMapper.Fact::containerBookingId));
return ids.stream().map(id->{
var rows=facts.getOrDefault(id,List.of());
return new Forecast(id,rows.isEmpty()?null:rows.get(0),rows.size()<2?null:rows.get(1),"SOURCE_LOCAL_UNSPECIFIED");
}).toList();
}
}