feat(appointments): add four-type calendar sources and align release contracts
This commit is contained in:
+28
@@ -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()));
|
||||
}
|
||||
}
|
||||
+8
@@ -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) { }
|
||||
+44
@@ -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);
|
||||
}
|
||||
+19
@@ -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());
|
||||
}
|
||||
}
|
||||
+46
@@ -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}
|
||||
+44
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user