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; }
}