Merge remote-tracking branch 'origin/test' into dev

This commit is contained in:
2026-09-08 20:22:49 +08:00
81 changed files with 2343 additions and 361 deletions
@@ -6,10 +6,12 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.Query;
import com.baiyue.firstleg.terminaldrayage.dto.ContainerWorkbenchDtos.WorkbenchItem;
import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.service.ContainerService;
import com.baiyue.firstleg.terminaldrayage.service.ContainerWorkbenchService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.LoadingListService;
import com.baiyue.firstleg.dto.LoadingListDtos.BusinessDocument;
@@ -39,21 +41,50 @@ import org.springframework.web.multipart.MultipartFile;
@RequestMapping("/api/platform/v1/containers")
public class PlatformContainerController {
@Resource private ContainerService service;
@Resource private ContainerWorkbenchService workbenches;
@Resource private LoadingListService loadingLists;
@Resource private ContainerOverviewService overview;
@GetMapping @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
public ApiResponse<PageResult<ContainerBookingEntity>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid Query query) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.page(query));
@GetMapping("/domestic") @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
public ApiResponse<PageResult<WorkbenchItem>> domesticPage(@AuthenticationPrincipal AuthenticatedUser user,
@Valid Query query) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(workbenches.domesticPage(query, user.permissions()));
}
@GetMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
public ApiResponse<ContainerBookingEntity> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(id));
@GetMapping("/overseas") @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
public ApiResponse<PageResult<WorkbenchItem>> overseasPage(@AuthenticationPrincipal AuthenticatedUser user,
@Valid Query query) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(workbenches.overseasPage(query, user.permissions()));
}
@GetMapping("/domestic/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
public ApiResponse<WorkbenchItem> domesticDetail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(workbenches.domesticDetail(id, user.permissions()));
}
@GetMapping("/overseas/{id}") @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
public ApiResponse<WorkbenchItem> overseasDetail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(workbenches.overseasDetail(id, user.permissions()));
}
@GetMapping("/{id}/overview") @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
public ApiResponse<Overview> overview(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(overview.overview(id));
FirstlegScopeGuard.platform(user); workbenches.domesticDetail(id, user.permissions());
return ApiResponse.success(overview.overview(id));
}
@GetMapping("/overseas/{id}/overview")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
public ApiResponse<Overview> overseasOverview(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id) {
FirstlegScopeGuard.platform(user); service.requireOverseas(id);
return ApiResponse.success(overview.overview(id));
}
@PutMapping("/{id}/pickup-code")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:document:edit')")
@BusinessAudit(event="CONTAINER_PICKUP_CODE_UPDATE", resourceType="CONTAINER", resourceId="#id", description="填写PU码")
public ApiResponse<Overview> updatePickupCode(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id,
@Valid @RequestBody PickupCodeRequest request) {
FirstlegScopeGuard.platform(user);
service.requireOverseas(id);
return ApiResponse.success(overview.updatePickupCode(id, user.userId(), user.username(), request));
}
@GetMapping("/{id}/pda/photos/{fileId}/download")
@PreAuthorize("hasAuthority('platform:firstleg:container:list')")
@@ -61,6 +92,7 @@ public class PlatformContainerController {
@PathVariable @Positive long id,
@PathVariable @Positive long fileId) {
FirstlegScopeGuard.platform(user);
service.requireDomestic(id);
var value = overview.downloadPhoto(id, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.inline()
@@ -71,20 +103,20 @@ public class PlatformContainerController {
public ApiResponse<Void> markException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ExceptionRequest request) {
FirstlegScopeGuard.platform(user); overview.mark(id,user.userId(),user.username(),key,request); return ApiResponse.success(null);
FirstlegScopeGuard.platform(user); service.requireDomestic(id); overview.mark(id,user.userId(),user.username(),key,request); return ApiResponse.success(null);
}
@DeleteMapping("/{id}/exception") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
public ApiResponse<Void> cancelException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ExceptionRequest request) {
FirstlegScopeGuard.platform(user); overview.cancel(id,user.userId(),user.username(),key,request); return ApiResponse.success(null);
FirstlegScopeGuard.platform(user); service.requireDomestic(id); overview.cancel(id,user.userId(),user.username(),key,request); return ApiResponse.success(null);
}
@GetMapping("/{id}/documents/{documentType}") @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
public ApiResponse<BusinessDocument> logisticsDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType){FirstlegScopeGuard.platform(user);return ApiResponse.success(loadingLists.containerLogisticsDocument(id,documentType));}
@PostMapping(value="/{id}/documents/{documentType}/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
public ApiResponse<BusinessDocument> uploadLogisticsDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType,@RequestHeader("Idempotency-Key") String key,@RequestPart("file") MultipartFile file,@RequestParam("expectedVersion") @Min(0) int version,@RequestParam(value="operationReason",required=false) String reason){FirstlegScopeGuard.platform(user);return ApiResponse.success(loadingLists.uploadContainerLogisticsDocument(id,documentType,user.userId(),user.username(),key,version,reason,file));}
@GetMapping("/{id}/documents/{documentType}/{fileId}/download") @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
public ResponseEntity<byte[]> downloadDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType,@PathVariable @Positive long fileId){FirstlegScopeGuard.platform(user);var d=loadingLists.downloadContainerDocument(id,documentType,fileId);return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header(HttpHeaders.CONTENT_DISPOSITION,org.springframework.http.ContentDisposition.attachment().filename(d.fileName(),StandardCharsets.UTF_8).build().toString()).contentType(d.contentType()).contentLength(d.content().length).body(d.content());}
@GetMapping("/{id}/documents/{documentType}") @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
public ApiResponse<BusinessDocument> logisticsDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType){FirstlegScopeGuard.platform(user);service.requireOverseas(id);return ApiResponse.success(loadingLists.containerLogisticsDocument(id,documentType));}
@PostMapping(value="/{id}/documents/{documentType}/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:document:edit')")
public ApiResponse<BusinessDocument> uploadLogisticsDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType,@RequestHeader("Idempotency-Key") String key,@RequestPart("file") MultipartFile file,@RequestParam("expectedVersion") @Min(0) int version,@RequestParam(value="operationReason",required=false) String reason){FirstlegScopeGuard.platform(user);service.requireOverseas(id);return ApiResponse.success(loadingLists.uploadContainerLogisticsDocument(id,documentType,user.userId(),user.username(),key,version,reason,file));}
@GetMapping("/{id}/documents/{documentType}/{fileId}/download") @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
public ResponseEntity<byte[]> downloadDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType,@PathVariable @Positive long fileId){FirstlegScopeGuard.platform(user);service.requireOverseas(id);var d=loadingLists.downloadContainerDocument(id,documentType,fileId);return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header(HttpHeaders.CONTENT_DISPOSITION,org.springframework.http.ContentDisposition.attachment().filename(d.fileName(),StandardCharsets.UTF_8).build().toString()).contentType(d.contentType()).contentLength(d.content().length).body(d.content());}
@PostMapping @PreAuthorize("hasAuthority('platform:firstleg:container:create')")
@BusinessAudit(event="CONTAINER_CREATE", resourceType="CONTAINER", description="新增货柜基础资料", captureBefore=false)
public ApiResponse<ContainerBookingEntity> create(@AuthenticationPrincipal AuthenticatedUser user,
@@ -104,21 +136,23 @@ public class PlatformContainerController {
return ApiResponse.success(service.updateDraft(id, user.userId(), user.username(), request));
}
@GetMapping("/{id}/clearance")
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
@Operation(summary = "查询货柜目的港清关资料")
public ApiResponse<BusinessDocument> clearance(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id) {
FirstlegScopeGuard.platform(user);
service.requireOverseas(id);
return ApiResponse.success(loadingLists.containerClearance(id));
}
@GetMapping("/{id}/clearance/document/{fileId}/download")
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
@Operation(summary = "下载货柜目的港清关附件", description = "校验当前清关附件和 File 业务归属,完成态仍可下载。")
public ResponseEntity<byte[]> downloadClearance(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id,
@PathVariable @Positive long fileId) {
FirstlegScopeGuard.platform(user);
service.requireOverseas(id);
var download = loadingLists.downloadContainerClearance(id, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.attachment()
@@ -128,19 +162,20 @@ public class PlatformContainerController {
}
@PostMapping("/{id}/clearance/document")
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:document:edit')")
@Operation(summary = "上传或替换货柜目的港清关资料")
public ApiResponse<BusinessDocument> attachClearance(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody DocumentAttachRequest request) {
FirstlegScopeGuard.platform(user);
service.requireOverseas(id);
return ApiResponse.success(loadingLists.attachContainerClearance(
id, user.userId(), user.username(), key, request));
}
@PostMapping(value = "/{id}/clearance/document/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:document:edit')")
@Operation(summary = "直接上传或替换货柜目的港清关资料")
public ApiResponse<BusinessDocument> uploadClearance(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id,
@@ -149,18 +184,20 @@ public class PlatformContainerController {
@RequestParam("expectedVersion") @Min(0) int expectedVersion,
@RequestParam(value = "operationReason", required = false) @Size(max = 500) String operationReason) {
FirstlegScopeGuard.platform(user);
service.requireOverseas(id);
return ApiResponse.success(loadingLists.uploadContainerClearance(
id, user.userId(), user.username(), key, expectedVersion, operationReason, file));
}
@PostMapping("/{id}/clearance/complete")
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:document:edit')")
@Operation(summary = "确认货柜目的港清关完成")
public ApiResponse<BusinessDocument> completeClearance(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody DocumentCompleteRequest request) {
FirstlegScopeGuard.platform(user);
service.requireOverseas(id);
return ApiResponse.success(loadingLists.completeContainerClearance(
id, user.userId(), user.username(), key, request));
}
@@ -19,6 +19,8 @@ import java.time.LocalDateTime;
public final class ContainerDtos {
private ContainerDtos() {}
public enum WorkbenchScope { ACTIVE, HISTORY }
@Schema(name = "ContainerQuery", description = "货柜分页筛选条件")
public record Query(
@Size(max = 64) String containerNo,
@@ -35,6 +37,7 @@ public final class ContainerDtos {
SmartLoadingStatus status,
LocalDateTime estimatedSailingFrom,
LocalDateTime estimatedSailingTo,
@NotNull WorkbenchScope scope,
@Min(1) long page,
@Min(1) @Max(200) long size
) {
@@ -4,6 +4,7 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Size;
import jakarta.validation.constraints.Pattern;
import java.time.LocalDateTime;
import java.util.List;
@@ -21,7 +22,8 @@ public final class ContainerOverviewDtos {
LocalDateTime estimatedWarehouseArrivalAt, LocalDateTime actualWarehouseArrivalAt,
LocalDateTime unloadingCompletedAt, LocalDateTime unloadingPhotoUploadedAt,
LocalDateTime estimatedEmptyReportedAt, LocalDateTime actualEmptyReportedAt,
Long emptyReportSourceVersion,
Long emptyReportSourceVersion, String pickupCode, Long pickupCodeUpdatedBy,
String pickupCodeUpdatedByName, LocalDateTime pickupCodeUpdatedAt,
Integer version) {}
public record PdaSummary(Long taskId, Long loadingPlanId, Long containerBookingId, String taskStatus,
String workflowStatus, Integer taskVersion, Integer itemCount, Integer completedItemCount, Integer unresolvedItemCount,
@@ -35,4 +37,6 @@ public final class ContainerOverviewDtos {
public record ExceptionHistory(Long id, String action, String reason, Long operatorId, String operatorName, LocalDateTime occurredAt) {}
public record TimelineEntry(String eventType, String detail, Long operatorId, String operatorName, LocalDateTime occurredAt) {}
public record ExceptionRequest(@NotNull @Min(0) Integer expectedVersion, @NotBlank @Size(max=500) String reason) {}
public record PickupCodeRequest(@NotBlank @Size(max=64) @Pattern(regexp="^[0-9]+$", message="PU码只能包含数字") String pickupCode,
@NotNull @Min(0) Integer expectedVersion) {}
}
@@ -60,6 +60,10 @@ public class ContainerBookingEntity {
private LocalDateTime estimatedEmptyReportedAt;
private LocalDateTime actualEmptyReportedAt;
private Long emptyReportSourceVersion;
private String pickupCode;
private Long pickupCodeUpdatedBy;
private String pickupCodeUpdatedByName;
private LocalDateTime pickupCodeUpdatedAt;
private Long createdBy;
private String createdByName;
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
@@ -76,5 +80,6 @@ public class ContainerBookingEntity {
@TableField(exist = false) private String destinationClearanceDocumentStatus;
@TableField(exist = false) private String customsDeclarationDocumentStatus;
@TableField(exist = false) private String deliveryOrderDocumentStatus;
@TableField(exist = false) private String pickupOrderDocumentStatus;
@TableField(exist = false) private String outboundDispatchStatus;
@TableField(exist = false) private LocalDateTime outboundConfirmedAt;
}
@@ -40,8 +40,14 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
COALESCE((SELECT c.status FROM fl_customs_container c WHERE c.container_booking_id=b.id LIMIT 1),'MISSING') AS customs_declaration_document_status,
COALESCE((SELECT 'UPLOADED' FROM fl_container_document d WHERE d.container_booking_id=b.id
AND d.document_type='DELIVERY_ORDER' LIMIT 1),'MISSING') AS delivery_order_document_status,
COALESCE((SELECT 'UPLOADED' FROM fl_container_document d WHERE d.container_booking_id=b.id
AND d.document_type='PICKUP_ORDER' LIMIT 1),'MISSING') AS pickup_order_document_status,
COALESCE((SELECT CASE WHEN MIN(d.confirmed_at) IS NOT NULL
AND MAX(d.reversed_at) IS NULL THEN 'DEPARTED'
ELSE MIN(d.status) END
FROM fl_outbound_dispatch d WHERE d.container_booking_id=b.id),'NOT_CREATED')
AS outbound_dispatch_status,
(SELECT MIN(d.confirmed_at) FROM fl_outbound_dispatch d
WHERE d.container_booking_id=b.id AND d.confirmed_at IS NOT NULL
AND d.reversed_at IS NULL AND d.status='WAITING_FOR_LOADING') AS outbound_confirmed_at,
CASE WHEN p.id IS NULL THEN 'WAIT_LOADING'
WHEN p.plan_status != 'COMPLETED' AND NOT EXISTS (
SELECT 1 FROM fl_loading_plan_item active_item
@@ -78,6 +84,24 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
ELSE p.plan_status END = #{query.status})
AND (#{query.estimatedSailingFrom} IS NULL OR b.estimated_sailing_at &gt;= #{query.estimatedSailingFrom})
AND (#{query.estimatedSailingTo} IS NULL OR b.estimated_sailing_at &lt;= #{query.estimatedSailingTo})
AND ((#{workbench}='DOMESTIC' AND (
(#{query.scope}='ACTIVE' AND NOT EXISTS (
SELECT 1 FROM fl_outbound_dispatch departure
WHERE departure.container_booking_id=b.id
AND departure.confirmed_at IS NOT NULL AND departure.reversed_at IS NULL
AND departure.status='WAITING_FOR_LOADING'))
OR (#{query.scope}='HISTORY' AND EXISTS (
SELECT 1 FROM fl_outbound_dispatch departure
WHERE departure.container_booking_id=b.id
AND departure.confirmed_at IS NOT NULL AND departure.reversed_at IS NULL
AND departure.status='WAITING_FOR_LOADING'))))
OR (#{workbench}='OVERSEAS' AND EXISTS (
SELECT 1 FROM fl_outbound_dispatch departure
WHERE departure.container_booking_id=b.id
AND departure.confirmed_at IS NOT NULL AND departure.reversed_at IS NULL
AND departure.status='WAITING_FOR_LOADING')
AND ((#{query.scope}='ACTIVE' AND b.actual_empty_reported_at IS NULL)
OR (#{query.scope}='HISTORY' AND b.actual_empty_reported_at IS NOT NULL))))
AND (#{query.keyword} IS NULL OR b.container_no LIKE CONCAT('%', #{query.keyword}, '%')
OR b.cabinet_no LIKE CONCAT('%', #{query.keyword}, '%')
OR b.bill_of_lading_no LIKE CONCAT('%', #{query.keyword}, '%')
@@ -86,7 +110,9 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
ORDER BY b.updated_at DESC, b.id DESC
</script>
""")
Page<ContainerBookingEntity> selectBookingPage(Page<ContainerBookingEntity> page, @Param("query") Query query);
Page<ContainerBookingEntity> selectBookingPage(Page<ContainerBookingEntity> page,
@Param("query") Query query,
@Param("workbench") String workbench);
@Select("""
SELECT b.*, p.id AS plan_id, p.plan_no AS plan_no,
@@ -100,8 +126,14 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
COALESCE((SELECT c.status FROM fl_customs_container c WHERE c.container_booking_id=b.id LIMIT 1),'MISSING') AS customs_declaration_document_status,
COALESCE((SELECT 'UPLOADED' FROM fl_container_document d WHERE d.container_booking_id=b.id
AND d.document_type='DELIVERY_ORDER' LIMIT 1),'MISSING') AS delivery_order_document_status,
COALESCE((SELECT 'UPLOADED' FROM fl_container_document d WHERE d.container_booking_id=b.id
AND d.document_type='PICKUP_ORDER' LIMIT 1),'MISSING') AS pickup_order_document_status,
COALESCE((SELECT CASE WHEN MIN(d.confirmed_at) IS NOT NULL
AND MAX(d.reversed_at) IS NULL THEN 'DEPARTED'
ELSE MIN(d.status) END
FROM fl_outbound_dispatch d WHERE d.container_booking_id=b.id),'NOT_CREATED')
AS outbound_dispatch_status,
(SELECT MIN(d.confirmed_at) FROM fl_outbound_dispatch d
WHERE d.container_booking_id=b.id AND d.confirmed_at IS NOT NULL
AND d.reversed_at IS NULL AND d.status='WAITING_FOR_LOADING') AS outbound_confirmed_at,
CASE WHEN p.id IS NULL THEN 'WAIT_LOADING'
WHEN p.plan_status != 'COMPLETED' AND NOT EXISTS (
SELECT 1 FROM fl_loading_plan_item active_item
@@ -119,6 +151,23 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
@Select("SELECT * FROM fl_container_booking WHERE id=#{id} AND record_status='ACTIVE' FOR UPDATE")
ContainerBookingEntity selectActiveBookingForUpdate(@Param("id") long id);
@Select("""
SELECT COUNT(1) FROM fl_outbound_dispatch
WHERE container_booking_id=#{id} AND confirmed_at IS NOT NULL AND reversed_at IS NULL
AND status='WAITING_FOR_LOADING'
""")
int hasActiveOutboundDeparture(@Param("id") long id);
@Update("""
UPDATE fl_container_booking SET pickup_code=#{pickupCode},pickup_code_updated_by=#{operatorId},
pickup_code_updated_by_name=#{operatorName},pickup_code_updated_at=CURRENT_TIMESTAMP(3),
updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{id} AND record_status='ACTIVE' AND version=#{version}
""")
int updatePickupCode(@Param("id") long id, @Param("pickupCode") String pickupCode,
@Param("version") int version, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Select("SELECT * FROM fl_container_booking WHERE container_no=#{containerNo} AND record_status='ACTIVE'")
ContainerBookingEntity selectActiveByContainerNo(@Param("containerNo") String containerNo);
@@ -3,7 +3,7 @@ import com.baiyue.firstleg.entity.ContainerDocumentEntity;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper public interface ContainerOverviewMapper {
@Select("SELECT * FROM fl_container_document WHERE container_booking_id=#{id} AND document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER','PICKUP_ORDER') ORDER BY document_type") List<ContainerDocumentEntity> documents(@Param("id") long id);
@Select("SELECT * FROM fl_container_document WHERE container_booking_id=#{id} AND document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER') ORDER BY document_type") List<ContainerDocumentEntity> documents(@Param("id") long id);
@Select("""
SELECT history.id,plan.container_booking_id containerBookingId,
JSON_UNQUOTE(JSON_EXTRACT(history.before_snapshot,'$.containerNo')) beforeContainerNo,
@@ -5,6 +5,7 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest;
@@ -19,6 +20,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
@@ -28,6 +30,7 @@ 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;
@@ -47,6 +50,17 @@ public class PlatformCarrierScheduleQueryController {
return ApiResponse.success(service.carriers());
}
@Operation(summary = "按货柜批量查询实际船节点摘要",
description = "最多200柜;仅返回实际节点时间,不返回或推断预计时间;没有船节点任务的货柜不返回行。")
@GetMapping("/container-actual-facts")
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:list')")
public ApiResponse<List<ContainerActualFacts>> containerActualFacts(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam("containerBookingIds") @Size(min = 1, max = 200) List<@Positive Long> containerBookingIds) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.containerActualFacts(containerBookingIds));
}
@GetMapping
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:list')")
public ApiResponse<PageResult<QueryView>> page(@AuthenticationPrincipal AuthenticatedUser user,
@@ -80,6 +80,17 @@ public final class CarrierScheduleQueryDtos {
public record QueryView(Task query, boolean expired) {}
/** Actual-only summary used by container workbenches; estimated facts are intentionally excluded. */
public record ContainerActualFacts(Long containerBookingId, Long queryId, Integer queryVersion,
String queryStatus, String latestActualNode,
LocalDateTime latestActualAt,
LocalDateTime departedWarehouseAt,
LocalDateTime departedOriginPortAt,
LocalDateTime arrivedDestinationPortAt,
LocalDateTime pickupReadyAt,
LocalDateTime pickedUpAt,
LocalDateTime emptyReturnedAt) {}
public record NodeFact(Long id, String providerCode, String providerEventId,
String providerNodeCode, String standardNode,
int nodeSequence, LocalDateTime observedAt, LocalDateTime estimatedAt,
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.portschedule.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@@ -46,6 +47,33 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
@Param("oceanBookingId") long oceanBookingId,
@Param("containerBookingId") long containerBookingId);
@Select("""
<script>
SELECT q.container_booking_id AS containerBookingId,q.id AS queryId,q.version AS queryVersion,
q.status AS queryStatus,q.latest_standard_node AS latestActualNode,
q.latest_node_observed_at AS latestActualAt,
MAX(CASE WHEN f.standard_node='DEPARTED_WAREHOUSE' THEN f.actual_at END) AS departedWarehouseAt,
MAX(CASE WHEN f.standard_node='DEPARTED_ORIGIN_PORT' THEN f.actual_at END) AS departedOriginPortAt,
MAX(CASE WHEN f.standard_node='ARRIVED_DESTINATION_PORT' THEN f.actual_at END) AS arrivedDestinationPortAt,
MAX(CASE WHEN f.standard_node='PICKUP_READY' THEN f.actual_at END) AS pickupReadyAt,
MAX(CASE WHEN f.standard_node='PICKED_UP' THEN f.actual_at END) AS pickedUpAt,
MAX(CASE WHEN f.standard_node='EMPTY_RETURNED' THEN f.actual_at END) AS emptyReturnedAt
FROM fl_carrier_schedule_query q
JOIN (
SELECT container_booking_id,MAX(id) AS query_id
FROM fl_carrier_schedule_query
WHERE container_booking_id IN
<foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach>
GROUP BY container_booking_id
) latest ON latest.query_id=q.id
LEFT JOIN fl_carrier_schedule_node_fact f ON f.query_id=q.id AND f.actual_at IS NOT NULL
GROUP BY q.container_booking_id,q.id,q.version,q.status,q.latest_standard_node,q.latest_node_observed_at
ORDER BY q.container_booking_id
</script>
""")
List<ContainerActualFacts> selectContainerActualFacts(
@Param("containerBookingIds") List<Long> containerBookingIds);
@Select("""
SELECT * FROM fl_carrier_schedule_query
WHERE status IN ('READY','MANUAL_REQUIRED')
@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity;
@@ -40,6 +41,9 @@ public class CarrierScheduleQueryRepository {
public CarrierScheduleQueryEntity findByBusinessScope(long customerId, long oceanBookingId, long containerBookingId) {
return queries.findByBusinessScope(customerId, oceanBookingId, containerBookingId);
}
public List<ContainerActualFacts> containerActualFacts(List<Long> containerBookingIds) {
return queries.selectContainerActualFacts(containerBookingIds);
}
public List<CarrierScheduleQueryEntity> due(LocalDateTime now, int limit) { return queries.selectDue(now, limit); }
public boolean claimLease(long id, String owner, LocalDateTime now, LocalDateTime leaseUntil) {
return queries.claimLease(id, owner, now, leaseUntil) == 1;
@@ -7,6 +7,7 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.event.ContainerNumbers;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest;
@@ -48,6 +49,7 @@ import java.time.Clock;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.HexFormat;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
@@ -76,6 +78,18 @@ public class CarrierScheduleQueryService {
source.page(), source.size(), source.total(), source.pages());
}
public List<ContainerActualFacts> containerActualFacts(List<Long> containerBookingIds) {
if (containerBookingIds == null || containerBookingIds.isEmpty() || containerBookingIds.size() > 200) {
throw badRequest("containerBookingIds数量必须在1到200之间");
}
Set<Long> unique = new HashSet<>();
for (Long id : containerBookingIds) {
if (id == null || id <= 0) throw badRequest("containerBookingIds必须全部为正数");
if (!unique.add(id)) throw badRequest("containerBookingIds不能包含重复值");
}
return repository.containerActualFacts(containerBookingIds);
}
public Detail detail(long id) { return detail(required(id)); }
public List<History> history(long id) {
@@ -12,15 +12,16 @@ import org.springframework.stereotype.Repository;
@Repository
public class ContainerRepository {
@Resource private ContainerBookingMapper mapper;
public PageResult<ContainerBookingEntity> page(Query query) {
public PageResult<ContainerBookingEntity> page(String workbench, Query query) {
Page<ContainerBookingEntity> result = mapper.selectBookingPage(
new Page<>(query.page(), query.size()), query);
new Page<>(query.page(), query.size()), query, workbench);
result.getRecords().forEach(this::resolveLoadingTime);
return PageResult.from(result);
}
public ContainerBookingEntity find(long id) { return resolveLoadingTime(mapper.selectBooking(id)); }
public ContainerBookingEntity findForUpdate(long id) { return mapper.selectBookingForUpdate(id); }
public ContainerBookingEntity findActiveForUpdate(long id) { return mapper.selectActiveBookingForUpdate(id); }
public boolean departed(long id) { return mapper.hasActiveOutboundDeparture(id) > 0; }
public boolean containerNoExists(String no, Long excludedId) {
if (no == null) return false;
return mapper.selectCount(new LambdaQueryWrapper<ContainerBookingEntity>()
@@ -45,6 +46,9 @@ public class ContainerRepository {
public int updateDraft(ContainerBookingEntity value, int version, long operatorId, String operatorName) {
return mapper.updateDraft(value, version, operatorId, operatorName);
}
public int updatePickupCode(long id, String pickupCode, int version, long operatorId, String operatorName) {
return mapper.updatePickupCode(id, pickupCode, version, operatorId, operatorName);
}
public boolean hasLoadingPlan(long id) { return mapper.hasLoadingPlan(id) > 0; }
public boolean hasActiveLoadingItems(long id) { return mapper.hasActiveLoadingItems(id) > 0; }
public boolean hasPickTask(long id) { return mapper.hasPickTask(id) > 0; }
@@ -51,7 +51,9 @@ public class ContainerOverviewService {
booking.getActualPortArrivalAt(), booking.getEstimatedWarehouseArrivalAt(),
booking.getActualWarehouseArrivalAt(), booking.getUnloadingCompletedAt(),
booking.getUnloadingPhotoUploadedAt(), booking.getEstimatedEmptyReportedAt(),
booking.getActualEmptyReportedAt(), booking.getEmptyReportSourceVersion(), booking.getVersion()),
booking.getActualEmptyReportedAt(), booking.getEmptyReportSourceVersion(), booking.getPickupCode(),
booking.getPickupCodeUpdatedBy(), booking.getPickupCodeUpdatedByName(), booking.getPickupCodeUpdatedAt(),
booking.getVersion()),
task == null ? booking.getPlanId() : task.loadingPlanId(), pda, documents,
new ExceptionState(latestException != null && "MARK".equals(latestException.action()),
latestException == null ? null : latestException.reason(),
@@ -72,6 +74,18 @@ public class ContainerOverviewService {
changeException(id, operatorId, operatorName, idempotencyKey, request, "CANCEL");
}
@Transactional
public Overview updatePickupCode(long id, long operatorId, String operatorName, PickupCodeRequest request) {
ContainerBookingEntity booking = containers.findActiveForUpdate(id);
if (booking == null) throw new BizException(ResultCode.NOT_FOUND, "货柜不存在");
if (!request.expectedVersion().equals(booking.getVersion())) throw conflict("货柜版本已变化,请刷新后重试");
String pickupCode = request.pickupCode().trim();
if (containers.updatePickupCode(id, pickupCode, request.expectedVersion(), operatorId, operatorName) != 1) {
throw conflict("货柜版本已变化,请刷新后重试");
}
return overview(id);
}
private void changeException(long id, long operatorId, String operatorName, String rawKey,
ExceptionRequest request, String action) {
String operationId = operationId(rawKey);
@@ -1,7 +1,6 @@
package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
@@ -9,7 +8,6 @@ import com.baiyue.firstleg.client.WarehouseMasterDataClient;
import com.baiyue.firstleg.domain.SmartLoadingStatus;
import com.baiyue.firstleg.domain.ContainerSpecificationCatalog;
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.Query;
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest;
@@ -41,11 +39,15 @@ public class ContainerService {
@Resource private SmartLoadingService smartLoadingService;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
public PageResult<ContainerBookingEntity> page(Query query) {
validateRange(query.estimatedSailingFrom(), query.estimatedSailingTo(), "预计船期筛选");
return repository.page(normalize(query));
public void requireOverseas(long id) {
required(id);
if (!repository.departed(id)) throw new BizException(ResultCode.NOT_FOUND, "国外工作台货柜不存在");
}
public void requireDomestic(long id) {
required(id);
if (repository.departed(id)) throw new BizException(ResultCode.NOT_FOUND, "国内工作台货柜不存在");
}
public ContainerBookingEntity detail(long id) { return required(id); }
@Transactional
public ContainerBookingEntity createSimulation(long operatorId, String operatorName, SimulationCreateRequest request) {
@@ -220,12 +222,6 @@ public class ContainerService {
if (from != null && to != null && to.isBefore(from))
throw new BizException(ResultCode.VALIDATION_FAILED, label + "先后顺序不正确");
}
private static Query normalize(Query q) {
return new Query(trim(q.containerNo()), trim(q.cabinetNo()), trim(q.billOfLadingNo()), trim(q.keyword()),
q.customerId(), q.warehouseId(), trim(q.containerType()), q.shippingCompanyId(), trim(q.departurePortCode()),
trim(q.destinationPortCode()), trim(q.destinationWarehouseCode()), q.status(), q.estimatedSailingFrom(),
q.estimatedSailingTo(), q.page(), q.size());
}
private static String trim(String value) {
if (value == null) return null;
String result = value.trim(); return result.isEmpty() ? null : result;
@@ -56,10 +56,8 @@ public class LoadingListService {
private static final Set<String> PRELOAD_STATUSES = Set.of("EDITING", "LOADING");
public static final String DESTINATION_CLEARANCE = "DESTINATION_CLEARANCE";
public static final String DELIVERY_ORDER = "DELIVERY_ORDER";
public static final String PICKUP_ORDER = "PICKUP_ORDER";
private static final String CLEARANCE_FILE_TYPE = "CONTAINER_CLEARANCE_DOCUMENT";
private static final String DO_FILE_TYPE = "CONTAINER_DELIVERY_ORDER";
private static final String PU_FILE_TYPE = "CONTAINER_PICKUP_ORDER";
@Resource
private LoadingListRepository repository;
@Resource
@@ -190,8 +188,8 @@ public class LoadingListService {
}
}
public record DocumentDownload(String fileName,MediaType contentType,byte[] content) {}
private static void requireLogisticsType(String type){ if(!DELIVERY_ORDER.equals(type)&&!PICKUP_ORDER.equals(type)) throw badRequest("documentType只能是DELIVERY_ORDER或PICKUP_ORDER"); }
private static String fileType(String type){ return switch(type){case DELIVERY_ORDER->DO_FILE_TYPE; case PICKUP_ORDER->PU_FILE_TYPE; default->throw badRequest("documentType只能是DELIVERY_ORDER或PICKUP_ORDER");}; }
private static void requireLogisticsType(String type){ if(!DELIVERY_ORDER.equals(type)) throw badRequest("documentType只能是DELIVERY_ORDER"); }
private static String fileType(String type){ if (DELIVERY_ORDER.equals(type)) return DO_FILE_TYPE; throw badRequest("documentType只能是DELIVERY_ORDER"); }
@Transactional
@BusinessAudit(event = "CONTAINER_CLEARANCE_DOCUMENT_ATTACH", resourceType = "CONTAINER",
@@ -63,8 +63,9 @@ public class OverseasContainerEventConsumer {
ContainerBookingEntity container = requiredContainer(payload.sourceContainerBookingId(), payload.containerNo());
LocalDateTime uploadedAt = utc(payload.unloadingPhotoUploadedAt());
containers.applyEmptyReport(container.getId(), utc(payload.unloadingCompletedAt()), uploadedAt,
uploadedAt.plusDays(2), utc(payload.actualEmptyReportedAt()), event.aggregateVersion());
LocalDateTime unloadingCompletedAt = utc(payload.unloadingCompletedAt());
containers.applyEmptyReport(container.getId(), unloadingCompletedAt, uploadedAt,
unloadingCompletedAt.plusHours(72), utc(payload.actualEmptyReportedAt()), event.aggregateVersion());
row.setStatus("PROCESSED");
row.setProcessedAt(LocalDateTime.now(ZoneOffset.UTC));
inbox.updateById(row);
@@ -39,24 +39,24 @@ public class PlatformTerminalDrayageController {
private final TerminalDrayageService service;
@GetMapping
@PreAuthorize("hasAuthority('platform:firstleg:container:list')")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage: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));
return ApiResponse.success(service.authorize(service.views(containerBookingIds), user.permissions()));
}
@GetMapping("/{containerBookingId}")
@PreAuthorize("hasAuthority('platform:firstleg:container:list')")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
public ApiResponse<View> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long containerBookingId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.detail(containerBookingId));
return ApiResponse.success(service.authorize(service.detail(containerBookingId), user.permissions()));
}
@GetMapping("/{containerBookingId}/history")
@PreAuthorize("hasAuthority('platform:firstleg:container:list')")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
public ApiResponse<List<History>> history(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long containerBookingId) {
FirstlegScopeGuard.platform(user);
@@ -64,7 +64,7 @@ public class PlatformTerminalDrayageController {
}
@PostMapping("/{containerBookingId}/fleet")
@PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:fleet:edit')")
@Operation(summary = "选择或更换目的港码头车队")
@BusinessAudit(event = "TERMINAL_DRAYAGE_SELECT_FLEET", resourceType = "CONTAINER",
resourceId = "#containerBookingId", description = "选择目的港码头车队")
@@ -73,11 +73,12 @@ public class PlatformTerminalDrayageController {
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody FleetSelectionRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.selectFleet(containerBookingId, user.userId(), user.username(), key, request));
return ApiResponse.success(service.authorize(service.selectFleet(
containerBookingId, user.userId(), user.username(), key, request), user.permissions()));
}
@PostMapping("/{containerBookingId}/pickup-appointment")
@PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:pickup-appointment:edit')")
@Operation(summary = "预约目的港提柜时间")
@BusinessAudit(event = "TERMINAL_DRAYAGE_SCHEDULE_PICKUP", resourceType = "CONTAINER",
resourceId = "#containerBookingId", description = "预约目的港提柜")
@@ -86,11 +87,12 @@ public class PlatformTerminalDrayageController {
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody PickupAppointmentRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.schedulePickup(containerBookingId, user.userId(), user.username(), key, request));
return ApiResponse.success(service.authorize(service.schedulePickup(
containerBookingId, user.userId(), user.username(), key, request), user.permissions()));
}
@PostMapping("/{containerBookingId}/pickup")
@PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:pickup:confirm')")
@Operation(summary = "确认目的港实际提柜")
@BusinessAudit(event = "TERMINAL_DRAYAGE_RECORD_PICKUP", resourceType = "CONTAINER",
resourceId = "#containerBookingId", description = "确认目的港实际提柜")
@@ -99,11 +101,12 @@ public class PlatformTerminalDrayageController {
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ActualTimeRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.recordPickup(containerBookingId, user.userId(), user.username(), key, request));
return ApiResponse.success(service.authorize(service.recordPickup(
containerBookingId, user.userId(), user.username(), key, request), user.permissions()));
}
@PostMapping("/{containerBookingId}/empty-return")
@PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:empty-return:confirm')")
@Operation(summary = "确认实际还空柜")
@BusinessAudit(event = "TERMINAL_DRAYAGE_RECORD_EMPTY_RETURN", resourceType = "CONTAINER",
resourceId = "#containerBookingId", description = "确认实际还空柜")
@@ -112,6 +115,7 @@ public class PlatformTerminalDrayageController {
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ActualTimeRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.recordEmptyReturn(containerBookingId, user.userId(), user.username(), key, request));
return ApiResponse.success(service.authorize(service.recordEmptyReturn(
containerBookingId, user.userId(), user.username(), key, request), user.permissions()));
}
}
@@ -0,0 +1,26 @@
package com.baiyue.firstleg.terminaldrayage.dto;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime;
import java.util.List;
/** 国内、国外货柜工作台共享只读模型。 */
public final class ContainerWorkbenchDtos {
private ContainerWorkbenchDtos() {}
@Schema(name = "ContainerWorkbenchStatusSummary", description = "柜级各履约域只读状态摘要")
public record StatusSummary(String loadingStatus, String customsStatus, String outboundStatus,
String destinationClearanceStatus, String deliveryOrderStatus,
String pickupCodeStatus, String latestActualNode,
LocalDateTime latestActualAt, String terminalDrayageStatus,
String emptyReturnWarningStatus) {}
@Schema(name = "ContainerWorkbenchItem", description = "同一containerBookingId的国内或国外工作台读模型")
public record WorkbenchItem(ContainerBookingEntity container, String workbenchStage,
StatusSummary statusSummary, List<String> allowedActions,
ContainerActualFacts actualFacts, View terminalDrayage) {}
}
@@ -7,6 +7,7 @@ import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
public final class TerminalDrayageDtos {
private TerminalDrayageDtos() {}
@@ -33,7 +34,8 @@ public final class TerminalDrayageDtos {
LocalDateTime pickupAppointmentAt, LocalDateTime actualPickupAt,
LocalDateTime estimatedEmptyReturnAt, LocalDateTime actualEmptyReturnAt,
LocalDateTime unloadingCompletedAt, Warning emptyReturnWarning,
List<String> allowedActions, LocalDateTime updatedAt) {}
List<String> allowedActions, ContainerActualFacts actualFacts,
LocalDateTime updatedAt) {}
public record History(String action, String fromStatus, String toStatus,
Integer fromVersion, Integer toVersion, String reason,
@@ -16,38 +16,16 @@ import java.util.List;
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
b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt
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
b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt
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);
@@ -55,19 +33,8 @@ public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity>
@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
b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt
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
@@ -141,8 +108,6 @@ public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity>
@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) {}
LocalDateTime unloadingCompletedAt) {}
record Operation(String operation, String requestHash, Integer resultVersion) {}
}
@@ -0,0 +1,165 @@
package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.ContainerDtos.Query;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
import com.baiyue.firstleg.repository.ContainerRepository;
import com.baiyue.firstleg.terminaldrayage.dto.ContainerWorkbenchDtos.StatusSummary;
import com.baiyue.firstleg.terminaldrayage.dto.ContainerWorkbenchDtos.WorkbenchItem;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** 基于同一货柜主档聚合国内、国外工作台读模型。 */
@Service
public class ContainerWorkbenchService {
@Resource private ContainerRepository repository;
@Resource private CarrierScheduleQueryService carrierSchedules;
@Resource private TerminalDrayageService terminalDrayages;
public PageResult<WorkbenchItem> domesticPage(Query query, Set<String> permissions) {
return page("DOMESTIC", query, permissions);
}
public PageResult<WorkbenchItem> overseasPage(Query query, Set<String> permissions) {
return page("OVERSEAS", query, permissions);
}
public WorkbenchItem domesticDetail(long id, Set<String> permissions) {
ContainerBookingEntity value = required(id);
if (repository.departed(id)) throw new BizException(ResultCode.NOT_FOUND, "国内工作台货柜不存在");
ContainerActualFacts facts = actualFacts(List.of(id)).get(id);
return item("DOMESTIC", value, facts, null, permissions);
}
public WorkbenchItem overseasDetail(long id, Set<String> permissions) {
ContainerBookingEntity value = required(id);
if (!repository.departed(id)) throw new BizException(ResultCode.NOT_FOUND, "国外工作台货柜不存在");
View drayage = terminalDrayages.authorize(terminalDrayages.detail(id), permissions);
return item("OVERSEAS", value, drayage.actualFacts(), drayage, permissions);
}
private PageResult<WorkbenchItem> page(String workbench, Query query, Set<String> permissions) {
validateRange(query.estimatedSailingFrom(), query.estimatedSailingTo(), "预计船期筛选");
Query normalized = normalize(query);
PageResult<ContainerBookingEntity> source = repository.page(workbench, normalized);
List<Long> ids = source.records().stream().map(ContainerBookingEntity::getId).toList();
Map<Long, ContainerActualFacts> facts = new HashMap<>();
Map<Long, View> drayages = new HashMap<>();
if (!ids.isEmpty() && "OVERSEAS".equals(workbench)) {
terminalDrayages.authorize(terminalDrayages.views(ids), permissions).forEach(value -> {
drayages.put(value.containerBookingId(), value);
facts.put(value.containerBookingId(), value.actualFacts());
});
} else if (!ids.isEmpty()) {
facts.putAll(actualFacts(ids));
}
List<WorkbenchItem> records = source.records().stream()
.map(value -> item(workbench, value, facts.get(value.getId()), drayages.get(value.getId()), permissions))
.toList();
return new PageResult<>(records, source.page(), source.size(), source.total(), source.pages());
}
private ContainerBookingEntity required(long id) {
ContainerBookingEntity value = repository.find(id);
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "货柜不存在");
return value;
}
private static void validateRange(LocalDateTime from, LocalDateTime to, String label) {
if (from != null && to != null && to.isBefore(from)) {
throw new BizException(ResultCode.VALIDATION_FAILED, label + "先后顺序不正确");
}
}
private static Query normalize(Query query) {
return new Query(trim(query.containerNo()), trim(query.cabinetNo()), trim(query.billOfLadingNo()),
trim(query.keyword()), query.customerId(), query.warehouseId(), trim(query.containerType()),
query.shippingCompanyId(), trim(query.departurePortCode()), trim(query.destinationPortCode()),
trim(query.destinationWarehouseCode()), query.status(), query.estimatedSailingFrom(),
query.estimatedSailingTo(), query.scope(), query.page(), query.size());
}
private Map<Long, ContainerActualFacts> actualFacts(List<Long> ids) {
Map<Long, ContainerActualFacts> result = new HashMap<>();
carrierSchedules.containerActualFacts(ids).forEach(value -> result.put(value.containerBookingId(), value));
return result;
}
private WorkbenchItem item(String workbench, ContainerBookingEntity container, ContainerActualFacts facts,
View drayage, Set<String> permissions) {
String stage = "DOMESTIC".equals(workbench) ? domesticStage(container) : overseasStage(container, facts, drayage);
StatusSummary summary = new StatusSummary(container.getPlanStatus(),
container.getCustomsDeclarationDocumentStatus(), container.getOutboundDispatchStatus(),
container.getDestinationClearanceDocumentStatus(), container.getDeliveryOrderDocumentStatus(),
trim(container.getPickupCode()) == null ? "MISSING" : "UPLOADED",
facts == null ? null : facts.latestActualNode(), facts == null ? null : facts.latestActualAt(),
drayage == null ? null : drayage.status(),
drayage == null || drayage.emptyReturnWarning() == null ? null : drayage.emptyReturnWarning().status());
List<String> actions = "DOMESTIC".equals(workbench)
? domesticActions(container, permissions) : overseasActions(drayage, permissions);
return new WorkbenchItem(container, stage, summary, actions, facts, drayage);
}
private static String domesticStage(ContainerBookingEntity value) {
if (value.getOutboundConfirmedAt() != null) return "DEPARTED_WAREHOUSE";
if (value.getPlanStatus() == null || "WAIT_LOADING".equals(value.getPlanStatus())) return "WAIT_LOADING";
if (!"COMPLETED".equals(value.getPlanStatus())) return "LOADING";
if (!"COMPLETED".equals(value.getCustomsDeclarationDocumentStatus())) return "WAIT_CUSTOMS";
return "WAIT_OUTBOUND";
}
private static String overseasStage(ContainerBookingEntity value, ContainerActualFacts facts, View drayage) {
if (drayage != null && "EMPTY_RETURNED".equals(drayage.status())) return "EMPTY_RETURNED";
if (value.getUnloadingCompletedAt() != null) return "UNLOADED";
if (drayage != null && "PICKED_UP".equals(drayage.status())) return "PICKED_UP";
if (facts != null && facts.pickupReadyAt() != null) return "PICKUP_READY";
if (facts != null && facts.arrivedDestinationPortAt() != null) return "ARRIVED_DESTINATION_PORT";
if (facts != null && facts.departedOriginPortAt() != null) return "IN_TRANSIT";
return "DEPARTED_WAREHOUSE";
}
private static List<String> domesticActions(ContainerBookingEntity value, Set<String> permissions) {
Set<String> granted = permissions == null ? Set.of() : permissions;
List<String> actions = new ArrayList<>();
if (granted.contains("platform:firstleg:container:edit")
&& ("WAIT_LOADING".equals(value.getPlanStatus()) || "EDITING".equals(value.getPlanStatus()))) {
actions.add("EDIT_CONTAINER");
}
if (granted.contains("platform:firstleg:customs:manage") && "COMPLETED".equals(value.getPlanStatus())
&& value.getOutboundConfirmedAt() == null) actions.add("MANAGE_CUSTOMS");
if (granted.contains("platform:firstleg:outbound-dispatch:confirm")
&& "COMPLETED".equals(value.getPlanStatus())
&& "COMPLETED".equals(value.getCustomsDeclarationDocumentStatus())
&& value.getOutboundConfirmedAt() == null) actions.add("CONFIRM_OUTBOUND");
return List.copyOf(actions);
}
private static List<String> overseasActions(View drayage, Set<String> permissions) {
Set<String> granted = permissions == null ? Set.of() : permissions;
List<String> actions = new ArrayList<>();
boolean returned = drayage != null && "EMPTY_RETURNED".equals(drayage.status());
if (!returned && granted.contains("platform:firstleg:terminal-drayage:document:edit")) {
actions.add("MANAGE_DESTINATION_DOCUMENTS");
}
if (drayage != null) actions.addAll(drayage.allowedActions());
return List.copyOf(actions);
}
private static String trim(String value) {
if (value == null) return null;
String result = value.trim();
return result.isEmpty() ? null : result;
}
}
@@ -4,6 +4,7 @@ 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.ContainerActualFacts;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
@@ -35,6 +36,7 @@ import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
@Service
@RequiredArgsConstructor
@@ -56,7 +58,10 @@ public class TerminalDrayageService {
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();
Map<Long, ContainerActualFacts> facts = actualFacts(ids);
return mapper.contexts(ids).stream().map(value -> context(value, facts.get(value.containerBookingId())))
.filter(Objects::nonNull)
.map(value -> view(value, rows.get(value.base().containerBookingId()))).toList();
}
public List<History> histories(long containerBookingId) {
@@ -64,11 +69,25 @@ public class TerminalDrayageService {
return mapper.histories(containerBookingId);
}
public List<View> authorize(List<View> values, Set<String> permissions) {
return values.stream().map(value -> authorize(value, permissions)).toList();
}
public View authorize(View value, Set<String> permissions) {
Set<String> granted = permissions == null ? Set.of() : permissions;
List<String> actions = value.allowedActions().stream()
.filter(action -> granted.contains(permission(action))).toList();
return new View(value.containerBookingId(), value.containerNo(), value.oceanBookingId(), value.status(),
value.version(), value.fleetSupplierId(), value.fleetSupplierName(), value.pickupAppointmentAt(),
value.actualPickupAt(), value.estimatedEmptyReturnAt(), value.actualEmptyReturnAt(),
value.unloadingCompletedAt(), value.emptyReturnWarning(), actions, value.actualFacts(), value.updatedAt());
}
@Transactional
public View selectFleet(long containerBookingId, long operatorId, String operatorName, String key,
FleetSelectionRequest request) {
String action = "SELECT_FLEET";
Context context = requiredLockedContext(containerBookingId);
WorkContext context = requiredLockedContext(containerBookingId);
View replay = replay(context, action, key, request);
if (replay != null) return replay;
requireArrived(context);
@@ -90,7 +109,7 @@ public class TerminalDrayageService {
public View schedulePickup(long containerBookingId, long operatorId, String operatorName, String key,
PickupAppointmentRequest request) {
String action = "SCHEDULE_PICKUP";
Context context = requiredLockedContext(containerBookingId);
WorkContext context = requiredLockedContext(containerBookingId);
View replay = replay(context, action, key, request);
if (replay != null) return replay;
requireArrived(context);
@@ -98,7 +117,8 @@ public class TerminalDrayageService {
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())) {
if (context.facts().arrivedDestinationPortAt() != null
&& request.pickupAppointmentAt().isBefore(context.facts().arrivedDestinationPortAt())) {
throw badRequest("预约提柜时间不能早于实际到港时间");
}
int fromVersion = version(value);
@@ -113,7 +133,7 @@ public class TerminalDrayageService {
public View recordPickup(long containerBookingId, long operatorId, String operatorName, String key,
ActualTimeRequest request) {
String action = "RECORD_PICKUP";
Context context = requiredLockedContext(containerBookingId);
WorkContext context = requiredLockedContext(containerBookingId);
View replay = replay(context, action, key, request);
if (replay != null) return replay;
requireAtLeast(context, CarrierScheduleNode.PICKUP_READY, "货柜尚未清关放行,不允许确认实际提柜");
@@ -123,7 +143,7 @@ public class TerminalDrayageService {
throw conflict("选择码头车队并预约提柜后才能确认实际提柜");
}
if (actualPickupAt(context, value) != null) throw conflict("实际提柜时间已确认");
if (context.latestActualAt() != null && request.actualAt().isBefore(context.latestActualAt())) {
if (context.facts().latestActualAt() != null && request.actualAt().isBefore(context.facts().latestActualAt())) {
throw badRequest("实际提柜时间不能早于当前实际船节点时间");
}
advanceNode(context, CarrierScheduleNode.PICKED_UP, request.actualAt(), action, key,
@@ -141,16 +161,16 @@ public class TerminalDrayageService {
public View recordEmptyReturn(long containerBookingId, long operatorId, String operatorName, String key,
ActualTimeRequest request) {
String action = "RECORD_EMPTY_RETURN";
Context context = requiredLockedContext(containerBookingId);
WorkContext 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 (context.base().unloadingCompletedAt() == null) throw conflict("海外卸柜收货完成后才能确认还柜");
LocalDateTime earliest = pickupAt.isAfter(context.base().unloadingCompletedAt())
? pickupAt : context.base().unloadingCompletedAt();
if (request.actualAt().isBefore(earliest)) throw badRequest("实际还柜时间不能早于提柜或卸柜完成时间");
if (actualEmptyReturnAt(context, value) != null) throw conflict("实际还柜时间已确认");
advanceNode(context, CarrierScheduleNode.EMPTY_RETURNED, request.actualAt(), action, key,
@@ -164,40 +184,40 @@ public class TerminalDrayageService {
fromVersion, request.reason(), operatorId, operatorName);
}
private void advanceNode(Context context, CarrierScheduleNode target, LocalDateTime actualAt,
private void advanceNode(WorkContext context, CarrierScheduleNode target, LocalDateTime actualAt,
String action, String key, String reason, long operatorId, String operatorName) {
if (context.queryId() == null || context.queryVersion() == null) {
if (context.facts().queryId() == null || context.facts().queryVersion() == null) {
throw conflict("货柜尚未建立船节点跟踪任务");
}
CarrierScheduleNode current = node(context.latestActualNode());
CarrierScheduleNode current = node(context.facts().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,
carrierSchedules.addManualNode(context.facts().queryId(), operatorId, operatorName,
"td-" + hash(context.base().containerBookingId() + "|" + action + "|" + key.trim()),
new ManualNodeRequest(context.facts().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,
private View finish(WorkContext 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());
TerminalDrayageEntity stored = mapper.find(context.base().containerBookingId());
WorkContext currentContext = requiredContext(context.base().containerBookingId());
String toStatus = status(currentContext, stored);
mapper.insertHistory(IdWorker.getId(), stored.getId(), context.containerBookingId(), action,
mapper.insertHistory(IdWorker.getId(), stored.getId(), context.base().containerBookingId(), action,
fromStatus, toStatus, fromVersion, version(stored), trim(reason), operatorId, operatorName);
mapper.insertOperation(IdWorker.getId(), context.containerBookingId(), action, key.trim(), hash(request),
mapper.insertOperation(IdWorker.getId(), context.base().containerBookingId(), action, key.trim(), hash(request),
version(stored), operatorId);
return view(currentContext, stored);
}
private View replay(Context context, String action, String key, Object request) {
private View replay(WorkContext 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());
Operation operation = mapper.operation(context.base().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()));
return view(context, mapper.find(context.base().containerBookingId()));
}
private TerminalDrayageEntity create(long containerBookingId, long operatorId, String operatorName) {
@@ -216,16 +236,16 @@ public class TerminalDrayageService {
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 void applyAuthoritativeDeadline(WorkContext context, TerminalDrayageEntity value) {
if (context.base().unloadingCompletedAt() != null) {
value.setEstimatedEmptyReturnAt(context.base().unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS));
}
}
private View view(Context context, TerminalDrayageEntity value) {
private View view(WorkContext context, TerminalDrayageEntity value) {
String status = status(context, value);
LocalDateTime deadline = context.unloadingCompletedAt() == null ? null
: context.unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS);
LocalDateTime deadline = context.base().unloadingCompletedAt() == null ? null
: context.base().unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS);
LocalDateTime returnedAt = actualEmptyReturnAt(context, value);
String warningStatus;
long overdueHours = 0;
@@ -236,18 +256,18 @@ public class TerminalDrayageService {
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,
return new View(context.base().containerBookingId(), context.base().containerNo(), context.base().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),
context.base().unloadingCompletedAt(), warning, allowedActions(context, value), context.facts(),
value == null ? null : value.getUpdatedAt());
}
private List<String> allowedActions(Context context, TerminalDrayageEntity value) {
private List<String> allowedActions(WorkContext context, TerminalDrayageEntity value) {
List<String> result = new ArrayList<>();
CarrierScheduleNode current = node(context.latestActualNode());
CarrierScheduleNode current = node(context.facts().latestActualNode());
if (current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence()
&& current.sequence() < CarrierScheduleNode.PICKED_UP.sequence()
&& actualPickupAt(context, value) == null) {
@@ -257,27 +277,27 @@ public class TerminalDrayageService {
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 (value != null && context.unloadingCompletedAt() != null && actualPickupAt(context, value) != null
if (value != null && context.base().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) {
private String status(WorkContext context, TerminalDrayageEntity value) {
if (actualEmptyReturnAt(context, value) != null) return "EMPTY_RETURNED";
if (context.unloadingCompletedAt() != null && actualPickupAt(context, value) != null) return "UNLOADED";
if (context.base().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());
CarrierScheduleNode current = node(context.facts().latestActualNode());
return current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence()
? "ARRIVED" : "WAITING_FOR_ARRIVAL";
}
private void requireArrived(Context context) {
private void requireArrived(WorkContext context) {
requireAtLeast(context, CarrierScheduleNode.ARRIVED_DESTINATION_PORT, "货柜实际到达目的港后才能安排码头车队");
}
private void requireAtLeast(Context context, CarrierScheduleNode required, String message) {
CarrierScheduleNode current = node(context.latestActualNode());
private void requireAtLeast(WorkContext context, CarrierScheduleNode required, String message) {
CarrierScheduleNode current = node(context.facts().latestActualNode());
if (current == null || current.sequence() < required.sequence()) throw conflict(message);
}
@@ -285,27 +305,45 @@ public class TerminalDrayageService {
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 actualPickupAt(WorkContext context, TerminalDrayageEntity value) {
return value != null && value.getActualPickupAt() != null ? value.getActualPickupAt() : context.facts().pickedUpAt();
}
private LocalDateTime actualEmptyReturnAt(Context context, TerminalDrayageEntity value) {
private LocalDateTime actualEmptyReturnAt(WorkContext context, TerminalDrayageEntity value) {
return value != null && value.getActualEmptyReturnAt() != null
? value.getActualEmptyReturnAt() : context.actualEmptyReturnNodeAt();
? value.getActualEmptyReturnAt() : context.facts().emptyReturnedAt();
}
private Context requiredContext(long id) {
private WorkContext requiredContext(long id) {
Context value = mapper.context(id);
if (value == null || !"ACTIVE".equals(value.recordStatus())) throw notFound();
return value;
WorkContext context = context(value, actualFacts(List.of(id)).get(id));
if (context == null) throw notFound();
return context;
}
private Context requiredLockedContext(long id) {
private WorkContext requiredLockedContext(long id) {
Context value = mapper.lockContext(id);
if (value == null || !"ACTIVE".equals(value.recordStatus())) throw notFound();
return value;
WorkContext context = context(value, actualFacts(List.of(id)).get(id));
if (context == null) throw notFound();
return context;
}
private Map<Long, ContainerActualFacts> actualFacts(List<Long> ids) {
Map<Long, ContainerActualFacts> result = new HashMap<>();
carrierSchedules.containerActualFacts(ids).forEach(value -> result.put(value.containerBookingId(), value));
return result;
}
private WorkContext context(Context base, ContainerActualFacts facts) {
if (base == null || !"ACTIVE".equals(base.recordStatus()) || facts == null
|| facts.departedWarehouseAt() == null) return null;
return new WorkContext(base, facts);
}
private record WorkContext(Context base, ContainerActualFacts facts) {}
private TerminalDrayageEntity requiredAggregate(long id) {
TerminalDrayageEntity value = mapper.lock(id);
if (value == null) throw conflict("请先选择码头车队");
@@ -330,6 +368,15 @@ public class TerminalDrayageService {
}
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static String permission(String action) {
return switch (action) {
case "SELECT_FLEET" -> "platform:firstleg:terminal-drayage:fleet:edit";
case "SCHEDULE_PICKUP" -> "platform:firstleg:terminal-drayage:pickup-appointment:edit";
case "RECORD_PICKUP" -> "platform:firstleg:terminal-drayage:pickup:confirm";
case "RECORD_EMPTY_RETURN" -> "platform:firstleg:terminal-drayage:empty-return:confirm";
default -> "";
};
}
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("码头拖车记录已变化,请刷新后重试"); }