feat(appointments): expose bounded terminal pickup calendar

This commit is contained in:
2026-10-10 11:43:47 +08:00
parent 47dc9e8bf6
commit 46ae8ab1c6
9 changed files with 271 additions and 0 deletions
@@ -12,6 +12,11 @@ import java.util.List;
@FeignClient(name = "warehouse-service", contextId = "firstlegWarehouseMasterDataClient",
url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouses")
public interface WarehouseMasterDataClient {
@GetMapping("/order-catalog")
ApiResponse<List<CatalogWarehouse>> catalog(@RequestHeader("X-Internal-Service-Token") String token);
record CatalogWarehouse(Long id, String warehouseCode, String warehouseName, String warehouseType, String countryCode) { }
@GetMapping("/{tenantId}/{warehouseId}/active")
ApiResponse<Detail> active(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId,
@@ -41,6 +41,17 @@ import java.util.List;
@RequestMapping("/api/platform/v1/terminal-drayages")
public class PlatformTerminalDrayageController {
@Resource private TerminalDrayageMailService mails;
@Resource private com.baiyue.firstleg.terminaldrayage.service.TerminalDrayageCalendarService calendar;
@GetMapping("/calendar")
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:firstleg:terminal-drayage:list')")
public ApiResponse<com.baiyue.firstleg.terminaldrayage.service.TerminalDrayageCalendarService.Calendar> calendar(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @org.springframework.format.annotation.DateTimeFormat(iso=org.springframework.format.annotation.DateTimeFormat.ISO.DATE) java.time.LocalDate from,
@RequestParam @org.springframework.format.annotation.DateTimeFormat(iso=org.springframework.format.annotation.DateTimeFormat.ISO.DATE) java.time.LocalDate to) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(calendar.calendar(from,to));
}
@PostMapping("/{containerBookingId}/pickup-email")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:fleet:edit')")
@@ -0,0 +1,8 @@
package com.baiyue.firstleg.terminaldrayage.dto;
import java.time.LocalDateTime;
/** Original pickup appointment wall time; the source does not establish a port time zone. */
public record PickupCalendarEntry(Long containerBookingId, String bookingNo, String containerNo,
String destinationPortName, String destinationWarehouseCode, String fleetSupplierName,
LocalDateTime pickupAppointmentAt, LocalDateTime actualPickupAt) { }
@@ -14,6 +14,19 @@ import java.util.List;
@Mapper
public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity> {
@Select("""
SELECT c.id containerBookingId,c.booking_no bookingNo,c.container_no containerNo,
c.destination_port_name destinationPortName,c.destination_warehouse_code destinationWarehouseCode,
t.fleet_supplier_name fleetSupplierName,t.pickup_appointment_at pickupAppointmentAt,NULL actualPickupAt
FROM fl_terminal_drayage t JOIN fl_container_booking c ON c.id=t.container_booking_id
WHERE c.record_status='ACTIVE' AND t.pickup_appointment_at>=#{from} AND t.pickup_appointment_at<#{to}
AND EXISTS(SELECT 1 FROM fl_outbound_dispatch d WHERE d.container_booking_id=c.id
AND d.confirmed_at IS NOT NULL AND d.reversed_at IS NULL AND d.status='WAITING_FOR_LOADING')
ORDER BY t.pickup_appointment_at,c.id LIMIT #{limit}
""")
List<com.baiyue.firstleg.terminaldrayage.dto.PickupCalendarEntry> calendar(
@Param("from") LocalDateTime from,@Param("to") LocalDateTime to,@Param("limit") int limit);
@Select("""
SELECT b.id AS containerBookingId,b.container_no AS containerNo,b.ocean_booking_id AS oceanBookingId,
b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt
@@ -0,0 +1,57 @@
package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.firstleg.client.WarehouseMasterDataClient;
import com.baiyue.firstleg.terminaldrayage.dto.PickupCalendarEntry;
import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.*;
@Service
public class TerminalDrayageCalendarService {
private final TerminalDrayageMapper mapper;
private final WarehouseMasterDataClient warehouses;
private final String token;
private final CarrierScheduleQueryService shipping;
public TerminalDrayageCalendarService(TerminalDrayageMapper mapper,WarehouseMasterDataClient warehouses,
CarrierScheduleQueryService shipping,
@Value("${baiyue.security.internal-service-token}") String token) {this.mapper=mapper;this.warehouses=warehouses;this.shipping=shipping;this.token=token;}
public record Calendar(List<PickupCalendarEntry> records,boolean truncated,int unclassifiedCount,
String scopeBasis,String timeBasis) { }
public Calendar calendar(LocalDate from,LocalDate to) {
if(from==null || to==null || !to.isAfter(from) || ChronoUnit.DAYS.between(from,to)>42)
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"日历查询范围必须为1至42天");
var rows=mapper.calendar(from.atStartOfDay(),to.atStartOfDay(),1001);
if(rows.isEmpty()) return result(List.of(),false,0);
final Map<String,String> countries=new HashMap<>();
try {
var response=warehouses.catalog(token);
if(response==null || !response.successful() || response.data()==null) throw new IllegalStateException();
for(var warehouse:response.data()) if(warehouse!=null && warehouse.warehouseCode()!=null && warehouse.countryCode()!=null)
countries.put(warehouse.warehouseCode(),warehouse.countryCode());
} catch(RuntimeException error) {throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"提柜目的仓范围暂不可核实",error);}
var records=new ArrayList<PickupCalendarEntry>();int unknown=0;
for(var row:rows.stream().limit(1000).toList()) {
var country=countries.get(row.destinationWarehouseCode());
if(country==null || country.isBlank()) unknown++;
else if("US".equals(country)) records.add(row);
}
var actual=new HashMap<Long,java.time.LocalDateTime>();
var ids=records.stream().map(PickupCalendarEntry::containerBookingId).distinct().toList();
for(int start=0;start<ids.size();start+=200) {
for(var fact:shipping.containerActualFacts(ids.subList(start,Math.min(start+200,ids.size()))))
if(fact.pickedUpAt()!=null) actual.put(fact.containerBookingId(),fact.pickedUpAt());
}
var enriched=records.stream().map(row->new PickupCalendarEntry(row.containerBookingId(),row.bookingNo(),row.containerNo(),
row.destinationPortName(),row.destinationWarehouseCode(),row.fleetSupplierName(),row.pickupAppointmentAt(),actual.get(row.containerBookingId()))).toList();
return result(enriched,rows.size()>1000,unknown);
}
private Calendar result(List<PickupCalendarEntry> records,boolean truncated,int unknown) {
return new Calendar(List.copyOf(records),truncated,unknown,"US_DESTINATION_WAREHOUSE","SOURCE_LOCAL_UNSPECIFIED");
}
}
@@ -1,6 +1,48 @@
openapi: 3.0.3
info: {title: Firstleg Terminal Drayage API, version: 1.0.0}
paths:
/api/platform/v1/terminal-drayages/calendar:
get:
summary: 美国目的仓相关的提柜预约日历
description: 平台身份,同时要求预约列表和码头拖车列表权限。按原业务预约提柜日期查询,不将实际提柜或预计到仓替代预约时间。美国范围按启用目的仓国家确认,不代表已确认港口所在国。原字段未提供港口时区,timeBasis明确待核实,禁止当作UTC或套用收货仓时区。最多42天、读取1001条以检测截断;返回最多1000条中的美国范围记录,未知目的仓计入unclassifiedCount,不能将缺失数据当零。
x-permissions: [platform:appointment:list, 'platform:firstleg:terminal-drayage:list']
parameters:
- {name: from, in: query, required: true, schema: {type: string, format: date}, description: 包含的原业务当地日期}
- {name: to, in: query, required: true, schema: {type: string, format: date}, description: 不包含的原业务当地日期}
responses:
'200':
description: 业务响应,code为200;仓库目录不可用则返回错误,不假装空列表
content:
application/json:
schema:
type: object
properties:
code: {type: string}
data:
type: object
required: [records, truncated, unclassifiedCount, scopeBasis, timeBasis]
properties:
truncated: {type: boolean}
unclassifiedCount: {type: integer, minimum: 0}
scopeBasis: {type: string, enum: [US_DESTINATION_WAREHOUSE]}
timeBasis: {type: string, enum: [SOURCE_LOCAL_UNSPECIFIED]}
records:
type: array
maxItems: 1000
items:
type: object
properties:
containerBookingId: {type: string, description: 仅用于来源详情定位}
bookingNo: {type: string}
containerNo: {type: string, nullable: true}
destinationPortName: {type: string, nullable: true}
destinationWarehouseCode: {type: string, nullable: true}
fleetSupplierName: {type: string, nullable: true}
pickupAppointmentAt: {type: string, description: 原业务当地时间,无时区偏移,不是UTC时间点}
actualPickupAt: {type: string, nullable: true}
'400': {description: 日期范围非法}
'403': {description: 非平台身份或权限不足}
'503': {description: 无法核对仓库范围}
/api/platform/v1/terminal-drayages:
get:
summary: 批量查询货柜码头拖车及服务端报空预警
@@ -0,0 +1,49 @@
package com.baiyue.firstleg.terminaldrayage.controller;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.terminaldrayage.service.*;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.*;
import org.springframework.core.env.MapPropertySource;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import java.time.LocalDate;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class TerminalDrayageCalendarAuthorizationTest {
static final String APPOINTMENT="platform:appointment:list",PICKUP="platform:firstleg:terminal-drayage:list";
@Test void bothReadPermissionsAndPlatformIdentityAreRequiredBeforeReadingAnyCalendar() {
try(var context=new AnnotationConfigApplicationContext()) {
context.getEnvironment().getPropertySources().addFirst(new MapPropertySource("test",Map.of("baiyue.security.internal-service-token","test")));
context.register(Config.class);context.refresh();
var controller=context.getBean(PlatformTerminalDrayageController.class);
var service=context.getBean(TerminalDrayageCalendarService.class);
var from=LocalDate.of(2026,10,1);var to=from.plusDays(31);
for(var permission:List.of(APPOINTMENT,PICKUP)) {
login(permission);assertThrows(AccessDeniedException.class,()->controller.calendar(user(0L,"PLATFORM"),from,to));
}
login(APPOINTMENT,PICKUP);
assertThrows(BizException.class,()->controller.calendar(user(1L,"MERCHANT"),from,to));
assertThrows(BizException.class,()->controller.calendar(null,from,to));
verifyNoInteractions(service);
controller.calendar(user(0L,"PLATFORM"),from,to);verify(service).calendar(from,to);
} finally {SecurityContextHolder.clearContext();}
}
static void login(String... permissions) {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("operator","unused",
Arrays.stream(permissions).map(SimpleGrantedAuthority::new).toList()));
}
static AuthenticatedUser user(long customer,String app) {return new AuthenticatedUser(7L,customer,0L,8L,"operator",app,"WEB",Set.of());}
@Configuration @EnableMethodSecurity
static class Config {
@Bean TerminalDrayageMailService mails(){return mock(TerminalDrayageMailService.class);}
@Bean TerminalDrayageCalendarService calendar(){return mock(TerminalDrayageCalendarService.class);}
@Bean PlatformTerminalDrayageController controller(){return new PlatformTerminalDrayageController(mock(TerminalDrayageService.class));}
}
}
@@ -0,0 +1,33 @@
package com.baiyue.firstleg.terminaldrayage.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.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class TerminalDrayageCalendarMapperTest {
@Test void respectsExclusiveRangeActiveDepartureWithoutDuplicatingContainersOrOwningShippingFacts() throws Exception {
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:pickup-calendar;MODE=MySQL;DB_CLOSE_DELAY=-1");
try(var c=ds.getConnection();var s=c.createStatement()) {
s.execute("CREATE TABLE fl_container_booking(id BIGINT,booking_no VARCHAR,container_no VARCHAR,destination_port_name VARCHAR,destination_warehouse_code VARCHAR,record_status VARCHAR)");
s.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,fleet_supplier_name VARCHAR,pickup_appointment_at TIMESTAMP)");
s.execute("CREATE TABLE fl_outbound_dispatch(container_booking_id BIGINT,confirmed_at TIMESTAMP,reversed_at TIMESTAMP,status VARCHAR)");
for(int id=1;id<=5;id++) {
s.execute("INSERT INTO fl_container_booking VALUES("+id+",'CB','CONT','Port','US-W','"+(id==4?"CANCELLED":"ACTIVE")+"')");
s.execute("INSERT INTO fl_terminal_drayage VALUES("+id+",'Fleet','"+(id==3?"2026-11-01 00:00:00":"2026-10-01 09:00:00")+"')");
s.execute("INSERT INTO fl_outbound_dispatch VALUES("+id+",'2026-09-01',"+(id==5?"'2026-09-02'":"NULL")+",'WAITING_FOR_LOADING')");
}
s.execute("INSERT INTO fl_outbound_dispatch VALUES(1,'2026-09-01',NULL,'WAITING_FOR_LOADING')");
}
var config=new MybatisConfiguration();config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));config.addMapper(TerminalDrayageMapper.class);
try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
var rows=session.getMapper(TerminalDrayageMapper.class).calendar(LocalDateTime.parse("2026-10-01T00:00:00"),LocalDateTime.parse("2026-11-01T00:00:00"),1001);
assertEquals(List.of(1L,2L),rows.stream().map(r->r.containerBookingId()).toList());assertNull(rows.get(0).actualPickupAt());
}
}
}
@@ -0,0 +1,53 @@
package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.client.WarehouseMasterDataClient;
import com.baiyue.firstleg.terminaldrayage.dto.PickupCalendarEntry;
import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class TerminalDrayageCalendarServiceTest {
final TerminalDrayageMapper mapper=mock(TerminalDrayageMapper.class);
final WarehouseMasterDataClient warehouses=mock(WarehouseMasterDataClient.class);
final com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService shipping=mock(com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService.class);
final TerminalDrayageCalendarService service=new TerminalDrayageCalendarService(mapper,warehouses,shipping,"test");
final LocalDate from=LocalDate.of(2026,10,1),to=from.plusDays(31);
PickupCalendarEntry row(long id,String code) {return new PickupCalendarEntry(id,"CB-"+id,"TEST-CONT","Port",code,"Fleet",from.atTime(9,0),null);}
@Test void batchesTheCatalogAndKeepsUnknownScopeVisibleWithoutGuessingPortTimeZone() {
when(mapper.calendar(any(),any(),eq(1001))).thenReturn(List.of(row(1,"US-W"),row(2,"CA-W"),row(3,"UNKNOWN")));
when(warehouses.catalog("test")).thenReturn(ApiResponse.success(List.of(
new WarehouseMasterDataClient.CatalogWarehouse(1L,"US-W","US","OVERSEAS","US"),
new WarehouseMasterDataClient.CatalogWarehouse(2L,"CA-W","CA","OVERSEAS","CA"))));
var pickedUp=from.atTime(10,30);
when(shipping.containerActualFacts(List.of(1L))).thenReturn(List.of(new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts(
1L,null,null,null,null,null,null,null,null,null,pickedUp,null)));
var result=service.calendar(from,to);
assertEquals(List.of(1L),result.records().stream().map(PickupCalendarEntry::containerBookingId).toList());
assertEquals(1,result.unclassifiedCount());assertFalse(result.truncated());assertEquals("SOURCE_LOCAL_UNSPECIFIED",result.timeBasis());
assertEquals(pickedUp,result.records().get(0).actualPickupAt());
assertEquals(from.atTime(9,0),result.records().get(0).pickupAppointmentAt());
verify(shipping,times(1)).containerActualFacts(List.of(1L));
verify(warehouses,times(1)).catalog("test");verify(mapper,times(1)).calendar(from.atStartOfDay(),to.atStartOfDay(),1001);
}
@Test void catalogFailureDoesNotMasqueradeAsAnEmptyCalendar() {
when(mapper.calendar(any(),any(),anyInt())).thenReturn(List.of(row(1,"US-W")));
when(warehouses.catalog("test")).thenThrow(new IllegalStateException());
assertEquals(503,assertThrows(ResponseStatusException.class,()->service.calendar(from,to)).getStatusCode().value());
}
@Test void boundedRangeAndEmptyCalendarAvoidUnnecessaryRequests() {
assertThrows(ResponseStatusException.class,()->service.calendar(from,from.plusDays(43)));
assertThrows(ResponseStatusException.class,()->service.calendar(from,from));verifyNoInteractions(mapper,warehouses);
when(mapper.calendar(any(),any(),anyInt())).thenReturn(List.of());assertTrue(service.calendar(from,to).records().isEmpty());verifyNoInteractions(warehouses);
}
@Test void reportsTruncationInsteadOfClaimingCompleteResults() {
when(mapper.calendar(any(),any(),anyInt())).thenReturn(Collections.nCopies(1001,row(1,"US-W")));
when(warehouses.catalog("test")).thenReturn(ApiResponse.success(List.of(new WarehouseMasterDataClient.CatalogWarehouse(1L,"US-W","US","OVERSEAS","US"))));
var result=service.calendar(from,to);assertTrue(result.truncated());assertEquals(1000,result.records().size());
}
}