feat: complete tail order controls and terminal drayage

This commit is contained in:
2026-09-07 23:58:18 +08:00
parent f018ca85a9
commit e32fe65fa8
53 changed files with 2566 additions and 87 deletions
@@ -0,0 +1,117 @@
package com.baiyue.firstleg.terminaldrayage.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.ActualTimeRequest;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.FleetSelectionRequest;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.History;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.PickupAppointmentRequest;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View;
import com.baiyue.firstleg.terminaldrayage.service.TerminalDrayageService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Validated
@RestController
@RequiredArgsConstructor
@Tag(name = "目的港码头拖车")
@RequestMapping("/api/platform/v1/terminal-drayages")
public class PlatformTerminalDrayageController {
private final TerminalDrayageService service;
@GetMapping
@PreAuthorize("hasAuthority('platform:firstleg:container:list')")
public ApiResponse<List<View>> views(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Size(min = 1, max = 200)
List<@Positive Long> containerBookingIds) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.views(containerBookingIds));
}
@GetMapping("/{containerBookingId}")
@PreAuthorize("hasAuthority('platform:firstleg:container:list')")
public ApiResponse<View> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long containerBookingId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.detail(containerBookingId));
}
@GetMapping("/{containerBookingId}/history")
@PreAuthorize("hasAuthority('platform:firstleg:container:list')")
public ApiResponse<List<History>> history(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long containerBookingId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.histories(containerBookingId));
}
@PostMapping("/{containerBookingId}/fleet")
@PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
@Operation(summary = "选择或更换目的港码头车队")
@BusinessAudit(event = "TERMINAL_DRAYAGE_SELECT_FLEET", resourceType = "CONTAINER",
resourceId = "#containerBookingId", description = "选择目的港码头车队")
public ApiResponse<View> selectFleet(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long containerBookingId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody FleetSelectionRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.selectFleet(containerBookingId, user.userId(), user.username(), key, request));
}
@PostMapping("/{containerBookingId}/pickup-appointment")
@PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
@Operation(summary = "预约目的港提柜时间")
@BusinessAudit(event = "TERMINAL_DRAYAGE_SCHEDULE_PICKUP", resourceType = "CONTAINER",
resourceId = "#containerBookingId", description = "预约目的港提柜")
public ApiResponse<View> schedulePickup(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long containerBookingId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody PickupAppointmentRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.schedulePickup(containerBookingId, user.userId(), user.username(), key, request));
}
@PostMapping("/{containerBookingId}/pickup")
@PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
@Operation(summary = "确认目的港实际提柜")
@BusinessAudit(event = "TERMINAL_DRAYAGE_RECORD_PICKUP", resourceType = "CONTAINER",
resourceId = "#containerBookingId", description = "确认目的港实际提柜")
public ApiResponse<View> recordPickup(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long containerBookingId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ActualTimeRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.recordPickup(containerBookingId, user.userId(), user.username(), key, request));
}
@PostMapping("/{containerBookingId}/empty-return")
@PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
@Operation(summary = "确认实际还空柜")
@BusinessAudit(event = "TERMINAL_DRAYAGE_RECORD_EMPTY_RETURN", resourceType = "CONTAINER",
resourceId = "#containerBookingId", description = "确认实际还空柜")
public ApiResponse<View> recordEmptyReturn(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long containerBookingId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ActualTimeRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.recordEmptyReturn(containerBookingId, user.userId(), user.username(), key, request));
}
}
@@ -0,0 +1,41 @@
package com.baiyue.firstleg.terminaldrayage.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
public final class TerminalDrayageDtos {
private TerminalDrayageDtos() {}
public record FleetSelectionRequest(@NotNull @Min(0) Integer expectedVersion,
@NotNull @Positive Long fleetSupplierId,
@Size(max = 128) String fleetSupplierName,
@Size(max = 500) String reason) {}
public record PickupAppointmentRequest(@NotNull @Min(0) Integer expectedVersion,
@NotNull LocalDateTime pickupAppointmentAt,
@Size(max = 500) String reason) {}
public record ActualTimeRequest(@NotNull @Min(0) Integer expectedVersion,
@NotNull LocalDateTime actualAt,
@Size(max = 500) String reason) {}
public record Warning(String status, LocalDateTime deadlineAt,
long overdueHours, long overdueDays) {}
public record View(Long containerBookingId, String containerNo,
Long oceanBookingId, String status, Integer version,
Long fleetSupplierId, String fleetSupplierName,
LocalDateTime pickupAppointmentAt, LocalDateTime actualPickupAt,
LocalDateTime estimatedEmptyReturnAt, LocalDateTime actualEmptyReturnAt,
LocalDateTime unloadingCompletedAt, Warning emptyReturnWarning,
List<String> allowedActions, LocalDateTime updatedAt) {}
public record History(String action, String fromStatus, String toStatus,
Integer fromVersion, Integer toVersion, String reason,
Long operatorId, String operatorName, LocalDateTime createdAt) {}
}
@@ -0,0 +1,30 @@
package com.baiyue.firstleg.terminaldrayage.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_terminal_drayage")
public class TerminalDrayageEntity {
@TableId(type = IdType.INPUT) private Long id;
private Long containerBookingId;
private Long fleetSupplierId;
private String fleetSupplierName;
private LocalDateTime pickupAppointmentAt;
private LocalDateTime actualPickupAt;
private LocalDateTime estimatedEmptyReturnAt;
private LocalDateTime actualEmptyReturnAt;
private Integer version;
private Long createdBy;
private String createdByName;
private LocalDateTime createdAt;
private Long updatedBy;
private String updatedByName;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,148 @@
package com.baiyue.firstleg.terminaldrayage.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.History;
import com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity> {
@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,
q.id AS queryId,q.latest_standard_node AS latestActualNode,
q.latest_node_observed_at AS latestActualAt,q.version AS queryVersion,
(SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id
AND f.standard_node='ARRIVED_DESTINATION_PORT') AS actualArrivalNodeAt,
(SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id
AND f.standard_node='PICKED_UP') AS actualPickupNodeAt,
(SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id
AND f.standard_node='EMPTY_RETURNED') AS actualEmptyReturnNodeAt
FROM fl_container_booking b
LEFT JOIN fl_carrier_schedule_query q ON q.id=(
SELECT q2.id FROM fl_carrier_schedule_query q2
WHERE q2.container_booking_id=b.id ORDER BY q2.id DESC LIMIT 1)
WHERE b.id=#{containerBookingId} FOR UPDATE
""")
Context lockContext(@Param("containerBookingId") long containerBookingId);
@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,
q.id AS queryId,q.latest_standard_node AS latestActualNode,
q.latest_node_observed_at AS latestActualAt,q.version AS queryVersion,
(SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id
AND f.standard_node='ARRIVED_DESTINATION_PORT') AS actualArrivalNodeAt,
(SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id
AND f.standard_node='PICKED_UP') AS actualPickupNodeAt,
(SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id
AND f.standard_node='EMPTY_RETURNED') AS actualEmptyReturnNodeAt
FROM fl_container_booking b
LEFT JOIN fl_carrier_schedule_query q ON q.id=(
SELECT q2.id FROM fl_carrier_schedule_query q2
WHERE q2.container_booking_id=b.id ORDER BY q2.id DESC LIMIT 1)
WHERE b.id=#{containerBookingId}
""")
Context context(@Param("containerBookingId") long containerBookingId);
@Select("""
<script>
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,
q.id AS queryId,q.latest_standard_node AS latestActualNode,
q.latest_node_observed_at AS latestActualAt,q.version AS queryVersion,
(SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id
AND f.standard_node='ARRIVED_DESTINATION_PORT') AS actualArrivalNodeAt,
(SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id
AND f.standard_node='PICKED_UP') AS actualPickupNodeAt,
(SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id
AND f.standard_node='EMPTY_RETURNED') AS actualEmptyReturnNodeAt
FROM fl_container_booking b
LEFT JOIN fl_carrier_schedule_query q ON q.id=(
SELECT q2.id FROM fl_carrier_schedule_query q2
WHERE q2.container_booking_id=b.id ORDER BY q2.id DESC LIMIT 1)
WHERE b.id IN
<foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach>
ORDER BY b.id
</script>
""")
List<Context> contexts(@Param("containerBookingIds") List<Long> containerBookingIds);
@Select("SELECT * FROM fl_terminal_drayage WHERE container_booking_id=#{containerBookingId}")
TerminalDrayageEntity find(@Param("containerBookingId") long containerBookingId);
@Select("SELECT * FROM fl_terminal_drayage WHERE container_booking_id=#{containerBookingId} FOR UPDATE")
TerminalDrayageEntity lock(@Param("containerBookingId") long containerBookingId);
@Select("""
<script>
SELECT * FROM fl_terminal_drayage WHERE container_booking_id IN
<foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach>
</script>
""")
List<TerminalDrayageEntity> findAll(@Param("containerBookingIds") List<Long> containerBookingIds);
@Update("""
UPDATE fl_terminal_drayage SET fleet_supplier_id=#{value.fleetSupplierId},
fleet_supplier_name=#{value.fleetSupplierName},pickup_appointment_at=#{value.pickupAppointmentAt},
actual_pickup_at=#{value.actualPickupAt},estimated_empty_return_at=#{value.estimatedEmptyReturnAt},
actual_empty_return_at=#{value.actualEmptyReturnAt},updated_by=#{operatorId},
updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE container_booking_id=#{value.containerBookingId} AND version=#{expectedVersion}
""")
int updateAggregate(@Param("value") TerminalDrayageEntity value,
@Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Select("""
SELECT action,from_status AS fromStatus,to_status AS toStatus,from_version AS fromVersion,
to_version AS toVersion,reason,operator_id AS operatorId,operator_name AS operatorName,created_at AS createdAt
FROM fl_terminal_drayage_history WHERE container_booking_id=#{containerBookingId}
ORDER BY created_at DESC,id DESC
""")
List<History> histories(@Param("containerBookingId") long containerBookingId);
@Insert("""
INSERT INTO fl_terminal_drayage_history(id,drayage_id,container_booking_id,action,from_status,to_status,
from_version,to_version,reason,operator_id,operator_name)
VALUES(#{id},#{drayageId},#{containerBookingId},#{action},#{fromStatus},#{toStatus},#{fromVersion},
#{toVersion},#{reason},#{operatorId},#{operatorName})
""")
int insertHistory(@Param("id") long id, @Param("drayageId") long drayageId,
@Param("containerBookingId") long containerBookingId,
@Param("action") String action, @Param("fromStatus") String fromStatus,
@Param("toStatus") String toStatus, @Param("fromVersion") int fromVersion,
@Param("toVersion") int toVersion, @Param("reason") String reason,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Select("""
SELECT operation,request_hash AS requestHash,result_version AS resultVersion
FROM fl_terminal_drayage_operation
WHERE container_booking_id=#{containerBookingId} AND idempotency_key=#{key}
""")
Operation operation(@Param("containerBookingId") long containerBookingId, @Param("key") String key);
@Insert("""
INSERT INTO fl_terminal_drayage_operation(id,container_booking_id,operation,idempotency_key,request_hash,
result_version,operator_id) VALUES(#{id},#{containerBookingId},#{operation},#{key},#{hash},
#{resultVersion},#{operatorId})
""")
int insertOperation(@Param("id") long id, @Param("containerBookingId") long containerBookingId,
@Param("operation") String operation, @Param("key") String key,
@Param("hash") String hash, @Param("resultVersion") int resultVersion,
@Param("operatorId") long operatorId);
record Context(Long containerBookingId, String containerNo, Long oceanBookingId, String recordStatus,
LocalDateTime unloadingCompletedAt, Long queryId, String latestActualNode,
LocalDateTime latestActualAt, Integer queryVersion, LocalDateTime actualArrivalNodeAt,
LocalDateTime actualPickupNodeAt, LocalDateTime actualEmptyReturnNodeAt) {}
record Operation(String operation, String requestHash, Integer resultVersion) {}
}
@@ -0,0 +1,339 @@
package com.baiyue.firstleg.terminaldrayage.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.portschedule.domain.CarrierScheduleNode;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.ActualTimeRequest;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.FleetSelectionRequest;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.History;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.PickupAppointmentRequest;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.Warning;
import com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity;
import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper;
import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Context;
import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Operation;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@Service
@RequiredArgsConstructor
public class TerminalDrayageService {
private static final int EMPTY_RETURN_WARNING_HOURS = 72;
private final TerminalDrayageMapper mapper;
private final WarehouseSupplierSnapshotService suppliers;
private final CarrierScheduleQueryService carrierSchedules;
private final ObjectMapper objectMapper;
private Clock clock = Clock.systemDefaultZone();
public View detail(long containerBookingId) {
return view(requiredContext(containerBookingId), mapper.find(containerBookingId));
}
public List<View> views(List<Long> containerBookingIds) {
if (containerBookingIds == null || containerBookingIds.isEmpty()) return List.of();
List<Long> ids = containerBookingIds.stream().filter(Objects::nonNull).distinct().toList();
if (ids.size() != containerBookingIds.size()) throw badRequest("containerBookingIds不能包含空值或重复值");
Map<Long, TerminalDrayageEntity> rows = new HashMap<>();
mapper.findAll(ids).forEach(value -> rows.put(value.getContainerBookingId(), value));
return mapper.contexts(ids).stream().map(value -> view(value, rows.get(value.containerBookingId()))).toList();
}
public List<History> histories(long containerBookingId) {
requiredContext(containerBookingId);
return mapper.histories(containerBookingId);
}
@Transactional
public View selectFleet(long containerBookingId, long operatorId, String operatorName, String key,
FleetSelectionRequest request) {
String action = "SELECT_FLEET";
Context context = requiredLockedContext(containerBookingId);
View replay = replay(context, action, key, request);
if (replay != null) return replay;
requireArrived(context);
TerminalDrayageEntity value = mapper.lock(containerBookingId);
requireVersion(value, request.expectedVersion());
if (actualPickupAt(context, value) != null) throw conflict("实际提柜后不可更换码头车队");
var fleet = suppliers.trucking(request.fleetSupplierId(), request.fleetSupplierName());
int fromVersion = version(value);
String fromStatus = status(context, value);
if (value == null) value = create(containerBookingId, operatorId, operatorName);
value.setFleetSupplierId(fleet.supplierId());
value.setFleetSupplierName(fleet.name());
persist(value, fromVersion, operatorId, operatorName);
return finish(context, value, action, key, request, fromStatus, fromVersion,
request.reason(), operatorId, operatorName);
}
@Transactional
public View schedulePickup(long containerBookingId, long operatorId, String operatorName, String key,
PickupAppointmentRequest request) {
String action = "SCHEDULE_PICKUP";
Context context = requiredLockedContext(containerBookingId);
View replay = replay(context, action, key, request);
if (replay != null) return replay;
requireArrived(context);
TerminalDrayageEntity value = requiredAggregate(containerBookingId);
requireVersion(value, request.expectedVersion());
if (value.getFleetSupplierId() == null) throw conflict("请先选择有效码头车队");
if (actualPickupAt(context, value) != null) throw conflict("实际提柜后不可修改预约提柜时间");
if (context.actualArrivalNodeAt() != null && request.pickupAppointmentAt().isBefore(context.actualArrivalNodeAt())) {
throw badRequest("预约提柜时间不能早于实际到港时间");
}
int fromVersion = version(value);
String fromStatus = status(context, value);
value.setPickupAppointmentAt(request.pickupAppointmentAt());
persist(value, fromVersion, operatorId, operatorName);
return finish(context, value, action, key, request, fromStatus, fromVersion,
request.reason(), operatorId, operatorName);
}
@Transactional
public View recordPickup(long containerBookingId, long operatorId, String operatorName, String key,
ActualTimeRequest request) {
String action = "RECORD_PICKUP";
Context context = requiredLockedContext(containerBookingId);
View replay = replay(context, action, key, request);
if (replay != null) return replay;
requireAtLeast(context, CarrierScheduleNode.PICKUP_READY, "货柜尚未清关放行,不允许确认实际提柜");
TerminalDrayageEntity value = requiredAggregate(containerBookingId);
requireVersion(value, request.expectedVersion());
if (value.getFleetSupplierId() == null || value.getPickupAppointmentAt() == null) {
throw conflict("选择码头车队并预约提柜后才能确认实际提柜");
}
if (actualPickupAt(context, value) != null) throw conflict("实际提柜时间已确认");
if (context.latestActualAt() != null && request.actualAt().isBefore(context.latestActualAt())) {
throw badRequest("实际提柜时间不能早于当前实际船节点时间");
}
advanceNode(context, CarrierScheduleNode.PICKED_UP, request.actualAt(), action, key,
request.reason(), operatorId, operatorName);
int fromVersion = version(value);
String fromStatus = status(context, value);
value.setActualPickupAt(request.actualAt());
applyAuthoritativeDeadline(context, value);
persist(value, fromVersion, operatorId, operatorName);
return finish(requiredContext(containerBookingId), value, action, key, request, fromStatus,
fromVersion, request.reason(), operatorId, operatorName);
}
@Transactional
public View recordEmptyReturn(long containerBookingId, long operatorId, String operatorName, String key,
ActualTimeRequest request) {
String action = "RECORD_EMPTY_RETURN";
Context context = requiredLockedContext(containerBookingId);
View replay = replay(context, action, key, request);
if (replay != null) return replay;
TerminalDrayageEntity value = requiredAggregate(containerBookingId);
requireVersion(value, request.expectedVersion());
LocalDateTime pickupAt = actualPickupAt(context, value);
if (pickupAt == null) throw conflict("实际提柜后才能确认还柜");
if (context.unloadingCompletedAt() == null) throw conflict("海外卸柜收货完成后才能确认还柜");
LocalDateTime earliest = pickupAt.isAfter(context.unloadingCompletedAt())
? pickupAt : context.unloadingCompletedAt();
if (request.actualAt().isBefore(earliest)) throw badRequest("实际还柜时间不能早于提柜或卸柜完成时间");
if (actualEmptyReturnAt(context, value) != null) throw conflict("实际还柜时间已确认");
advanceNode(context, CarrierScheduleNode.EMPTY_RETURNED, request.actualAt(), action, key,
request.reason(), operatorId, operatorName);
int fromVersion = version(value);
String fromStatus = status(context, value);
applyAuthoritativeDeadline(context, value);
value.setActualEmptyReturnAt(request.actualAt());
persist(value, fromVersion, operatorId, operatorName);
return finish(requiredContext(containerBookingId), value, action, key, request, fromStatus,
fromVersion, request.reason(), operatorId, operatorName);
}
private void advanceNode(Context context, CarrierScheduleNode target, LocalDateTime actualAt,
String action, String key, String reason, long operatorId, String operatorName) {
if (context.queryId() == null || context.queryVersion() == null) {
throw conflict("货柜尚未建立船节点跟踪任务");
}
CarrierScheduleNode current = node(context.latestActualNode());
if (current != null && current.sequence() >= target.sequence()) return;
carrierSchedules.addManualNode(context.queryId(), operatorId, operatorName,
"td-" + hash(context.containerBookingId() + "|" + action + "|" + key.trim()),
new ManualNodeRequest(context.queryVersion(), target.name(), LocalDateTime.now(clock), null,
actualAt, null, null, StringUtils.hasText(reason) ? reason.trim() : "码头拖车操作同步"));
}
private View finish(Context context, TerminalDrayageEntity value, String action, String key, Object request,
String fromStatus, int fromVersion, String reason,
long operatorId, String operatorName) {
TerminalDrayageEntity stored = mapper.find(context.containerBookingId());
Context currentContext = requiredContext(context.containerBookingId());
String toStatus = status(currentContext, stored);
mapper.insertHistory(IdWorker.getId(), stored.getId(), context.containerBookingId(), action,
fromStatus, toStatus, fromVersion, version(stored), trim(reason), operatorId, operatorName);
mapper.insertOperation(IdWorker.getId(), context.containerBookingId(), action, key.trim(), hash(request),
version(stored), operatorId);
return view(currentContext, stored);
}
private View replay(Context context, String action, String key, Object request) {
if (!StringUtils.hasText(key) || key.length() > 255) throw badRequest("Idempotency-Key不能为空且不能超过255字符");
Operation operation = mapper.operation(context.containerBookingId(), key.trim());
if (operation == null) return null;
if (!action.equals(operation.operation()) || !hash(request).equals(operation.requestHash())) {
throw conflict("Idempotency-Key已被不同的码头拖车操作占用");
}
return view(context, mapper.find(context.containerBookingId()));
}
private TerminalDrayageEntity create(long containerBookingId, long operatorId, String operatorName) {
TerminalDrayageEntity value = new TerminalDrayageEntity();
value.setId(IdWorker.getId()); value.setContainerBookingId(containerBookingId); value.setVersion(1);
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName);
return value;
}
private void persist(TerminalDrayageEntity value, int expectedVersion, long operatorId, String operatorName) {
if (expectedVersion == 0) {
mapper.insert(value);
return;
}
if (mapper.updateAggregate(value, expectedVersion, operatorId, operatorName) != 1) throw concurrent();
}
private void applyAuthoritativeDeadline(Context context, TerminalDrayageEntity value) {
if (context.unloadingCompletedAt() != null) {
value.setEstimatedEmptyReturnAt(context.unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS));
}
}
private View view(Context context, TerminalDrayageEntity value) {
String status = status(context, value);
LocalDateTime deadline = context.unloadingCompletedAt() == null ? null
: context.unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS);
LocalDateTime returnedAt = actualEmptyReturnAt(context, value);
String warningStatus;
long overdueHours = 0;
if (returnedAt != null) warningStatus = "RESOLVED";
else if (deadline == null) warningStatus = "NOT_APPLICABLE";
else if (LocalDateTime.now(clock).isAfter(deadline)) {
warningStatus = "OVERDUE";
overdueHours = Math.max(0, Duration.between(deadline, LocalDateTime.now(clock)).toHours());
} else warningStatus = "PENDING";
Warning warning = new Warning(warningStatus, deadline, overdueHours, overdueHours / 24);
return new View(context.containerBookingId(), context.containerNo(), context.oceanBookingId(), status,
version(value), value == null ? null : value.getFleetSupplierId(),
value == null ? null : value.getFleetSupplierName(),
value == null ? null : value.getPickupAppointmentAt(),
actualPickupAt(context, value), deadline, returnedAt,
context.unloadingCompletedAt(), warning, allowedActions(context, value),
value == null ? null : value.getUpdatedAt());
}
private List<String> allowedActions(Context context, TerminalDrayageEntity value) {
List<String> result = new ArrayList<>();
CarrierScheduleNode current = node(context.latestActualNode());
if (current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence()
&& current.sequence() < CarrierScheduleNode.PICKED_UP.sequence()
&& actualPickupAt(context, value) == null) {
result.add("SELECT_FLEET");
if (value != null && value.getFleetSupplierId() != null) result.add("SCHEDULE_PICKUP");
}
if (current != null && current.sequence() >= CarrierScheduleNode.PICKUP_READY.sequence()
&& value != null && value.getFleetSupplierId() != null && value.getPickupAppointmentAt() != null
&& actualPickupAt(context, value) == null) result.add("RECORD_PICKUP");
if (context.unloadingCompletedAt() != null && actualPickupAt(context, value) != null
&& actualEmptyReturnAt(context, value) == null) result.add("RECORD_EMPTY_RETURN");
return List.copyOf(result);
}
private String status(Context context, TerminalDrayageEntity value) {
if (actualEmptyReturnAt(context, value) != null) return "EMPTY_RETURNED";
if (context.unloadingCompletedAt() != null && actualPickupAt(context, value) != null) return "UNLOADED";
if (actualPickupAt(context, value) != null) return "PICKED_UP";
if (value != null && value.getPickupAppointmentAt() != null) return "PICKUP_SCHEDULED";
CarrierScheduleNode current = node(context.latestActualNode());
return current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence()
? "ARRIVED" : "WAITING_FOR_ARRIVAL";
}
private void requireArrived(Context context) {
requireAtLeast(context, CarrierScheduleNode.ARRIVED_DESTINATION_PORT, "货柜实际到达目的港后才能安排码头车队");
}
private void requireAtLeast(Context context, CarrierScheduleNode required, String message) {
CarrierScheduleNode current = node(context.latestActualNode());
if (current == null || current.sequence() < required.sequence()) throw conflict(message);
}
private CarrierScheduleNode node(String value) {
return StringUtils.hasText(value) ? CarrierScheduleNode.require(value) : null;
}
private LocalDateTime actualPickupAt(Context context, TerminalDrayageEntity value) {
return value != null && value.getActualPickupAt() != null ? value.getActualPickupAt() : context.actualPickupNodeAt();
}
private LocalDateTime actualEmptyReturnAt(Context context, TerminalDrayageEntity value) {
return value != null && value.getActualEmptyReturnAt() != null
? value.getActualEmptyReturnAt() : context.actualEmptyReturnNodeAt();
}
private Context requiredContext(long id) {
Context value = mapper.context(id);
if (value == null || !"ACTIVE".equals(value.recordStatus())) throw notFound();
return value;
}
private Context requiredLockedContext(long id) {
Context value = mapper.lockContext(id);
if (value == null || !"ACTIVE".equals(value.recordStatus())) throw notFound();
return value;
}
private TerminalDrayageEntity requiredAggregate(long id) {
TerminalDrayageEntity value = mapper.lock(id);
if (value == null) throw conflict("请先选择码头车队");
return value;
}
private void requireVersion(TerminalDrayageEntity value, int expectedVersion) {
if (version(value) != expectedVersion) throw concurrent();
}
private int version(TerminalDrayageEntity value) {
return value == null || value.getVersion() == null ? 0 : value.getVersion();
}
private String hash(Object request) {
try {
byte[] json = objectMapper.writeValueAsBytes(request);
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json));
} catch (JsonProcessingException | NoSuchAlgorithmException exception) {
throw new IllegalStateException("无法计算码头拖车请求摘要", exception);
}
}
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static BizException badRequest(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
private static BizException concurrent() { return conflict("码头拖车记录已变化,请刷新后重试"); }
private static BizException notFound() { return new BizException(ResultCode.NOT_FOUND, "货柜不存在或不可用"); }
void setClock(Clock clock) { this.clock = clock; }
}
@@ -0,0 +1,63 @@
SET NAMES utf8mb4;
USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_terminal_drayage (
id BIGINT UNSIGNED NOT NULL COMMENT '目的港码头拖车聚合ID',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '唯一归属物理货柜ID',
fleet_supplier_id BIGINT UNSIGNED NOT NULL COMMENT 'Warehouse ACTIVE/TRUCKING供应商ID快照',
fleet_supplier_name VARCHAR(128) NOT NULL COMMENT 'Warehouse供应商名称快照',
pickup_appointment_at DATETIME(3) NULL COMMENT '预约提柜时间',
actual_pickup_at DATETIME(3) NULL COMMENT '实际提柜时间',
estimated_empty_return_at DATETIME(3) NULL COMMENT '服务端按卸柜完成时间加72小时计算的预计还柜时间',
actual_empty_return_at DATETIME(3) NULL COMMENT '实际还空柜时间',
version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观锁版本号',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后更新人ID',
updated_by_name VARCHAR(64) NULL COMMENT '最后更新人名称快照',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(id),
UNIQUE KEY uk_terminal_drayage_container(container_booking_id),
KEY idx_terminal_drayage_warning(actual_empty_return_at,estimated_empty_return_at),
CONSTRAINT fk_terminal_drayage_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id),
CONSTRAINT chk_terminal_drayage_times CHECK(
(actual_pickup_at IS NULL OR pickup_appointment_at IS NOT NULL) AND
(actual_empty_return_at IS NULL OR actual_pickup_at IS NOT NULL) AND
(actual_empty_return_at IS NULL OR actual_empty_return_at >= actual_pickup_at))
) ENGINE=InnoDB COMMENT='目的港货柜级码头拖车与还空事实';
CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history (
id BIGINT UNSIGNED NOT NULL COMMENT '历史ID',
drayage_id BIGINT UNSIGNED NOT NULL COMMENT '码头拖车聚合ID',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID快照',
action VARCHAR(32) NOT NULL COMMENT 'SELECT_FLEET/SCHEDULE_PICKUP/RECORD_PICKUP/RECORD_EMPTY_RETURN',
from_status VARCHAR(32) NOT NULL COMMENT '操作前状态',
to_status VARCHAR(32) NOT NULL COMMENT '操作后状态',
from_version INT UNSIGNED NOT NULL COMMENT '操作前版本',
to_version INT UNSIGNED NOT NULL COMMENT '操作后版本',
reason VARCHAR(500) NULL COMMENT '操作说明',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间',
PRIMARY KEY(id),
KEY idx_terminal_drayage_history(container_booking_id,created_at,id),
CONSTRAINT fk_terminal_drayage_history FOREIGN KEY(drayage_id) REFERENCES fl_terminal_drayage(id),
CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN
('SELECT_FLEET','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN'))
) ENGINE=InnoDB COMMENT='目的港码头拖车不可变操作历史';
CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation (
id BIGINT UNSIGNED NOT NULL COMMENT '幂等操作ID',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID',
operation VARCHAR(32) NOT NULL COMMENT '业务动作',
idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键',
request_hash CHAR(64) NOT NULL COMMENT '请求载荷SHA-256',
result_version INT UNSIGNED NOT NULL COMMENT '成功结果版本',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '完成时间',
PRIMARY KEY(id),
UNIQUE KEY uk_terminal_drayage_operation(container_booking_id,idempotency_key),
KEY idx_terminal_drayage_operation(container_booking_id,created_at),
CONSTRAINT fk_terminal_drayage_operation_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id)
) ENGINE=InnoDB COMMENT='目的港码头拖车成功幂等操作';
@@ -0,0 +1,127 @@
openapi: 3.0.3
info: {title: Firstleg Terminal Drayage API, version: 1.0.0}
paths:
/api/platform/v1/terminal-drayages:
get:
summary: 批量查询货柜码头拖车及服务端报空预警
x-permission: platform:firstleg:container:list
parameters:
- name: containerBookingIds
in: query
required: true
schema: {type: array, minItems: 1, maxItems: 200, items: {type: string, pattern: '^[1-9][0-9]*$'}}
responses: {'200': {$ref: '#/components/responses/ViewsResponse'}}
/api/platform/v1/terminal-drayages/{containerBookingId}:
get:
summary: 查询单柜码头拖车详情
x-permission: platform:firstleg:container:list
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
/api/platform/v1/terminal-drayages/{containerBookingId}/history:
get:
summary: 查询单柜码头拖车操作历史
x-permission: platform:firstleg:container:list
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
responses: {'200': {$ref: '#/components/responses/HistoryResponse'}}
/api/platform/v1/terminal-drayages/{containerBookingId}/fleet:
post:
summary: 到港后选择或更换码头车队
x-permission: platform:firstleg:container:edit
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/FleetSelectionRequest'}}}}
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
/api/platform/v1/terminal-drayages/{containerBookingId}/pickup-appointment:
post:
summary: 预约目的港提柜时间
x-permission: platform:firstleg:container:edit
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PickupAppointmentRequest'}}}}
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
/api/platform/v1/terminal-drayages/{containerBookingId}/pickup:
post:
summary: 清关可提柜后确认实际提柜并推进PICKED_UP节点
x-permission: platform:firstleg:container:edit
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualTimeRequest'}}}}
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
/api/platform/v1/terminal-drayages/{containerBookingId}/empty-return:
post:
summary: 海外卸柜收货完成后确认实际还柜并推进EMPTY_RETURNED节点
x-permission: platform:firstleg:container:edit
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualTimeRequest'}}}}
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
components:
parameters:
ContainerBookingId: {name: containerBookingId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 255}}
schemas:
FleetSelectionRequest:
type: object
required: [expectedVersion, fleetSupplierId]
properties:
expectedVersion: {type: integer, minimum: 0}
fleetSupplierId: {type: string, pattern: '^[1-9][0-9]*$', description: Warehouse ACTIVE/TRUCKING供应商ID}
fleetSupplierName: {type: string, maxLength: 128, nullable: true, description: 可选一致性提示,持久化名称以服务端快照为准}
reason: {type: string, maxLength: 500, nullable: true}
PickupAppointmentRequest:
type: object
required: [expectedVersion, pickupAppointmentAt]
properties:
expectedVersion: {type: integer, minimum: 0}
pickupAppointmentAt: {type: string, format: date-time}
reason: {type: string, maxLength: 500, nullable: true}
ActualTimeRequest:
type: object
required: [expectedVersion, actualAt]
properties:
expectedVersion: {type: integer, minimum: 0}
actualAt: {type: string, format: date-time}
reason: {type: string, maxLength: 500, nullable: true}
Warning:
type: object
required: [status, overdueHours, overdueDays]
properties:
status: {type: string, enum: [NOT_APPLICABLE, PENDING, OVERDUE, RESOLVED]}
deadlineAt: {type: string, format: date-time, nullable: true, description: 卸柜完成时间加72小时}
overdueHours: {type: integer, format: int64, minimum: 0}
overdueDays: {type: integer, format: int64, minimum: 0}
TerminalDrayageView:
type: object
required: [containerBookingId, status, version, emptyReturnWarning, allowedActions]
properties:
containerBookingId: {type: string, pattern: '^[1-9][0-9]*$'}
containerNo: {type: string, nullable: true}
oceanBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
status: {type: string, enum: [WAITING_FOR_ARRIVAL, ARRIVED, PICKUP_SCHEDULED, PICKED_UP, UNLOADED, EMPTY_RETURNED]}
version: {type: integer, minimum: 0}
fleetSupplierId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
fleetSupplierName: {type: string, nullable: true}
pickupAppointmentAt: {type: string, format: date-time, nullable: true}
actualPickupAt: {type: string, format: date-time, nullable: true}
estimatedEmptyReturnAt: {type: string, format: date-time, nullable: true, description: 服务端权威值,卸柜完成时间加72小时}
actualEmptyReturnAt: {type: string, format: date-time, nullable: true}
unloadingCompletedAt: {type: string, format: date-time, nullable: true}
emptyReturnWarning: {$ref: '#/components/schemas/Warning'}
allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN]}}
updatedAt: {type: string, format: date-time, nullable: true}
History:
type: object
required: [action, fromStatus, toStatus, fromVersion, toVersion, operatorId, createdAt]
properties:
action: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN]}
fromStatus: {type: string}
toStatus: {type: string}
fromVersion: {type: integer, minimum: 0}
toVersion: {type: integer, minimum: 1}
reason: {type: string, nullable: true}
operatorId: {type: string, pattern: '^[1-9][0-9]*$'}
operatorName: {type: string, nullable: true}
createdAt: {type: string, format: date-time}
ApiView: {type: object, required: [code, data], properties: {code: {type: string}, message: {type: string}, data: {$ref: '#/components/schemas/TerminalDrayageView'}}}
ApiViews: {type: object, required: [code, data], properties: {code: {type: string}, message: {type: string}, data: {type: array, items: {$ref: '#/components/schemas/TerminalDrayageView'}}}}
ApiHistory: {type: object, required: [code, data], properties: {code: {type: string}, message: {type: string}, data: {type: array, items: {$ref: '#/components/schemas/History'}}}}
responses:
ViewResponse: {description: 成功, content: {application/json: {schema: {$ref: '#/components/schemas/ApiView'}}}}
ViewsResponse: {description: 成功, content: {application/json: {schema: {$ref: '#/components/schemas/ApiViews'}}}}
HistoryResponse: {description: 成功, content: {application/json: {schema: {$ref: '#/components/schemas/ApiHistory'}}}}
@@ -0,0 +1,24 @@
package com.baiyue.firstleg.terminaldrayage.controller;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class TerminalDrayageOpenApiContractTest {
@Test
void freezesActionsPermissionsAndServerWarningFields() throws Exception {
String yaml = Files.readString(Path.of("src/main/resources/openapi/terminal-drayage-v1.yaml"));
assertThat(yaml).contains(
"/api/platform/v1/terminal-drayages/{containerBookingId}/fleet:",
"/api/platform/v1/terminal-drayages/{containerBookingId}/pickup-appointment:",
"/api/platform/v1/terminal-drayages/{containerBookingId}/pickup:",
"/api/platform/v1/terminal-drayages/{containerBookingId}/empty-return:",
"platform:firstleg:container:list", "platform:firstleg:container:edit",
"expectedVersion", "Idempotency-Key", "estimatedEmptyReturnAt",
"emptyReturnWarning", "deadlineAt", "overdueHours", "overdueDays");
assertThat(yaml).contains("description: 卸柜完成时间加72小时");
}
}
@@ -0,0 +1,27 @@
package com.baiyue.firstleg.terminaldrayage.mapper;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class TerminalDrayageSqlContractTest {
@Test
void schemaKeepsOneAggregatePerContainerAndImmutableAudit() throws Exception {
String sql = Files.readString(Path.of("src/main/resources/db/phase3-terminal-drayage-schema.sql"));
String mapper = Files.readString(Path.of(
"src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java"));
assertThat(sql).contains(
"CREATE TABLE IF NOT EXISTS fl_terminal_drayage (",
"UNIQUE KEY uk_terminal_drayage_container(container_booking_id)",
"CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history (",
"CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation (",
"UNIQUE KEY uk_terminal_drayage_operation(container_booking_id,idempotency_key)",
"服务端按卸柜完成时间加72小时计算的预计还柜时间");
assertThat(sql).doesNotContain("ON DELETE CASCADE");
assertThat(mapper).contains("WHERE b.id=#{containerBookingId} FOR UPDATE",
"WHERE container_booking_id=#{value.containerBookingId} AND version=#{expectedVersion}");
}
}
@@ -0,0 +1,251 @@
package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehouseSupplierClient;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.ActualTimeRequest;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.FleetSelectionRequest;
import com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity;
import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper;
import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Context;
import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Operation;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class TerminalDrayageServiceTest {
private static final LocalDateTime ARRIVED_AT = LocalDateTime.of(2026, 9, 1, 8, 0);
private TerminalDrayageMapper mapper;
private WarehouseSupplierSnapshotService suppliers;
private CarrierScheduleQueryService carrierSchedules;
private TerminalDrayageService service;
@BeforeEach
void setUp() {
mapper = mock(TerminalDrayageMapper.class);
suppliers = mock(WarehouseSupplierSnapshotService.class);
carrierSchedules = mock(CarrierScheduleQueryService.class);
service = new TerminalDrayageService(mapper, suppliers, carrierSchedules, new ObjectMapper().findAndRegisterModules());
service.setClock(Clock.fixed(Instant.parse("2026-09-05T02:00:00Z"), ZoneOffset.UTC));
}
@Test
void rejectsFleetSelectionBeforeActualDestinationArrival() {
when(mapper.lockContext(11L)).thenReturn(context("IN_TRANSIT", null));
assertThatThrownBy(() -> service.selectFleet(11L, 7L, "admin", "fleet-1",
new FleetSelectionRequest(0, 91L, null, null)))
.isInstanceOf(BizException.class)
.hasMessage("货柜实际到达目的港后才能安排码头车队");
verify(suppliers, never()).trucking(anyLong(), any());
}
@Test
void selectsActiveTruckingSupplierAndPersistsServerSnapshot() {
Context context = context("ARRIVED_DESTINATION_PORT", null);
when(mapper.lockContext(11L)).thenReturn(context);
when(mapper.context(11L)).thenReturn(context);
when(mapper.lock(11L)).thenReturn(null);
when(mapper.operation(11L, "fleet-1")).thenReturn(null);
when(suppliers.trucking(91L, "用户提示名")).thenReturn(
new WarehouseSupplierClient.Snapshot(91L, "服务端车队", "TRUCKING", "ACTIVE", List.of()));
AtomicReference<TerminalDrayageEntity> stored = new AtomicReference<>();
when(mapper.insert(any(TerminalDrayageEntity.class))).thenAnswer(invocation -> {
TerminalDrayageEntity value = invocation.getArgument(0);
stored.set(value);
return 1;
});
when(mapper.find(11L)).thenAnswer(ignored -> stored.get());
var result = service.selectFleet(11L, 7L, "admin", "fleet-1",
new FleetSelectionRequest(0, 91L, "用户提示名", "安排提柜"));
assertThat(result.version()).isEqualTo(1);
assertThat(result.fleetSupplierId()).isEqualTo(91L);
assertThat(result.fleetSupplierName()).isEqualTo("服务端车队");
assertThat(result.allowedActions()).containsExactlyInAnyOrder("SELECT_FLEET", "SCHEDULE_PICKUP");
verify(mapper).insertHistory(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString(),
anyInt(), anyInt(), any(), anyLong(), anyString());
verify(mapper).insertOperation(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyInt(), anyLong());
}
@Test
void recordsPickupOnceAndAdvancesCanonicalNode() {
Context before = context("PICKUP_READY", null);
Context after = context("PICKED_UP", null);
when(mapper.lockContext(11L)).thenReturn(before);
when(mapper.context(11L)).thenReturn(after);
when(mapper.operation(11L, "pickup-1")).thenReturn(null);
TerminalDrayageEntity row = row(2);
row.setPickupAppointmentAt(LocalDateTime.of(2026, 9, 2, 9, 0));
when(mapper.lock(11L)).thenReturn(row);
when(mapper.updateAggregate(any(), anyInt(), anyLong(), anyString())).thenAnswer(invocation -> {
row.setVersion(3);
return 1;
});
when(mapper.find(11L)).thenReturn(row);
var result = service.recordPickup(11L, 7L, "admin", "pickup-1",
new ActualTimeRequest(2, LocalDateTime.of(2026, 9, 2, 10, 0), "码头提柜"));
assertThat(result.status()).isEqualTo("PICKED_UP");
assertThat(result.actualPickupAt()).isEqualTo(LocalDateTime.of(2026, 9, 2, 10, 0));
ArgumentCaptor<ManualNodeRequest> node = ArgumentCaptor.forClass(ManualNodeRequest.class);
verify(carrierSchedules).addManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture());
assertThat(node.getValue().standardNode()).isEqualTo("PICKED_UP");
verify(mapper, times(1)).updateAggregate(any(), anyInt(), anyLong(), anyString());
}
@Test
void requiresOverseasUnloadBeforeEmptyReturn() {
Context context = context("PICKED_UP", null);
when(mapper.lockContext(11L)).thenReturn(context);
when(mapper.operation(11L, "return-1")).thenReturn(null);
TerminalDrayageEntity row = row(3);
row.setActualPickupAt(LocalDateTime.of(2026, 9, 2, 10, 0));
when(mapper.lock(11L)).thenReturn(row);
assertThatThrownBy(() -> service.recordEmptyReturn(11L, 7L, "admin", "return-1",
new ActualTimeRequest(3, LocalDateTime.of(2026, 9, 4, 10, 0), null)))
.isInstanceOf(BizException.class)
.hasMessage("海外卸柜收货完成后才能确认还柜");
verify(carrierSchedules, never()).addManualNode(anyLong(), anyLong(), anyString(), anyString(), any());
}
@Test
void computesSeventyTwoHourWarningAndClearsItAfterReturn() {
LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 1, 0, 0);
Context context = context("PICKED_UP", unloadedAt);
TerminalDrayageEntity row = row(3);
row.setActualPickupAt(LocalDateTime.of(2026, 8, 31, 18, 0));
when(mapper.context(11L)).thenReturn(context);
when(mapper.find(11L)).thenReturn(row);
var overdue = service.detail(11L);
assertThat(overdue.estimatedEmptyReturnAt()).isEqualTo(LocalDateTime.of(2026, 9, 4, 0, 0));
assertThat(overdue.emptyReturnWarning().status()).isEqualTo("OVERDUE");
assertThat(overdue.emptyReturnWarning().overdueHours()).isEqualTo(26);
assertThat(overdue.emptyReturnWarning().overdueDays()).isEqualTo(1);
row.setActualEmptyReturnAt(LocalDateTime.of(2026, 9, 4, 1, 0));
assertThat(service.detail(11L).emptyReturnWarning().status()).isEqualTo("RESOLVED");
assertThat(service.detail(11L).emptyReturnWarning().overdueHours()).isZero();
}
@Test
void sameIdempotencyKeyWithDifferentPayloadConflictsWithoutWriting() {
Context context = context("ARRIVED_DESTINATION_PORT", null);
when(mapper.lockContext(11L)).thenReturn(context);
when(mapper.operation(11L, "same-key")).thenReturn(new Operation("SELECT_FLEET", "different-hash", 1));
assertThatThrownBy(() -> service.selectFleet(11L, 7L, "admin", "same-key",
new FleetSelectionRequest(0, 91L, null, null)))
.isInstanceOf(BizException.class)
.hasMessage("Idempotency-Key已被不同的码头拖车操作占用");
verify(mapper, never()).insert(any(TerminalDrayageEntity.class));
verify(mapper, never()).updateAggregate(any(), anyInt(), anyLong(), anyString());
}
@Test
void sameIdempotencyKeyAndPayloadReplaysWithoutSecondWrite() {
Context context = context("ARRIVED_DESTINATION_PORT", null);
when(mapper.lockContext(11L)).thenReturn(context);
when(mapper.context(11L)).thenReturn(context);
when(mapper.lock(11L)).thenReturn(null);
when(suppliers.trucking(91L, null)).thenReturn(
new WarehouseSupplierClient.Snapshot(91L, "服务端车队", "TRUCKING", "ACTIVE", List.of()));
AtomicReference<TerminalDrayageEntity> stored = new AtomicReference<>();
AtomicReference<Operation> operation = new AtomicReference<>();
when(mapper.insert(any(TerminalDrayageEntity.class))).thenAnswer(invocation -> {
stored.set(invocation.getArgument(0));
return 1;
});
when(mapper.find(11L)).thenAnswer(ignored -> stored.get());
when(mapper.operation(11L, "fleet-replay")).thenAnswer(ignored -> operation.get());
when(mapper.insertOperation(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyInt(), anyLong()))
.thenAnswer(invocation -> {
operation.set(new Operation(invocation.getArgument(2), invocation.getArgument(4), invocation.getArgument(5)));
return 1;
});
FleetSelectionRequest request = new FleetSelectionRequest(0, 91L, null, null);
var first = service.selectFleet(11L, 7L, "admin", "fleet-replay", request);
var replay = service.selectFleet(11L, 7L, "admin", "fleet-replay", request);
assertThat(replay).isEqualTo(first);
verify(mapper, times(1)).insert(any(TerminalDrayageEntity.class));
verify(mapper, times(1)).insertOperation(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyInt(), anyLong());
verify(suppliers, times(1)).trucking(91L, null);
}
@Test
void recordsEmptyReturnAndResolvesWarning() {
LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 3, 0, 0);
Context before = context("PICKED_UP", unloadedAt);
Context after = context("EMPTY_RETURNED", unloadedAt);
when(mapper.lockContext(11L)).thenReturn(before);
when(mapper.context(11L)).thenReturn(after);
when(mapper.operation(11L, "return-2")).thenReturn(null);
TerminalDrayageEntity row = row(3);
row.setPickupAppointmentAt(LocalDateTime.of(2026, 9, 2, 9, 0));
row.setActualPickupAt(LocalDateTime.of(2026, 9, 2, 10, 0));
when(mapper.lock(11L)).thenReturn(row);
when(mapper.updateAggregate(any(), anyInt(), anyLong(), anyString())).thenAnswer(invocation -> {
row.setVersion(4);
return 1;
});
when(mapper.find(11L)).thenReturn(row);
var result = service.recordEmptyReturn(11L, 7L, "admin", "return-2",
new ActualTimeRequest(3, LocalDateTime.of(2026, 9, 4, 9, 0), "已还柜"));
assertThat(result.status()).isEqualTo("EMPTY_RETURNED");
assertThat(result.estimatedEmptyReturnAt()).isEqualTo(unloadedAt.plusHours(72));
assertThat(result.emptyReturnWarning().status()).isEqualTo("RESOLVED");
ArgumentCaptor<ManualNodeRequest> node = ArgumentCaptor.forClass(ManualNodeRequest.class);
verify(carrierSchedules).addManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture());
assertThat(node.getValue().standardNode()).isEqualTo("EMPTY_RETURNED");
}
private Context context(String node, LocalDateTime unloadedAt) {
int sequenceVersion = switch (node) {
case "PICKED_UP" -> 5;
case "PICKUP_READY" -> 4;
case "ARRIVED_DESTINATION_PORT" -> 3;
default -> 2;
};
return new Context(11L, "MSCU1234567", 21L, "ACTIVE", unloadedAt,
31L, node, ARRIVED_AT, sequenceVersion, ARRIVED_AT,
"PICKED_UP".equals(node) || "EMPTY_RETURNED".equals(node) ? ARRIVED_AT.plusDays(1) : null,
"EMPTY_RETURNED".equals(node) ? ARRIVED_AT.plusDays(3) : null);
}
private TerminalDrayageEntity row(int version) {
TerminalDrayageEntity value = new TerminalDrayageEntity();
value.setId(41L); value.setContainerBookingId(11L); value.setVersion(version);
value.setFleetSupplierId(91L); value.setFleetSupplierName("服务端车队");
return value;
}
}
@@ -7,6 +7,7 @@
"booking-customs-trucking->outbound",
"booking-customs-trucking->pricebook-query",
"booking-customs-trucking->shared-technical",
"booking-customs-trucking->shipping-nodes",
"booking-customs-trucking->split-loading",
"customer-statistics->shared-technical",
"finance-integration->inbound",