feat(appointments): expose bounded terminal pickup calendar
This commit is contained in:
@@ -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,
|
||||
|
||||
+11
@@ -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')")
|
||||
|
||||
+8
@@ -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) { }
|
||||
+13
@@ -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
|
||||
|
||||
+57
@@ -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: 批量查询货柜码头拖车及服务端报空预警
|
||||
|
||||
+49
@@ -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));}
|
||||
}
|
||||
}
|
||||
+33
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user