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; }
|
||||
}
|
||||
Reference in New Issue
Block a user