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}