feat(appointments): expose authoritative arrival forecast observations
This commit is contained in:
+28
@@ -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()));
|
||||
}
|
||||
}
|
||||
+26
@@ -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<=2 ORDER BY container_booking_id,position
|
||||
</script>
|
||||
""")
|
||||
List<Fact> latest(@Param("ids") List<Long> containerIds);
|
||||
}
|
||||
+26
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 预约筹划船期事实内部查询
|
||||
version: 1.0.0
|
||||
paths:
|
||||
/internal/firstleg/appointment-forecasts/batch:
|
||||
post:
|
||||
operationId: readAppointmentForecastFacts
|
||||
summary: 批量读取有效柜次最新两个到港船节点观察
|
||||
description: 仅内部服务令牌,不开放公网。最多200个不重复柜次,一次批量读取;按照观察时间及事实ID降序。最新观察没有预计时间时保持空,不沿用旧ETA;实际到港单独返回。缺失或非有效柜次返回空latest/previous。原来源时间没有可靠时区,明确SOURCE_LOCAL_UNSPECIFIED,不转成UTC或伪造备妥时间。
|
||||
security: [{internalToken: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [containerBookingIds]
|
||||
properties:
|
||||
containerBookingIds: {type: array, minItems: 1, maxItems: 200, uniqueItems: true, items: {type: integer, format: int64, minimum: 1}}
|
||||
responses:
|
||||
'200':
|
||||
description: ApiResponse;每个请求柜次恰好一行,按请求顺序
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: {type: string}
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
containerBookingId: {type: string}
|
||||
timeBasis: {type: string, enum: [SOURCE_LOCAL_UNSPECIFIED]}
|
||||
latest: {$ref: '#/components/schemas/Fact'}
|
||||
previous: {$ref: '#/components/schemas/Fact'}
|
||||
'401': {description: 内部身份缺失、错误或服务端未配置}
|
||||
components:
|
||||
securitySchemes:
|
||||
internalToken: {type: apiKey, in: header, name: X-Internal-Service-Token}
|
||||
schemas:
|
||||
Fact:
|
||||
type: object
|
||||
nullable: true
|
||||
properties:
|
||||
containerBookingId: {type: string}
|
||||
factId: {type: string, description: 技术关联标识,不展示为业务编号}
|
||||
providerCode: {type: string, nullable: true}
|
||||
sourceReference: {type: string, nullable: true}
|
||||
observedAt: {type: string, nullable: true, description: 来源观察时间}
|
||||
estimatedAt: {type: string, nullable: true, description: 来源当地预计到港时间,时区未核实}
|
||||
actualAt: {type: string, nullable: true, description: 来源实际到港时间,与预测分开}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.firstleg.portschedule.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
|
||||
import org.apache.ibatis.mapping.Environment;
|
||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class AppointmentForecastMapperTest {
|
||||
@Test void keepsNewestTwoObservationsIncludingWithdrawnEtaAndExcludesOtherNodes() throws Exception {
|
||||
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
try(var c=ds.getConnection();var s=c.createStatement()) {
|
||||
s.execute("CREATE TABLE fl_container_booking(id BIGINT PRIMARY KEY,record_status VARCHAR(32))");
|
||||
s.execute("CREATE TABLE fl_carrier_schedule_node_fact(id BIGINT PRIMARY KEY,container_booking_id BIGINT,standard_node VARCHAR(64),provider_code VARCHAR(64),source_reference VARCHAR(128),observed_at TIMESTAMP,estimated_at TIMESTAMP,actual_at TIMESTAMP)");
|
||||
s.execute("INSERT INTO fl_container_booking VALUES(1,'ACTIVE'),(2,'CANCELLED'),(3,'ACTIVE')");
|
||||
for(int id=1;id<=3;id++) s.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES("+id+",1,'ARRIVED_DESTINATION_PORT','carrier','REF','2026-10-10 12:00:00',"+(id==3?"NULL":"'2026-10-15 09:00:00'")+",NULL)");
|
||||
s.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(4,1,'DEPARTED_ORIGIN_PORT','carrier','OTHER','2026-10-11',NULL,NULL),(5,2,'ARRIVED_DESTINATION_PORT','carrier','REF','2026-10-11','2026-10-15',NULL),(6,3,'ARRIVED_DESTINATION_PORT','carrier','REF','2026-10-11',NULL,'2026-10-11')");
|
||||
}
|
||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);
|
||||
config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));config.addMapper(AppointmentForecastMapper.class);
|
||||
try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
|
||||
var rows=session.getMapper(AppointmentForecastMapper.class).latest(List.of(1L,2L,3L));
|
||||
assertThat(rows).extracting(AppointmentForecastMapper.Fact::factId).containsExactly(3L,2L,6L);
|
||||
assertThat(rows.get(0).estimatedAt()).isNull();assertThat(rows.get(1).estimatedAt()).isNotNull();
|
||||
assertThat(rows.get(2).actualAt()).isNotNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.baiyue.firstleg.portschedule.service;
|
||||
|
||||
import com.baiyue.firstleg.portschedule.mapper.AppointmentForecastMapper;
|
||||
import com.baiyue.firstleg.portschedule.controller.InternalAppointmentForecastController;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class AppointmentForecastServiceTest {
|
||||
@Test void preservesMissingForecastAndSourceLocalTimesWithoutSynthesizingEta() {
|
||||
var mapper=mock(AppointmentForecastMapper.class);var service=new AppointmentForecastService(mapper);
|
||||
var observed=LocalDateTime.of(2026,10,10,12,0);
|
||||
var latest=new AppointmentForecastMapper.Fact(1L,20L,"carrier","REF",observed,null,null);
|
||||
var previous=new AppointmentForecastMapper.Fact(1L,10L,"carrier","REF-OLD",observed.minusDays(1),observed.plusDays(5),null);
|
||||
when(mapper.latest(List.of(1L,2L))).thenReturn(List.of(latest,previous));
|
||||
var result=service.batch(List.of(1L,2L));
|
||||
assertThat(result.get(0).latest()).isSameAs(latest);assertThat(result.get(0).previous()).isSameAs(previous);
|
||||
assertThat(result.get(1).latest()).isNull();assertThat(result.get(1).timeBasis()).isEqualTo("SOURCE_LOCAL_UNSPECIFIED");
|
||||
verify(mapper).latest(List.of(1L,2L));verifyNoMoreInteractions(mapper);
|
||||
}
|
||||
@Test void rejectsInvalidBatchesAndMissingOrBlankInternalCredentials() {
|
||||
var mapper=mock(AppointmentForecastMapper.class);var service=new AppointmentForecastService(mapper);
|
||||
for(var ids:List.of(List.<Long>of(),List.of(1L,1L),List.of(-1L),Collections.nCopies(201,1L)))
|
||||
assertThatThrownBy(()->service.batch(ids)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
|
||||
assertThatThrownBy(()->service.batch(null)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
|
||||
var controller=new InternalAppointmentForecastController(service,"expected");
|
||||
assertThatThrownBy(()->controller.batch(null,new InternalAppointmentForecastController.Request(List.of(1L)))).isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
|
||||
assertThatThrownBy(()->controller.batch("wrong",new InternalAppointmentForecastController.Request(List.of(1L)))).isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
|
||||
assertThatThrownBy(()->new InternalAppointmentForecastController(service,"").batch("",new InternalAppointmentForecastController.Request(List.of(1L)))).isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
|
||||
verifyNoInteractions(mapper);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user