feat(appointments): add four-type calendar sources and align release contracts

This commit is contained in:
2026-10-19 12:15:43 +08:00
parent fe1b2a6a46
commit aa195cef66
21 changed files with 599 additions and 65 deletions
@@ -0,0 +1,28 @@
package com.baiyue.firstleg.terminaldrayage.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.terminaldrayage.service.ContainerCalendarSourceService;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
@RestController
@RequestMapping("/api/platform/v1/terminal-drayages")
public class ContainerCalendarSourceController {
private final ContainerCalendarSourceService service;
/** 装配只读日历业务服务。 */
public ContainerCalendarSourceController(ContainerCalendarSourceService service) { this.service = service; }
/** 预约列表可读基础柜信息,还柜事实继续核验原业务读取权限。 */
@GetMapping("/calendar-sources")
@PreAuthorize("hasAuthority('platform:appointment:list')")
public ApiResponse<ContainerCalendarSourceService.Result> calendar(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.calendar(from, to, user.permissions()));
}
}
@@ -0,0 +1,8 @@
package com.baiyue.firstleg.terminaldrayage.dto;
import java.time.LocalDateTime;
/** 四类日历使用的柜级来源,日期均保留原业务当地值。 */
public record ContainerCalendarSource(Long containerBookingId, String cabinetNo, String containerNo,
String vesselName, String voyageNo, String destinationPortName, String destinationWarehouseCode,
LocalDateTime arrivalAt, String arrivalType) { }
@@ -0,0 +1,44 @@
package com.baiyue.firstleg.terminaldrayage.mapper;
import com.baiyue.firstleg.terminaldrayage.dto.ContainerCalendarSource;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface ContainerCalendarSourceMapper {
/** 同时选出到港、缺日期和可能还柜的有效出仓柜,最终日期由业务服务复核。 */
@Select("""
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
c.vessel_name vesselName,c.voyage_no voyageNo,c.destination_port_name destinationPortName,
c.destination_warehouse_code destinationWarehouseCode,
COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) arrivalAt,
CASE WHEN COALESCE(n.actual_at,c.actual_port_arrival_at) IS NOT NULL THEN 'ACTUAL' ELSE 'ESTIMATED' END arrivalType
FROM fl_container_booking c
LEFT JOIN fl_terminal_drayage t ON t.container_booking_id=c.id
LEFT JOIN fl_carrier_schedule_node_fact n ON n.id=(
SELECT f.id FROM fl_carrier_schedule_node_fact f WHERE f.container_booking_id=c.id
AND f.standard_node='ARRIVED_DESTINATION_PORT' AND f.actual_at IS NOT NULL
ORDER BY f.observed_at DESC,f.id DESC LIMIT 1)
WHERE c.record_status='ACTIVE'
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')
AND (
COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) IS NULL
OR (COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at)>=#{arrivalFrom}
AND COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at)<#{to})
OR (t.actual_empty_return_at>=#{from} AND t.actual_empty_return_at<#{to})
OR (c.unloading_completed_at>=#{returnFrom} AND c.unloading_completed_at<#{to})
OR EXISTS(SELECT 1 FROM fl_carrier_schedule_node_fact r WHERE r.container_booking_id=c.id
AND r.standard_node='EMPTY_RETURNED' AND r.actual_at>=#{from} AND r.actual_at<#{to})
OR EXISTS(SELECT 1 FROM fl_direct_delivery_unloading u WHERE u.container_booking_id=c.id
AND u.actual_at>=#{returnFrom} AND u.actual_at<#{to})
)
ORDER BY c.id LIMIT 2001
""")
List<ContainerCalendarSource> sources(@Param("from") LocalDateTime from,
@Param("arrivalFrom") LocalDateTime arrivalFrom, @Param("returnFrom") LocalDateTime returnFrom,
@Param("to") LocalDateTime to);
}
@@ -0,0 +1,19 @@
package com.baiyue.firstleg.terminaldrayage.repository;
import com.baiyue.firstleg.terminaldrayage.dto.ContainerCalendarSource;
import com.baiyue.firstleg.terminaldrayage.mapper.ContainerCalendarSourceMapper;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
import java.util.List;
@Repository
public class ContainerCalendarSourceRepository {
private final ContainerCalendarSourceMapper mapper;
/** 使用本服务 Mapper 读取日历所需柜级来源。 */
public ContainerCalendarSourceRepository(ContainerCalendarSourceMapper mapper) { this.mapper = mapper; }
/** 到港前移两天覆盖默认到仓,直送还柜候选额外留一天覆盖夏令时。 */
public List<ContainerCalendarSource> sources(LocalDate from, LocalDate to) {
return mapper.sources(from.atStartOfDay(), from.minusDays(2).atStartOfDay(),
from.minusDays(4).atStartOfDay(), to.atStartOfDay());
}
}
@@ -0,0 +1,46 @@
package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.firstleg.terminaldrayage.dto.ContainerCalendarSource;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View;
import com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.*;
import org.springframework.web.server.ResponseStatusException;
@Service
public class ContainerCalendarSourceService {
public record Result(List<ContainerCalendarSource> containers, List<View> emptyReturns,
boolean emptyReturnsReadable) { }
private final ContainerCalendarSourceRepository repository;
private final TerminalDrayageService drayages;
/** 复用真实还柜业务的截止时间和完成事实,不在日历另算还柜状态。 */
public ContainerCalendarSourceService(ContainerCalendarSourceRepository repository, TerminalDrayageService drayages) {
this.repository = repository; this.drayages = drayages;
}
/** 按日期范围读取到港、默认到仓来源及可见的还柜记录。 */
public Result calendar(LocalDate from, LocalDate to, Set<String> permissions) {
if (from == null || to == null || !to.isAfter(from) || ChronoUnit.DAYS.between(from, to) > 42)
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "日历查询范围必须为1至42天");
var sources = repository.sources(from, to);
if (sources.size() > 2000)
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "日历来源超过2000柜,请缩小范围或补齐未定日期");
boolean readable = permissions != null && permissions.contains("platform:firstleg:terminal-drayage:list");
var returns = new ArrayList<View>();
if (readable) {
var ids = sources.stream().map(ContainerCalendarSource::containerBookingId).toList();
for (int start = 0; start < ids.size(); start += 200) {
for (var row : drayages.authorize(drayages.views(ids.subList(start, Math.min(start + 200, ids.size()))), permissions)) {
var at = row.actualEmptyReturnAt() != null ? row.actualEmptyReturnAt() : row.emptyReturnWarning().deadlineAt();
if (at != null && !at.toLocalDate().isBefore(from) && at.toLocalDate().isBefore(to)) returns.add(row);
}
}
}
// 未授权还柜读取时,也不能通过仅因还柜日期命中的旧柜候选推断受保护事实。
var visibleSources = readable ? sources : sources.stream().filter(row -> row.arrivalAt() == null
|| !row.arrivalAt().toLocalDate().isBefore(from.minusDays(2)) && row.arrivalAt().toLocalDate().isBefore(to)).toList();
return new Result(List.copyOf(visibleSources), List.copyOf(returns), readable);
}
}
@@ -0,0 +1,55 @@
openapi: 3.0.3
info: {title: 四类日历柜级来源, version: 1.0.0}
security: [{bearerAuth: []}]
paths:
/api/platform/v1/terminal-drayages/calendar-sources:
get:
summary: 查询日历到港、默认到仓来源和还柜事实
description: >-
仅平台,platform:appointment:list。返回已确认国内出仓、未撤销且有效的柜次。
到港取最新已观测实际到港节点、柜次实际到港、预计到港,按此顺序选一个日期。
到港候选前移两天覆盖计划到仓默认日期;缺到港日期的柜次同时返回,不能伪造日期。
还柜候选含原截止日期与真实完成日期,最终由原码头拖车业务复核,完成后只按实际还柜日期归日历。
没有platform:firstleg:terminal-drayage:list时emptyReturns为空且emptyReturnsReadable=false;不把缺权限当没有安排。
最多2000个候选,超出返回400;来源读取失败返回错误,不以空列表代替。
x-permissions: [platform:appointment: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: 不包含的原业务当地日期,范围最多42天}
responses:
'200':
description: 成功业务响应,code为字符串200
content:
application/json:
schema:
type: object
properties:
code: {type: string}
data:
type: object
required: [containers, emptyReturns, emptyReturnsReadable]
properties:
emptyReturnsReadable: {type: boolean}
containers:
type: array
items:
type: object
properties:
containerBookingId: {type: string}
cabinetNo: {type: string, nullable: true}
containerNo: {type: string, nullable: true}
vesselName: {type: string, nullable: true}
voyageNo: {type: string, nullable: true}
destinationPortName: {type: string, nullable: true}
destinationWarehouseCode: {type: string, nullable: true}
arrivalAt: {type: string, format: date-time, nullable: true}
arrivalType: {type: string, enum: [ACTUAL, ESTIMATED]}
emptyReturns:
type: array
description: 沿terminal-drayage-v1的View结构,allowedActions按当前权限过滤;含actualEmptyReturnAt及emptyReturnWarning.deadlineAt
items: {$ref: './terminal-drayage-v1.yaml#/components/schemas/TerminalDrayageView'}
'400': {description: 范围无效或候选过多}
'403': {description: 非平台或没有预约读取权限}
components:
securitySchemes:
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
@@ -0,0 +1,44 @@
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 static org.junit.jupiter.api.Assertions.*;
class ContainerCalendarSourceMapperTest {
/** 使用实际 Mapper 核对跨月默认日期候选、缺日期与真实出仓边界。 */
@Test
void selectsShiftedArrivalUndatedAndOlderReturnCandidatesWithoutInactiveOrUndepartedContainers() throws Exception {
var ds = new JdbcDataSource();
ds.setURL("jdbc:h2:mem:four-type-calendar;MODE=MySQL;DB_CLOSE_DELAY=-1");
try (var connection = ds.getConnection(); var sql = connection.createStatement()) {
sql.execute("CREATE TABLE fl_container_booking(id BIGINT,cabinet_no VARCHAR,container_no VARCHAR," +
"vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_warehouse_code VARCHAR," +
"actual_port_arrival_at TIMESTAMP,estimated_port_arrival_at TIMESTAMP,unloading_completed_at TIMESTAMP,record_status VARCHAR)");
sql.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,actual_empty_return_at TIMESTAMP)");
sql.execute("CREATE TABLE fl_carrier_schedule_node_fact(id BIGINT,container_booking_id BIGINT,standard_node VARCHAR,actual_at TIMESTAMP,observed_at TIMESTAMP)");
sql.execute("CREATE TABLE fl_direct_delivery_unloading(container_booking_id BIGINT,actual_at TIMESTAMP)");
sql.execute("CREATE TABLE fl_outbound_dispatch(container_booking_id BIGINT,confirmed_at TIMESTAMP,reversed_at TIMESTAMP,status VARCHAR)");
sql.execute("INSERT INTO fl_container_booking(id,cabinet_no,estimated_port_arrival_at,record_status) VALUES " +
"(1,'D1','2026-10-30 12:00:00','ACTIVE'),(2,'D2','2026-11-10 12:00:00','ACTIVE')," +
"(3,'D3',NULL,'ACTIVE'),(4,'D4','2026-01-01 12:00:00','ACTIVE')," +
"(5,'D5','2026-11-10 12:00:00','CANCELLED'),(6,'D6','2026-11-10 12:00:00','ACTIVE')");
sql.execute("INSERT INTO fl_outbound_dispatch SELECT id,'2026-01-01 00:00:00',NULL,'WAITING_FOR_LOADING' FROM fl_container_booking WHERE id<>6");
sql.execute("INSERT INTO fl_terminal_drayage VALUES(4,'2026-11-05 12:00:00')");
sql.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(1,2,'ARRIVED_DESTINATION_PORT','2026-10-25 12:00:00','2026-10-26 12:00:00')");
}
var config = new MybatisConfiguration();
config.setEnvironment(new Environment("test", new JdbcTransactionFactory(), ds)); config.addMapper(ContainerCalendarSourceMapper.class);
try (var session = new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
var from = LocalDateTime.parse("2026-11-01T00:00:00");
var rows = session.getMapper(ContainerCalendarSourceMapper.class).sources(from, from.minusDays(2), from.minusDays(4), from.plusDays(30));
assertEquals(java.util.List.of(1L, 3L, 4L), rows.stream().map(row -> row.containerBookingId()).toList());
assertEquals("2026-10-30T12:00", rows.get(0).arrivalAt().toString());
assertNull(rows.get(1).arrivalAt());
}
}
}
@@ -0,0 +1,45 @@
package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.firstleg.terminaldrayage.dto.ContainerCalendarSource;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.*;
import com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import java.time.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class ContainerCalendarSourceServiceTest {
private final ContainerCalendarSourceRepository repository = mock(ContainerCalendarSourceRepository.class);
private final TerminalDrayageService drayages = mock(TerminalDrayageService.class);
private final ContainerCalendarSourceService service = new ContainerCalendarSourceService(repository, drayages);
private final LocalDate from = LocalDate.of(2026, 11, 1), to = from.plusDays(30);
/** 缺还柜读取权限时保留范围说明,不读取受保护事实。 */
@Test void keepsMissingReturnPermissionExplicitWithoutReadingProtectedFacts() {
when(repository.sources(from, to)).thenReturn(List.of(
new ContainerCalendarSource(1L,"D1",null,null,null,null,null,null,"ESTIMATED"),
new ContainerCalendarSource(2L,"D2",null,null,null,null,null,from.minusMonths(2).atStartOfDay(),"ACTUAL")));
var result = service.calendar(from, to, Set.of("platform:appointment:list"));
assertEquals(1, result.containers().size()); assertFalse(result.emptyReturnsReadable());
verifyNoInteractions(drayages);
}
/** 实际还柜日期优先于截止日期,范围外完成事实不回落到原提醒日。 */
@Test void usesOriginalDeadlineButActualReturnSupersedesTheDeadlineDate() {
when(repository.sources(from, to)).thenReturn(List.of(new ContainerCalendarSource(1L,"D1",null,null,null,null,null,null,"ESTIMATED")));
var row = mock(View.class);
when(row.actualEmptyReturnAt()).thenReturn(to.atStartOfDay());
when(row.emptyReturnWarning()).thenReturn(new Warning("RESOLVED", from.atStartOfDay(),0,0));
when(drayages.views(List.of(1L))).thenReturn(List.of(row));
when(drayages.authorize(anyList(), anySet())).thenAnswer(call -> call.getArgument(0));
assertTrue(service.calendar(from,to,Set.of("platform:firstleg:terminal-drayage:list")).emptyReturns().isEmpty());
when(row.actualEmptyReturnAt()).thenReturn(null);
assertEquals(List.of(row), service.calendar(from,to,Set.of("platform:firstleg:terminal-drayage:list")).emptyReturns());
}
/** 非法日期范围在访问持久化层前拒绝。 */
@Test void rejectsInvalidRangesBeforeDatabaseAccess() {
assertThrows(ResponseStatusException.class, () -> service.calendar(from,from,Set.of()));
assertThrows(ResponseStatusException.class, () -> service.calendar(from,from.plusDays(43),Set.of()));
verifyNoInteractions(repository);
}
}