feat: support shared FBA appointments and arrival calendar

This commit is contained in:
2026-10-09 11:01:39 +08:00
parent 4f9433c39a
commit e94271706c
34 changed files with 677 additions and 121 deletions
+2
View File
@@ -9,6 +9,8 @@
## 模块入口
预约日历到港只读:`portschedule/controller/PlatformPortScheduleController.java` 的 `/arrival-calendar`,沿 `PortScheduleService`、`PortScheduleMapper` 查询有效柜次及实际到港节点;合同 `src/main/resources/openapi/arrival-calendar-v1.yaml`,权限 `platform:appointment:list`。不在日历维护第二套船节点。
表内 `controller/`、`service/`、`mapper/`、`loading/` 等业务包路径相对 `src/main/java/com/baiyue/firstleg/`;`src/`、`docs/`、`scripts/` 和 `../` 开头路径相对本服务目录。裸文件名沿同一条目最近业务包定位,必要时在该包用 `rg --files` 查找,不从全仓猜路径。
事件人工重试:`controller/EventOperationsController.java` 提供内部 `/internal/firstleg/event-operations/{entryId}` 查询及 `/retry` 写入,只允许装柜完成与船节点事件;独立 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可修改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务提交,脚本见根 `scripts/event-outbox-operations.mjs`。
@@ -37,6 +37,18 @@ import java.util.List;
public class PlatformPortScheduleController {
@Resource private PortScheduleService service;
@GetMapping("/arrival-calendar")
@PreAuthorize("hasAuthority('platform:appointment:list')")
public ApiResponse<List<com.baiyue.firstleg.portschedule.dto.ArrivalCalendarEntry>> arrivalCalendar(
@AuthenticationPrincipal AuthenticatedUser user,
@org.springframework.web.bind.annotation.RequestParam
@org.springframework.format.annotation.DateTimeFormat(iso = org.springframework.format.annotation.DateTimeFormat.ISO.DATE) java.time.LocalDate from,
@org.springframework.web.bind.annotation.RequestParam
@org.springframework.format.annotation.DateTimeFormat(iso = org.springframework.format.annotation.DateTimeFormat.ISO.DATE) java.time.LocalDate to) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.arrivalCalendar(from, to));
}
@GetMapping
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule:list')")
public ApiResponse<PageResult<ScheduleView>> page(@AuthenticationPrincipal AuthenticatedUser user,
@@ -0,0 +1,7 @@
package com.baiyue.firstleg.portschedule.dto;
import java.time.LocalDateTime;
public record ArrivalCalendarEntry(Long containerBookingId, String bookingNo, String containerNo,
String vesselName, String voyageNo, String destinationPortName, String destinationWarehouseCode,
LocalDateTime arrivalAt, String arrivalType) {}
@@ -11,6 +11,26 @@ import org.apache.ibatis.annotations.Update;
@Mapper
public interface PortScheduleMapper extends BaseMapper<PortScheduleEntity> {
@Select("""
SELECT c.id containerBookingId,c.booking_no bookingNo,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_carrier_schedule_node_fact n ON n.container_booking_id=c.id
AND n.standard_node='ARRIVED_DESTINATION_PORT' AND n.actual_at IS NOT NULL
AND n.id=(SELECT n2.id FROM fl_carrier_schedule_node_fact n2
WHERE n2.container_booking_id=c.id AND n2.standard_node='ARRIVED_DESTINATION_PORT'
AND n2.actual_at IS NOT NULL ORDER BY n2.observed_at DESC,n2.id DESC LIMIT 1)
WHERE c.record_status='ACTIVE'
AND COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at)>=#{from}
AND COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at)<#{to}
ORDER BY arrivalAt,c.id
""")
java.util.List<com.baiyue.firstleg.portschedule.dto.ArrivalCalendarEntry> arrivalCalendar(
@Param("from") java.time.LocalDateTime from, @Param("to") java.time.LocalDateTime to);
@Select("""
<script>
SELECT * FROM fl_port_schedule
@@ -35,6 +35,10 @@ public class PortScheduleRepository {
return PageResult.from(schedules.selectPage(new Page<>(query.page(), query.size()), query));
}
public PortScheduleEntity find(long id) { return schedules.selectById(id); }
public List<com.baiyue.firstleg.portschedule.dto.ArrivalCalendarEntry> arrivalCalendar(
java.time.LocalDateTime from, java.time.LocalDateTime to) {
return schedules.arrivalCalendar(from, to);
}
public PortScheduleEntity latestForDispatch(long customerId, long oceanBookingId, long containerBookingId) {
return schedules.latestForDispatch(customerId, oceanBookingId, containerBookingId);
}
@@ -48,6 +48,15 @@ public class PortScheduleService {
return detail(required(id));
}
public java.util.List<com.baiyue.firstleg.portschedule.dto.ArrivalCalendarEntry> arrivalCalendar(
java.time.LocalDate from, java.time.LocalDate to) {
if (from == null || to == null || !to.isAfter(from)
|| java.time.temporal.ChronoUnit.DAYS.between(from, to) > 42) {
throw badRequest("日历查询范围必须为1至42天");
}
return repository.arrivalCalendar(from.atStartOfDay(), to.atStartOfDay());
}
public java.util.List<PortScheduleHistoryEntity> history(long id) {
required(id);
return repository.histories(id);
@@ -0,0 +1,42 @@
openapi: 3.0.3
info:
title: 预约日历到港信息
version: 1.0.0
security:
- bearerAuth: []
paths:
/api/platform/v1/port-schedules/arrival-calendar:
get:
summary: 查询日期范围内货柜到港信息
description: '仅平台,权限platform:appointment:list。按有效柜次返回一条记录;实际到港优先取最新已观测实际船节点,其次柜次实际到港,再取预计到港。未提供日期的柜次不伪造日期。区间左闭右开,最多42天。'
parameters:
- {name: from, in: query, required: true, schema: {type: string, format: date}}
- {name: to, in: query, required: true, schema: {type: string, format: date}}
responses:
'200':
description: 业务响应,成功code为字符串200。
content:
application/json:
schema:
type: object
properties:
code: {type: string}
message: {type: string}
data:
type: array
items:
type: object
properties:
containerBookingId: {type: string}
bookingNo: {type: string}
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}
arrivalType: {type: string, enum: [ACTUAL, ESTIMATED]}
'403': {description: 无权限或非平台身份}
components:
securitySchemes:
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
@@ -0,0 +1,26 @@
package com.baiyue.firstleg.portschedule.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDate;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class ArrivalCalendarTest {
@Test
void rejectsInvalidRangesAndPassesTheWholeHalfOpenRangeToOneQuery() {
var repository = mock(PortScheduleRepository.class);
var service = new PortScheduleService();
ReflectionTestUtils.setField(service, "repository", repository);
var from = LocalDate.of(2026, 9, 28);
assertThrows(BizException.class, () -> service.arrivalCalendar(from, from));
assertThrows(BizException.class, () -> service.arrivalCalendar(from, from.plusDays(43)));
verifyNoInteractions(repository);
when(repository.arrivalCalendar(from.atStartOfDay(), from.plusDays(42).atStartOfDay())).thenReturn(List.of());
assertTrue(service.arrivalCalendar(from, from.plusDays(42)).isEmpty());
verify(repository).arrivalCalendar(from.atStartOfDay(), from.plusDays(42).atStartOfDay());
}
}