feat: complete tail order controls and terminal drayage
This commit is contained in:
+117
@@ -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));
|
||||
}
|
||||
}
|
||||
+41
@@ -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) {}
|
||||
}
|
||||
+30
@@ -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;
|
||||
}
|
||||
+148
@@ -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) {}
|
||||
}
|
||||
+339
@@ -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'}}}}
|
||||
+24
@@ -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小时");
|
||||
}
|
||||
}
|
||||
+27
@@ -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}");
|
||||
}
|
||||
}
|
||||
+251
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user