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
@@ -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());
}
}