diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseMasterDataClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseMasterDataClient.java index 28ae9d820..bd3d578b2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseMasterDataClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseMasterDataClient.java @@ -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> 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 active(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable long tenantId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java index 263235431..16d1d26e2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java @@ -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 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')") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/PickupCalendarEntry.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/PickupCalendarEntry.java new file mode 100644 index 000000000..7a9befaa3 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/PickupCalendarEntry.java @@ -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) { } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java index 1e713a7f1..d835d49dc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java @@ -14,6 +14,19 @@ import java.util.List; @Mapper public interface TerminalDrayageMapper extends BaseMapper { + @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 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 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageCalendarService.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageCalendarService.java new file mode 100644 index 000000000..46873bc55 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageCalendarService.java @@ -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 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 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();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(); + var ids=records.stream().map(PickupCalendarEntry::containerBookingId).distinct().toList(); + for(int start=0;startnew 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 records,boolean truncated,int unknown) { + return new Calendar(List.copyOf(records),truncated,unknown,"US_DESTINATION_WAREHOUSE","SOURCE_LOCAL_UNSPECIFIED"); + } +} diff --git a/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml b/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml index efa8a3f02..a6b2dc43e 100644 --- a/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml +++ b/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml @@ -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: 批量查询货柜码头拖车及服务端报空预警 diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageCalendarAuthorizationTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageCalendarAuthorizationTest.java new file mode 100644 index 000000000..a67d70d23 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageCalendarAuthorizationTest.java @@ -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));} + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageCalendarMapperTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageCalendarMapperTest.java new file mode 100644 index 000000000..b961e9520 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageCalendarMapperTest.java @@ -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()); + } + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageCalendarServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageCalendarServiceTest.java new file mode 100644 index 000000000..261f645f1 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageCalendarServiceTest.java @@ -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()); + } +}