feat(firstleg): split domestic and overseas container workbenches
This commit is contained in:
+59
-22
@@ -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 >= #{query.estimatedSailingFrom})
|
||||
AND (#{query.estimatedSailingTo} IS NULL OR b.estimated_sailing_at <= #{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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
+3
-2
@@ -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);
|
||||
|
||||
+17
-13
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
+26
@@ -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) {}
|
||||
}
|
||||
+3
-1
@@ -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,
|
||||
|
||||
+4
-39
@@ -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) {}
|
||||
}
|
||||
|
||||
+165
@@ -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;
|
||||
}
|
||||
}
|
||||
+96
-49
@@ -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("码头拖车记录已变化,请刷新后重试"); }
|
||||
|
||||
@@ -74,9 +74,13 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
|
||||
actual_warehouse_arrival_at DATETIME(3) NULL COMMENT '实际到仓时间',
|
||||
unloading_completed_at DATETIME(3) NULL COMMENT '海外仓卸柜完成时间(UTC)',
|
||||
unloading_photo_uploaded_at DATETIME(3) NULL COMMENT '海外仓卸柜照片上传成功时间(UTC)',
|
||||
estimated_empty_reported_at DATETIME(3) NULL COMMENT '预计报空时间(卸柜照片上传成功时间加两天,UTC)',
|
||||
estimated_empty_reported_at DATETIME(3) NULL COMMENT '预计还空时间(卸柜完成时间加72小时,UTC)',
|
||||
actual_empty_reported_at DATETIME(3) NULL COMMENT '实际报空时间(UTC)',
|
||||
empty_report_source_version BIGINT UNSIGNED NULL COMMENT '海外货柜事件聚合版本,用于阻止乱序覆盖',
|
||||
pickup_code VARCHAR(64) NULL COMMENT 'PU数字码;按字符串保存以保留前导零',
|
||||
pickup_code_updated_by BIGINT UNSIGNED NULL COMMENT 'PU码最后填写人ID',
|
||||
pickup_code_updated_by_name VARCHAR(64) NULL COMMENT 'PU码最后填写人名称快照',
|
||||
pickup_code_updated_at DATETIME(3) NULL COMMENT 'PU码最后填写时间',
|
||||
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
|
||||
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
@@ -96,6 +100,7 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
|
||||
CONSTRAINT chk_container_booking_record_status CHECK (record_status IN ('ACTIVE','MERGED')),
|
||||
CONSTRAINT chk_container_booking_capacity CHECK (capacity_cbm IS NULL OR capacity_cbm > 0),
|
||||
CONSTRAINT chk_container_booking_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0),
|
||||
CONSTRAINT chk_container_booking_pickup_code CHECK (pickup_code IS NULL OR pickup_code REGEXP '^[0-9]{1,64}$'),
|
||||
CONSTRAINT chk_container_booking_iso6346 CHECK
|
||||
(container_no IS NULL OR container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$')
|
||||
) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划';
|
||||
@@ -162,7 +167,7 @@ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND column_name='estimated_empty_reported_at') THEN
|
||||
ALTER TABLE fl_container_booking ADD COLUMN estimated_empty_reported_at DATETIME(3) NULL
|
||||
COMMENT '预计报空时间(卸柜照片上传成功时间加两天,UTC)' AFTER unloading_photo_uploaded_at;
|
||||
COMMENT '预计还空时间(卸柜完成时间加72小时,UTC)' AFTER unloading_photo_uploaded_at;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND column_name='actual_empty_reported_at') THEN
|
||||
@@ -174,6 +179,26 @@ BEGIN
|
||||
ALTER TABLE fl_container_booking ADD COLUMN empty_report_source_version BIGINT UNSIGNED NULL
|
||||
COMMENT '海外货柜事件聚合版本,用于阻止乱序覆盖' AFTER actual_empty_reported_at;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND column_name='pickup_code') THEN
|
||||
ALTER TABLE fl_container_booking ADD COLUMN pickup_code VARCHAR(64) NULL
|
||||
COMMENT 'PU数字码;按字符串保存以保留前导零' AFTER empty_report_source_version;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND column_name='pickup_code_updated_by') THEN
|
||||
ALTER TABLE fl_container_booking ADD COLUMN pickup_code_updated_by BIGINT UNSIGNED NULL
|
||||
COMMENT 'PU码最后填写人ID' AFTER pickup_code;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND column_name='pickup_code_updated_by_name') THEN
|
||||
ALTER TABLE fl_container_booking ADD COLUMN pickup_code_updated_by_name VARCHAR(64) NULL
|
||||
COMMENT 'PU码最后填写人名称快照' AFTER pickup_code_updated_by;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND column_name='pickup_code_updated_at') THEN
|
||||
ALTER TABLE fl_container_booking ADD COLUMN pickup_code_updated_at DATETIME(3) NULL
|
||||
COMMENT 'PU码最后填写时间' AFTER pickup_code_updated_by_name;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND index_name='uk_container_booking_container_no') THEN
|
||||
ALTER TABLE fl_container_booking ADD UNIQUE KEY uk_container_booking_container_no (container_no);
|
||||
@@ -197,6 +222,11 @@ BEGIN
|
||||
ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_iso6346
|
||||
CHECK (container_no IS NULL OR container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$');
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND constraint_name='chk_container_booking_pickup_code') THEN
|
||||
ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_pickup_code
|
||||
CHECK (pickup_code IS NULL OR pickup_code REGEXP '^[0-9]{1,64}$');
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL phase3_container_real_unit_columns();
|
||||
@@ -318,11 +348,11 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
|
||||
CONSTRAINT chk_loading_item_deleted CHECK (deleted IN (0, 1))
|
||||
) ENGINE=InnoDB COMMENT='智能装柜客户货物明细;客户归属仅在明细层';
|
||||
|
||||
-- 柜次级简单文件仅承载目的港清关、提货单和取货单;出口报关由 customs-v1 柜级聚合承载。
|
||||
-- 柜次级简单文件仅承载目的港清关和提货单;PU资料作为数字码保存在货柜主表。
|
||||
CREATE TABLE IF NOT EXISTS fl_container_document (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)',
|
||||
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID',
|
||||
document_type VARCHAR(32) NOT NULL COMMENT '文件类型:DESTINATION_CLEARANCE目的港清关、DELIVERY_ORDER提货单、PICKUP_ORDER取货单',
|
||||
document_type VARCHAR(32) NOT NULL COMMENT '文件类型:DESTINATION_CLEARANCE目的港清关、DELIVERY_ORDER提货单',
|
||||
file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID,不建立跨库外键',
|
||||
file_name VARCHAR(255) NOT NULL COMMENT '文件名称快照',
|
||||
file_hash VARCHAR(64) NULL COMMENT 'File服务SHA-256快照',
|
||||
@@ -341,8 +371,17 @@ CREATE TABLE IF NOT EXISTS fl_container_document (
|
||||
CONSTRAINT fk_container_document_booking FOREIGN KEY (container_booking_id)
|
||||
REFERENCES fl_container_booking (id),
|
||||
CONSTRAINT chk_container_document_type
|
||||
CHECK (document_type IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER'))
|
||||
) ENGINE=InnoDB COMMENT='平台货柜清关、DO与PU文件关联';
|
||||
CHECK (document_type IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER'))
|
||||
) ENGINE=InnoDB COMMENT='平台货柜清关与DO文件关联';
|
||||
|
||||
SET @has_container_document_type_check=(SELECT COUNT(*) FROM information_schema.table_constraints
|
||||
WHERE table_schema=DATABASE() AND table_name='fl_container_document'
|
||||
AND constraint_name='chk_container_document_type' AND constraint_type='CHECK');
|
||||
SET @ddl=IF(@has_container_document_type_check=0,'SELECT 1',
|
||||
'ALTER TABLE fl_container_document DROP CHECK chk_container_document_type');
|
||||
PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
|
||||
ALTER TABLE fl_container_document ADD CONSTRAINT chk_container_document_type
|
||||
CHECK (document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER'));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_container_exception_history (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '货柜异常历史ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '物理货柜ID',
|
||||
@@ -1206,7 +1245,7 @@ BEGIN
|
||||
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
|
||||
WHERE container_booking_id = NEW.container_booking_id;
|
||||
IF current_plan_status = 'COMPLETED'
|
||||
AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN
|
||||
AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增';
|
||||
END IF;
|
||||
END$$
|
||||
@@ -1222,7 +1261,7 @@ BEGIN
|
||||
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
|
||||
WHERE container_booking_id = OLD.container_booking_id;
|
||||
IF current_plan_status = 'COMPLETED'
|
||||
AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN
|
||||
AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改';
|
||||
END IF;
|
||||
END$$
|
||||
|
||||
@@ -406,25 +406,6 @@ paths:
|
||||
'200': {description: 成功或业务失败,按响应体 code 判断}
|
||||
'403': {description: 无分配权限}
|
||||
/api/platform/v1/containers:
|
||||
get:
|
||||
summary: 分页查询平台货柜
|
||||
description: 需要 platform:firstleg:container:list 权限;货柜为平台级数据,不传 tenant/account
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/Page'}
|
||||
- {$ref: '#/components/parameters/Size'}
|
||||
- {name: keyword, in: query, schema: {type: string, maxLength: 128}}
|
||||
- {name: customerId, in: query, description: 仅返回包含该客户货物的柜次, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: containerNo, in: query, schema: {type: string, maxLength: 64}}
|
||||
- {name: cabinetNo, in: query, schema: {type: string, maxLength: 64}}
|
||||
- {name: warehouseId, in: query, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: status, in: query, schema: {type: string, enum: [WAIT_LOADING, EDITING, LOADING, COMPLETED]}}
|
||||
responses:
|
||||
'200':
|
||||
description: 成功或业务失败,按响应体 code 判断
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/ContainerBookingPageEnvelope'}
|
||||
'403': {description: 无货柜查询权限}
|
||||
post:
|
||||
summary: 新增内部模拟柜
|
||||
description: 需要 platform:firstleg:container:create 权限;服务端生成稳定 containerBookingId/cabinetNo,不要求真实柜号或订舱
|
||||
@@ -436,6 +417,43 @@ paths:
|
||||
responses:
|
||||
'200': {description: 成功或重复号、参数校验等业务失败}
|
||||
'403': {description: 无新增按钮权限}
|
||||
/api/platform/v1/containers/domestic:
|
||||
get:
|
||||
summary: 分页查询国内货柜工作台
|
||||
description: 需要 platform:firstleg:container:list;ACTIVE仅真实国内离仓前,HISTORY仅已真实离仓
|
||||
parameters: &ContainerWorkbenchQueryParameters
|
||||
- {$ref: '#/components/parameters/Page'}
|
||||
- {$ref: '#/components/parameters/Size'}
|
||||
- {name: scope, in: query, required: true, schema: {type: string, enum: [ACTIVE, HISTORY]}}
|
||||
- {name: keyword, in: query, schema: {type: string, maxLength: 128}}
|
||||
- {name: customerId, in: query, description: 仅返回包含该客户货物的柜次, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: containerNo, in: query, schema: {type: string, maxLength: 64}}
|
||||
- {name: cabinetNo, in: query, schema: {type: string, maxLength: 64}}
|
||||
- {name: warehouseId, in: query, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: status, in: query, description: 仅筛选装柜子状态,不作为出仓或船节点状态, schema: {type: string, enum: [WAIT_LOADING, EDITING, LOADING, COMPLETED]}}
|
||||
responses:
|
||||
'200': {description: 成功, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerWorkbenchPageEnvelope'}}}}
|
||||
'403': {description: 无国内货柜查询权限}
|
||||
/api/platform/v1/containers/overseas:
|
||||
get:
|
||||
summary: 分页查询国外货柜工作台
|
||||
description: 需要 platform:firstleg:terminal-drayage:list;ACTIVE从真实DEPARTED_WAREHOUSE至EMPTY_RETURNED前,HISTORY仅已还空柜
|
||||
parameters: *ContainerWorkbenchQueryParameters
|
||||
responses:
|
||||
'200': {description: 成功, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerWorkbenchPageEnvelope'}}}}
|
||||
'403': {description: 无国外货柜查询权限}
|
||||
/api/platform/v1/containers/domestic/{id}:
|
||||
get:
|
||||
summary: 查询国内货柜工作台详情
|
||||
description: 需要 platform:firstleg:container:list;装柜清单只返回唯一loadingPlanId/planNo/planStatus关联
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
|
||||
responses: {'200': {description: 成功, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerWorkbenchEnvelope'}}}}}
|
||||
/api/platform/v1/containers/overseas/{id}:
|
||||
get:
|
||||
summary: 查询国外货柜工作台详情
|
||||
description: 需要 platform:firstleg:terminal-drayage:list;同一containerBookingId只读聚合柜内组成和海外履约
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
|
||||
responses: {'200': {description: 成功, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerWorkbenchEnvelope'}}}}}
|
||||
/api/platform/v1/containers/{id}/ocean-booking-link:
|
||||
post:
|
||||
summary: 将内部模拟柜关联到已发布订舱需求
|
||||
@@ -444,14 +462,6 @@ paths:
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingLinkRequest'}}}}
|
||||
responses: {'200': {description: 关联后的同一模拟柜 containerBookingId/cabinetNo;列表仍只有这一条 ACTIVE 操作身份}}
|
||||
/api/platform/v1/containers/{id}:
|
||||
get:
|
||||
summary: 查询平台货柜详情
|
||||
description: 需要 platform:firstleg:container:list 权限
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/ContainerBookingId'}
|
||||
responses:
|
||||
'200': {description: 成功或货柜不存在}
|
||||
'403': {description: 无货柜查询权限}
|
||||
put:
|
||||
summary: 草稿阶段编辑货柜基础资料
|
||||
description: 需要 platform:firstleg:container:edit 权限;仅待装柜或计划为 EDITING 时允许;请求不包含 containerNo/cabinetNo,不形成真实柜号或内部柜次号写入口
|
||||
@@ -465,16 +475,23 @@ paths:
|
||||
responses:
|
||||
'200': {description: 成功或状态、版本、重复号冲突}
|
||||
'403': {description: 无货柜编辑权限}
|
||||
/api/platform/v1/containers/{id}/pickup-code:
|
||||
put:
|
||||
summary: 填写或更新PU码
|
||||
description: 需要 platform:firstleg:terminal-drayage:document:edit 权限;仅国外工作台货柜;PU资料为1至64位数字码,按字符串保存以保留前导零
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerPickupCodeRequest'}}}}
|
||||
responses: {'200': {description: 返回最新货柜履约概览}, '403': {description: 无货柜编辑权限}}
|
||||
/api/platform/v1/containers/{id}/clearance:
|
||||
get:
|
||||
summary: 查询货柜目的港清关资料
|
||||
description: 需要 platform:firstleg:customs:manage 权限
|
||||
description: 需要 platform:firstleg:terminal-drayage:list 权限;仅国外工作台货柜
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
|
||||
responses: {'200': {description: 成功或货柜不存在}}
|
||||
/api/platform/v1/containers/{id}/clearance/document:
|
||||
post:
|
||||
summary: 上传或替换货柜目的港清关资料
|
||||
description: 需要 platform:firstleg:customs:manage 权限,fileId 必须严格绑定当前货柜
|
||||
description: 需要 platform:firstleg:terminal-drayage:document:edit 权限,fileId 必须严格绑定当前货柜
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -483,7 +500,7 @@ paths:
|
||||
/api/platform/v1/containers/{id}/clearance/document/{fileId}/download:
|
||||
get:
|
||||
summary: 下载货柜目的港清关附件
|
||||
description: 需要 platform:firstleg:customs:manage 权限;服务端校验当前 DESTINATION_CLEARANCE fileId 及 File 业务归属,完成态仍可下载,响应禁止缓存
|
||||
description: 需要 platform:firstleg:terminal-drayage:list 权限;服务端校验当前 DESTINATION_CLEARANCE fileId 及 File 业务归属,完成态仍可下载,响应禁止缓存
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/ContainerBookingId'}
|
||||
- {name: fileId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
@@ -493,7 +510,7 @@ paths:
|
||||
/api/platform/v1/containers/{id}/clearance/document/upload:
|
||||
post:
|
||||
summary: 直接选择文件上传或替换货柜目的港清关资料
|
||||
description: Firstleg 使用内部服务令牌上传 File,浏览器不得传递 fileId 或内部令牌;旧 JSON document 接口仅兼容存量调用。
|
||||
description: 需要 platform:firstleg:terminal-drayage:document:edit;Firstleg 使用内部服务令牌上传 File,浏览器不得传递 fileId 或内部令牌。
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -504,7 +521,7 @@ paths:
|
||||
/api/platform/v1/containers/{id}/clearance/complete:
|
||||
post:
|
||||
summary: 点击完成目的港清关
|
||||
description: 需要 platform:firstleg:customs:manage 权限
|
||||
description: 需要 platform:firstleg:terminal-drayage:document:edit 权限
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -1986,7 +2003,7 @@ components:
|
||||
publicRemark: {type: string, nullable: true}
|
||||
expressCompanyName: {type: string, nullable: true}
|
||||
expressTrackingNo: {type: string, nullable: true}
|
||||
ContainerBookingPageEnvelope:
|
||||
ContainerWorkbenchPageEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
code: {type: string, example: '200'}
|
||||
@@ -1994,14 +2011,78 @@ components:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
records: {type: array, items: {$ref: '#/components/schemas/ContainerBooking'}}
|
||||
records: {type: array, items: {$ref: '#/components/schemas/ContainerWorkbenchItem'}}
|
||||
page: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
size: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
total: {type: string, pattern: '^[0-9]+$'}
|
||||
pages: {type: string, pattern: '^[0-9]+$'}
|
||||
ContainerWorkbenchEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
code: {type: string, example: '200'}
|
||||
message: {type: string}
|
||||
data: {$ref: '#/components/schemas/ContainerWorkbenchItem'}
|
||||
ContainerWorkbenchItem:
|
||||
type: object
|
||||
required: [container, workbenchStage, statusSummary, allowedActions]
|
||||
properties:
|
||||
container: {$ref: '#/components/schemas/ContainerBooking'}
|
||||
workbenchStage: {type: string, enum: [WAIT_LOADING, LOADING, WAIT_CUSTOMS, WAIT_OUTBOUND, DEPARTED_WAREHOUSE, IN_TRANSIT, ARRIVED_DESTINATION_PORT, PICKUP_READY, PICKED_UP, UNLOADED, EMPTY_RETURNED]}
|
||||
statusSummary: {$ref: '#/components/schemas/ContainerWorkbenchStatusSummary'}
|
||||
allowedActions:
|
||||
type: array
|
||||
description: 已同时按真实业务状态与当前用户权限裁剪
|
||||
items: {type: string, enum: [EDIT_CONTAINER, MANAGE_CUSTOMS, CONFIRM_OUTBOUND, MANAGE_DESTINATION_DOCUMENTS, SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN]}
|
||||
actualFacts: {$ref: '#/components/schemas/ContainerActualFacts'}
|
||||
terminalDrayage: {$ref: '#/components/schemas/ContainerTerminalDrayage'}
|
||||
ContainerWorkbenchStatusSummary:
|
||||
type: object
|
||||
properties:
|
||||
loadingStatus: {type: string, nullable: true}
|
||||
customsStatus: {type: string, nullable: true}
|
||||
outboundStatus: {type: string, nullable: true}
|
||||
destinationClearanceStatus: {$ref: '#/components/schemas/ContainerDocumentStatus'}
|
||||
deliveryOrderStatus: {$ref: '#/components/schemas/ContainerDocumentStatus'}
|
||||
pickupCodeStatus: {type: string, enum: [MISSING, UPLOADED]}
|
||||
latestActualNode: {type: string, nullable: true}
|
||||
latestActualAt: {type: string, format: date-time, nullable: true}
|
||||
terminalDrayageStatus: {type: string, nullable: true}
|
||||
emptyReturnWarningStatus: {type: string, enum: [NOT_APPLICABLE, PENDING, OVERDUE, RESOLVED], nullable: true}
|
||||
ContainerActualFacts:
|
||||
type: object
|
||||
nullable: true
|
||||
description: 仅来自最新船节点任务的actual_at;无任务时为null,不用预计时间推断
|
||||
properties:
|
||||
containerBookingId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
queryId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
queryVersion: {type: integer, minimum: 0}
|
||||
queryStatus: {type: string}
|
||||
latestActualNode: {type: string, nullable: true}
|
||||
latestActualAt: {type: string, format: date-time, nullable: true}
|
||||
departedWarehouseAt: {type: string, format: date-time, nullable: true}
|
||||
departedOriginPortAt: {type: string, format: date-time, nullable: true}
|
||||
arrivedDestinationPortAt: {type: string, format: date-time, nullable: true}
|
||||
pickupReadyAt: {type: string, format: date-time, nullable: true}
|
||||
pickedUpAt: {type: string, format: date-time, nullable: true}
|
||||
emptyReturnedAt: {type: string, format: date-time, nullable: true}
|
||||
ContainerTerminalDrayage:
|
||||
type: object
|
||||
nullable: true
|
||||
properties:
|
||||
status: {type: string, enum: [WAITING_FOR_ARRIVAL, ARRIVED, PICKUP_SCHEDULED, PICKED_UP, UNLOADED, EMPTY_RETURNED]}
|
||||
version: {type: integer, minimum: 0}
|
||||
fleetSupplierId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
|
||||
fleetSupplierName: {type: string, nullable: true}
|
||||
pickupAppointmentAt: {type: string, format: date-time, nullable: true}
|
||||
actualPickupAt: {type: string, format: date-time, nullable: true}
|
||||
estimatedEmptyReturnAt: {type: string, format: date-time, nullable: true, description: unloadingCompletedAt加72小时}
|
||||
actualEmptyReturnAt: {type: string, format: date-time, nullable: true}
|
||||
unloadingCompletedAt: {type: string, format: date-time, nullable: true}
|
||||
emptyReturnWarning: {type: object, nullable: true}
|
||||
allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN]}}
|
||||
ContainerBooking:
|
||||
type: object
|
||||
required: [id, bookingNo, cabinetNo, sourceType, recordStatus, warehouseId, warehouseName, containerType, capacityCbm, maxWeightKg, planStatus, exceptionActive, destinationClearanceDocumentStatus, customsDeclarationDocumentStatus, deliveryOrderDocumentStatus, pickupOrderDocumentStatus, version]
|
||||
required: [id, bookingNo, cabinetNo, sourceType, recordStatus, warehouseId, warehouseName, containerType, capacityCbm, maxWeightKg, planStatus, exceptionActive, destinationClearanceDocumentStatus, customsDeclarationDocumentStatus, deliveryOrderDocumentStatus, version]
|
||||
properties:
|
||||
id: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 关联智能装柜计划ID;未创建计划时为空}
|
||||
@@ -2043,7 +2124,7 @@ components:
|
||||
actualWarehouseArrivalAt: {type: string, format: date-time, nullable: true}
|
||||
unloadingCompletedAt: {type: string, format: date-time, nullable: true, description: 海外仓卸柜完成时间}
|
||||
unloadingPhotoUploadedAt: {type: string, format: date-time, nullable: true, description: 海外仓卸柜照片上传成功时间}
|
||||
estimatedEmptyReportedAt: {type: string, format: date-time, nullable: true, description: 预计报空时间,由卸柜照片上传成功时间加两天计算}
|
||||
estimatedEmptyReportedAt: {type: string, format: date-time, nullable: true, description: 预计还空时间,统一按卸柜完成时间加72小时计算}
|
||||
actualEmptyReportedAt: {type: string, format: date-time, nullable: true, description: 实际报空时间}
|
||||
emptyReportSourceVersion: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 海外货柜事件聚合版本}
|
||||
planStatus: {type: string, enum: [WAIT_LOADING, EDITING, LOADING, COMPLETED]}
|
||||
@@ -2051,12 +2132,17 @@ components:
|
||||
destinationClearanceDocumentStatus: {$ref: '#/components/schemas/ContainerDocumentStatus'}
|
||||
customsDeclarationDocumentStatus: {$ref: '#/components/schemas/ContainerDocumentStatus'}
|
||||
deliveryOrderDocumentStatus: {$ref: '#/components/schemas/ContainerDocumentStatus'}
|
||||
pickupOrderDocumentStatus: {$ref: '#/components/schemas/ContainerDocumentStatus'}
|
||||
outboundDispatchStatus: {type: string, nullable: true, description: 国内出仓聚合状态,不使用planStatus推断}
|
||||
outboundConfirmedAt: {type: string, format: date-time, nullable: true, description: 真实国内离仓确认时间}
|
||||
pickupCode: {type: string, pattern: '^[0-9]{1,64}$', nullable: true, description: PU数字码;字符串保存以保留前导零}
|
||||
pickupCodeUpdatedBy: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
|
||||
pickupCodeUpdatedByName: {type: string, nullable: true}
|
||||
pickupCodeUpdatedAt: {type: string, format: date-time, nullable: true}
|
||||
version: {type: integer, minimum: 0}
|
||||
ContainerDocumentStatus:
|
||||
type: string
|
||||
enum: [MISSING, UPLOADED, COMPLETED]
|
||||
description: 单证聚合状态;DO/PU 无完成动作,因此只返回 MISSING 或 UPLOADED
|
||||
description: 单证聚合状态;DO 无完成动作,因此只返回 MISSING 或 UPLOADED
|
||||
ContainerSaveRequest:
|
||||
type: object
|
||||
required: [warehouseId, warehouseName, containerType, capacityCbm, maxWeightKg]
|
||||
@@ -2366,6 +2452,12 @@ components:
|
||||
file: {type: string, format: binary, description: 用户直接选择的清关资料}
|
||||
expectedVersion: {type: integer, minimum: 0, description: 当前文档乐观锁版本,首次上传为 0}
|
||||
operationReason: {type: string, maxLength: 500, nullable: true}
|
||||
ContainerPickupCodeRequest:
|
||||
type: object
|
||||
required: [pickupCode, expectedVersion]
|
||||
properties:
|
||||
pickupCode: {type: string, pattern: '^[0-9]{1,64}$'}
|
||||
expectedVersion: {type: integer, minimum: 0}
|
||||
MerchantInboundBillingEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -7,6 +7,19 @@ paths:
|
||||
description: '权限:platform:firstleg:container:list'
|
||||
parameters: [{$ref: '#/components/parameters/ContainerId'}]
|
||||
responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/OverviewEnvelope'}}}}}
|
||||
/api/platform/v1/containers/overseas/{id}/overview:
|
||||
get:
|
||||
operationId: getOverseasContainerOverview
|
||||
description: '权限:platform:firstleg:terminal-drayage:list;仅真实国内离仓后的国外工作台货柜'
|
||||
parameters: [{$ref: '#/components/parameters/ContainerId'}]
|
||||
responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/OverviewEnvelope'}}}}}
|
||||
/api/platform/v1/containers/{id}/pickup-code:
|
||||
put:
|
||||
operationId: updateContainerPickupCode
|
||||
description: '权限:platform:firstleg:terminal-drayage:document:edit;PU资料为1至64位数字码,以字符串保存并保留前导零'
|
||||
parameters: [{$ref: '#/components/parameters/ContainerId'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PickupCodeRequest'}}}}
|
||||
responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/OverviewEnvelope'}}}}}
|
||||
/api/platform/v1/containers/{id}/exception:
|
||||
parameters: [{$ref: '#/components/parameters/ContainerId'}]
|
||||
post: {operationId: markContainerException, description: '权限:platform:firstleg:container:edit', parameters: [{name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}}], requestBody: {$ref: '#/components/requestBodies/ExceptionRequest'}, responses: {'200': {description: OK}}}
|
||||
@@ -20,32 +33,32 @@ paths:
|
||||
/api/platform/v1/containers/{id}/documents/{documentType}:
|
||||
get:
|
||||
operationId: getContainerDocument
|
||||
description: '权限:platform:firstleg:container:list;仅DO/PU,completed恒为false'
|
||||
description: '权限:platform:firstleg:terminal-drayage:list;仅国外工作台DO,completed恒为false'
|
||||
parameters: [{$ref: '#/components/parameters/ContainerId'}, {$ref: '#/components/parameters/DocumentType'}]
|
||||
responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentEnvelope'}}}}}
|
||||
/api/platform/v1/containers/{id}/documents/{documentType}/upload:
|
||||
post:
|
||||
operationId: uploadContainerDocument
|
||||
description: '权限:platform:firstleg:container:edit;仅DO/PU,无完成动作'
|
||||
description: '权限:platform:firstleg:terminal-drayage:document:edit;仅国外工作台DO,无完成动作'
|
||||
parameters: [{$ref: '#/components/parameters/ContainerId'}, {$ref: '#/components/parameters/DocumentType'}, {name: Idempotency-Key, in: header, required: true, schema: {type: string}}]
|
||||
requestBody: {required: true, content: {multipart/form-data: {schema: {type: object, required: [file, expectedVersion], properties: {file: {type: string, format: binary}, expectedVersion: {type: integer, minimum: 0}, operationReason: {type: string, maxLength: 500}}}}}}
|
||||
responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentEnvelope'}}}}}
|
||||
/api/platform/v1/containers/{id}/documents/{documentType}/{fileId}/download:
|
||||
get:
|
||||
operationId: downloadContainerDocument
|
||||
description: '权限:platform:firstleg:container:list;Firstleg代理File内部下载,不向调用方暴露内部令牌'
|
||||
description: '权限:platform:firstleg:terminal-drayage:list;Firstleg代理File内部下载,不向调用方暴露内部令牌'
|
||||
parameters: [{$ref: '#/components/parameters/ContainerId'}, {$ref: '#/components/parameters/DocumentType'}, {name: fileId, in: path, required: true, schema: {type: integer, format: int64}}]
|
||||
responses: {'200': {description: file bytes, content: {application/octet-stream: {schema: {type: string, format: binary}}}}}
|
||||
/api/platform/v1/containers/{id}/clearance/document/{fileId}/download:
|
||||
get:
|
||||
operationId: downloadContainerDestinationClearance
|
||||
description: '权限:platform:firstleg:customs:manage;校验当前 DESTINATION_CLEARANCE fileId 以及 File tenantId=0、businessType=CONTAINER_CLEARANCE_DOCUMENT、businessId=id;返回 attachment 与 no-store,完成态仍可下载'
|
||||
description: '权限:platform:firstleg:terminal-drayage:list;校验当前 DESTINATION_CLEARANCE fileId 以及 File tenantId=0、businessType=CONTAINER_CLEARANCE_DOCUMENT、businessId=id;返回 attachment 与 no-store,完成态仍可下载'
|
||||
parameters: [{$ref: '#/components/parameters/ContainerId'}, {name: fileId, in: path, required: true, schema: {type: integer, format: int64}}]
|
||||
responses: {'200': {description: clearance file bytes, content: {application/octet-stream: {schema: {type: string, format: binary}}}}, '404': {description: current file or ownership mismatch}}
|
||||
components:
|
||||
parameters:
|
||||
ContainerId: {name: id, in: path, required: true, schema: {type: integer, format: int64}}
|
||||
DocumentType: {name: documentType, in: path, required: true, schema: {type: string, enum: [DELIVERY_ORDER, PICKUP_ORDER]}, description: '通用单证仅支持DO/PU;目的港清关使用 /clearance;出口报关由 customs-v1 柜级聚合接口承载'}
|
||||
DocumentType: {name: documentType, in: path, required: true, schema: {type: string, enum: [DELIVERY_ORDER]}, description: '通用单证仅支持DO;PU资料使用 /pickup-code;目的港清关使用 /clearance;出口报关由 customs-v1 柜级聚合接口承载'}
|
||||
requestBodies:
|
||||
ExceptionRequest: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ExceptionRequest'}}}}
|
||||
schemas:
|
||||
@@ -57,16 +70,17 @@ components:
|
||||
container: {$ref: '#/components/schemas/Container'}
|
||||
loadingPlanId: {type: integer, format: int64}
|
||||
pda: {$ref: '#/components/schemas/PdaSummary'}
|
||||
documents: {type: array, description: '按 documentType 稳定排序返回 DESTINATION_CLEARANCE、DELIVERY_ORDER、PICKUP_ORDER 的当前唯一记录和真实 version;出口报关只由 customs-v1 柜级聚合承载', items: {$ref: '#/components/schemas/Document'}}
|
||||
documents: {type: array, description: '按 documentType 稳定排序返回 DESTINATION_CLEARANCE、DELIVERY_ORDER 的当前唯一记录和真实 version;PU码位于 container.pickupCode;出口报关只由 customs-v1 柜级聚合承载', items: {$ref: '#/components/schemas/Document'}}
|
||||
exception: {$ref: '#/components/schemas/ExceptionState'}
|
||||
exceptionHistory: {type: array, items: {$ref: '#/components/schemas/ExceptionHistory'}}
|
||||
timeline: {type: array, items: {$ref: '#/components/schemas/TimelineEntry'}}
|
||||
Container: {type: object, properties: {id: {type: integer, format: int64}, bookingNo: {type: string, pattern: '^CB-[0-9]{6}-[0-9]{4}$', example: CB-260818-0001}, cabinetNo: {type: string, pattern: '^CAB-[0-9]{6}-[0-9]{4}$', example: CAB-260818-0001}, containerNo: {type: string}, status: {type: string}, warehouseId: {type: integer, format: int64}, warehouseName: {type: string}, voyageNo: {type: string}, vesselName: {type: string}, version: {type: integer, minimum: 0}}}
|
||||
Container: {type: object, properties: {id: {type: integer, format: int64}, bookingNo: {type: string, pattern: '^CB-[0-9]{6}-[0-9]{4}$', example: CB-260818-0001}, cabinetNo: {type: string, pattern: '^CAB-[0-9]{6}-[0-9]{4}$', example: CAB-260818-0001}, containerNo: {type: string}, status: {type: string}, warehouseId: {type: integer, format: int64}, warehouseName: {type: string}, voyageNo: {type: string}, vesselName: {type: string}, pickupCode: {type: string, pattern: '^[0-9]{1,64}$', nullable: true}, pickupCodeUpdatedBy: {type: integer, format: int64, nullable: true}, pickupCodeUpdatedByName: {type: string, nullable: true}, pickupCodeUpdatedAt: {type: string, format: date-time, nullable: true}, version: {type: integer, minimum: 0}}}
|
||||
PdaSummary: {type: object, properties: {taskId: {type: integer, format: int64}, loadingPlanId: {type: integer, format: int64}, containerBookingId: {type: integer, format: int64}, taskStatus: {type: string, description: '持久化任务态:LOADING/COMPLETED'}, workflowStatus: {type: string, enum: [IN_PROGRESS, AWAITING_REVIEW, COMPLETED], description: 'PDA作业展示态'}, taskVersion: {type: integer}, itemCount: {type: integer}, completedItemCount: {type: integer}, unresolvedItemCount: {type: integer}, photoCounts: {type: array, items: {$ref: '#/components/schemas/PhotoCount'}}, photos: {type: array, items: {$ref: '#/components/schemas/Photo'}}, exceptionReasons: {type: array, items: {type: string}}}}
|
||||
PhotoCount: {type: object, properties: {evidenceType: {type: string}, count: {type: integer}}}
|
||||
Photo: {type: object, properties: {evidenceId: {type: integer, format: int64}, tenantId: {type: integer, format: int64}, inboundId: {type: integer, format: int64}, evidenceType: {type: string}, fileId: {type: integer, format: int64}, fileName: {type: string}, status: {type: string, enum: [ACTIVE, VOID]}, downloadPath: {type: string, nullable: true, description: '仅 ACTIVE 照片提供鉴权下载路径;VOID 历史照片为空'}, uploadedAt: {type: string, format: date-time}}}
|
||||
Document: {type: object, properties: {id: {type: integer, format: int64}, documentType: {type: string, enum: [DESTINATION_CLEARANCE, DELIVERY_ORDER, PICKUP_ORDER]}, fileId: {type: integer, format: int64}, fileName: {type: string}, fileHash: {type: string}, uploadedBy: {type: integer, format: int64}, uploadedByName: {type: string}, uploadedAt: {type: string, format: date-time}, version: {type: integer, description: '当前唯一记录的真实乐观锁版本;更新时必须原样作为 expectedVersion'}, completed: {type: boolean}}}
|
||||
Document: {type: object, properties: {id: {type: integer, format: int64}, documentType: {type: string, enum: [DESTINATION_CLEARANCE, DELIVERY_ORDER]}, fileId: {type: integer, format: int64}, fileName: {type: string}, fileHash: {type: string}, uploadedBy: {type: integer, format: int64}, uploadedByName: {type: string}, uploadedAt: {type: string, format: date-time}, version: {type: integer, description: '当前唯一记录的真实乐观锁版本;更新时必须原样作为 expectedVersion'}, completed: {type: boolean}}}
|
||||
ExceptionState: {type: object, properties: {active: {type: boolean}, reason: {type: string}, markedBy: {type: integer, format: int64}, markedByName: {type: string}, markedAt: {type: string, format: date-time}}}
|
||||
ExceptionHistory: {type: object, properties: {id: {type: integer, format: int64}, action: {type: string, enum: [MARK, CANCEL]}, reason: {type: string}, operatorId: {type: integer, format: int64}, operatorName: {type: string}, occurredAt: {type: string, format: date-time}}}
|
||||
TimelineEntry: {type: object, properties: {eventType: {type: string}, detail: {type: string}, operatorId: {type: integer, format: int64}, operatorName: {type: string}, occurredAt: {type: string, format: date-time}}}
|
||||
ExceptionRequest: {type: object, required: [expectedVersion, reason], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, maxLength: 500}}}
|
||||
PickupCodeRequest: {type: object, required: [pickupCode, expectedVersion], properties: {pickupCode: {type: string, pattern: '^[0-9]{1,64}$'}, expectedVersion: {type: integer, minimum: 0}}}
|
||||
|
||||
@@ -4,7 +4,7 @@ paths:
|
||||
/api/platform/v1/terminal-drayages:
|
||||
get:
|
||||
summary: 批量查询货柜码头拖车及服务端报空预警
|
||||
x-permission: platform:firstleg:container:list
|
||||
x-permission: platform:firstleg:terminal-drayage:list
|
||||
parameters:
|
||||
- name: containerBookingIds
|
||||
in: query
|
||||
@@ -14,40 +14,40 @@ paths:
|
||||
/api/platform/v1/terminal-drayages/{containerBookingId}:
|
||||
get:
|
||||
summary: 查询单柜码头拖车详情
|
||||
x-permission: platform:firstleg:container:list
|
||||
x-permission: platform:firstleg:terminal-drayage:list
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
|
||||
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
|
||||
/api/platform/v1/terminal-drayages/{containerBookingId}/history:
|
||||
get:
|
||||
summary: 查询单柜码头拖车操作历史
|
||||
x-permission: platform:firstleg:container:list
|
||||
x-permission: platform:firstleg:terminal-drayage:list
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
|
||||
responses: {'200': {$ref: '#/components/responses/HistoryResponse'}}
|
||||
/api/platform/v1/terminal-drayages/{containerBookingId}/fleet:
|
||||
post:
|
||||
summary: 到港后选择或更换码头车队
|
||||
x-permission: platform:firstleg:container:edit
|
||||
x-permission: platform:firstleg:terminal-drayage:fleet:edit
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/FleetSelectionRequest'}}}}
|
||||
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
|
||||
/api/platform/v1/terminal-drayages/{containerBookingId}/pickup-appointment:
|
||||
post:
|
||||
summary: 预约目的港提柜时间
|
||||
x-permission: platform:firstleg:container:edit
|
||||
x-permission: platform:firstleg:terminal-drayage:pickup-appointment:edit
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PickupAppointmentRequest'}}}}
|
||||
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
|
||||
/api/platform/v1/terminal-drayages/{containerBookingId}/pickup:
|
||||
post:
|
||||
summary: 清关可提柜后确认实际提柜并推进PICKED_UP节点
|
||||
x-permission: platform:firstleg:container:edit
|
||||
x-permission: platform:firstleg:terminal-drayage:pickup:confirm
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualTimeRequest'}}}}
|
||||
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
|
||||
/api/platform/v1/terminal-drayages/{containerBookingId}/empty-return:
|
||||
post:
|
||||
summary: 海外卸柜收货完成后确认实际还柜并推进EMPTY_RETURNED节点
|
||||
x-permission: platform:firstleg:container:edit
|
||||
x-permission: platform:firstleg:terminal-drayage:empty-return:confirm
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualTimeRequest'}}}}
|
||||
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
|
||||
@@ -88,7 +88,7 @@ components:
|
||||
overdueDays: {type: integer, format: int64, minimum: 0}
|
||||
TerminalDrayageView:
|
||||
type: object
|
||||
required: [containerBookingId, status, version, emptyReturnWarning, allowedActions]
|
||||
required: [containerBookingId, status, version, emptyReturnWarning, allowedActions, actualFacts]
|
||||
properties:
|
||||
containerBookingId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
containerNo: {type: string, nullable: true}
|
||||
@@ -104,7 +104,24 @@ components:
|
||||
unloadingCompletedAt: {type: string, format: date-time, nullable: true}
|
||||
emptyReturnWarning: {$ref: '#/components/schemas/Warning'}
|
||||
allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN]}}
|
||||
actualFacts: {$ref: '#/components/schemas/ContainerActualFacts'}
|
||||
updatedAt: {type: string, format: date-time, nullable: true}
|
||||
ContainerActualFacts:
|
||||
type: object
|
||||
required: [containerBookingId, queryId, queryVersion, queryStatus]
|
||||
properties:
|
||||
containerBookingId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
queryId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
queryVersion: {type: integer, minimum: 0}
|
||||
queryStatus: {type: string}
|
||||
latestActualNode: {type: string, nullable: true}
|
||||
latestActualAt: {type: string, format: date-time, nullable: true}
|
||||
departedWarehouseAt: {type: string, format: date-time, nullable: true}
|
||||
departedOriginPortAt: {type: string, format: date-time, nullable: true}
|
||||
arrivedDestinationPortAt: {type: string, format: date-time, nullable: true}
|
||||
pickupReadyAt: {type: string, format: date-time, nullable: true}
|
||||
pickedUpAt: {type: string, format: date-time, nullable: true}
|
||||
emptyReturnedAt: {type: string, format: date-time, nullable: true}
|
||||
History:
|
||||
type: object
|
||||
required: [action, fromStatus, toStatus, fromVersion, toVersion, operatorId, createdAt]
|
||||
|
||||
+19
-10
@@ -18,30 +18,35 @@ class ContainerControllerContractTest {
|
||||
@Test
|
||||
@DisplayName("查询、新增和编辑按钮权限彼此独立")
|
||||
void separatesButtonPermissions() {
|
||||
assertPermission("page", "platform:firstleg:container:list");
|
||||
assertPermission("detail", "platform:firstleg:container:list");
|
||||
assertPermission("domesticPage", "platform:firstleg:container:list");
|
||||
assertPermission("overseasPage", "platform:firstleg:terminal-drayage:list");
|
||||
assertPermission("domesticDetail", "platform:firstleg:container:list");
|
||||
assertPermission("overseasDetail", "platform:firstleg:terminal-drayage:list");
|
||||
assertPermission("create", "platform:firstleg:container:create");
|
||||
assertPermission("linkOceanBooking", "platform:firstleg:container:edit");
|
||||
assertPermission("updateDraft", "platform:firstleg:container:edit");
|
||||
assertPermission("uploadClearance", "platform:firstleg:customs:manage");
|
||||
assertPermission("uploadClearance", "platform:firstleg:terminal-drayage:document:edit");
|
||||
assertPermission("overview", "platform:firstleg:container:list");
|
||||
assertPermission("overseasOverview", "platform:firstleg:terminal-drayage:list");
|
||||
assertPermission("updatePickupCode", "platform:firstleg:terminal-drayage:document:edit");
|
||||
assertPermission("downloadPdaPhoto", "platform:firstleg:container:list");
|
||||
assertPermission("markException", "platform:firstleg:container:edit");
|
||||
assertPermission("cancelException", "platform:firstleg:container:edit");
|
||||
assertPermission("logisticsDocument", "platform:firstleg:container:list");
|
||||
assertPermission("uploadLogisticsDocument", "platform:firstleg:container:edit");
|
||||
assertPermission("downloadDocument", "platform:firstleg:container:list");
|
||||
assertPermission("downloadClearance", "platform:firstleg:customs:manage");
|
||||
assertPermission("logisticsDocument", "platform:firstleg:terminal-drayage:list");
|
||||
assertPermission("uploadLogisticsDocument", "platform:firstleg:terminal-drayage:document:edit");
|
||||
assertPermission("downloadDocument", "platform:firstleg:terminal-drayage:list");
|
||||
assertPermission("downloadClearance", "platform:firstleg:terminal-drayage:list");
|
||||
}
|
||||
|
||||
@Test void overviewOpenApiFreezesContainerIdBasedPdaContract() throws Exception {
|
||||
String openapi = new ClassPathResource("openapi/phase3-container-overview-v1.yaml")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(openapi).contains("/api/platform/v1/containers/{id}/overview:",
|
||||
"/api/platform/v1/containers/overseas/{id}/overview:",
|
||||
"/documents/{documentType}/{fileId}/download:", "ExceptionRequest",
|
||||
"/clearance/document/{fileId}/download:",
|
||||
"DESTINATION_CLEARANCE、DELIVERY_ORDER、PICKUP_ORDER",
|
||||
"enum: [DELIVERY_ORDER, PICKUP_ORDER]", "Idempotency-Key", "photoCounts:",
|
||||
"/api/platform/v1/containers/{id}/pickup-code:", "PickupCodeRequest",
|
||||
"enum: [DELIVERY_ORDER]", "Idempotency-Key", "photoCounts:",
|
||||
"/pda/photos/{fileId}/download:", "downloadPath:", "workflowStatus:",
|
||||
"AWAITING_REVIEW", "version: {type: integer, minimum: 0}",
|
||||
"downloadPath: {type: string, nullable: true",
|
||||
@@ -59,6 +64,9 @@ class ContainerControllerContractTest {
|
||||
|
||||
assertThat(section)
|
||||
.contains("/api/platform/v1/containers/{id}/ocean-booking-link:")
|
||||
.contains("/api/platform/v1/containers/domestic:")
|
||||
.contains("/api/platform/v1/containers/overseas:")
|
||||
.contains("scope, in: query, required: true")
|
||||
.contains("首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜")
|
||||
.contains("RELEASED oceanBookingId")
|
||||
.contains("未满配 requirementId")
|
||||
@@ -72,7 +80,7 @@ class ContainerControllerContractTest {
|
||||
.contains("/api/platform/v1/containers/{id}/clearance/document/upload:")
|
||||
.contains("multipart/form-data")
|
||||
.contains("ContainerDocumentUploadRequest")
|
||||
.contains("platform:firstleg:container:list")
|
||||
.contains("platform:firstleg:terminal-drayage:list")
|
||||
.contains("EDITING")
|
||||
.contains("WAIT_LOADING")
|
||||
.contains("LOADING")
|
||||
@@ -80,6 +88,7 @@ class ContainerControllerContractTest {
|
||||
.doesNotContain("tenantId")
|
||||
.doesNotContain("accountId");
|
||||
assertThat(openapi)
|
||||
.contains("ContainerWorkbenchItem:", "allowedActions")
|
||||
.contains("/api/platform/v1/containers/{id}/clearance/document/{fileId}/download:")
|
||||
.contains("Content-Disposition 与 Cache-Control no-store")
|
||||
.contains("required: [oceanBookingId, requirementId, expectedVersion]")
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ class ContainerBookingMapperContractTest {
|
||||
void filtersPageByStableCustomerId() throws Exception {
|
||||
String page = String.join("\n", ContainerBookingMapper.class
|
||||
.getDeclaredMethod("selectBookingPage", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class,
|
||||
com.baiyue.firstleg.dto.ContainerDtos.Query.class)
|
||||
com.baiyue.firstleg.dto.ContainerDtos.Query.class, String.class)
|
||||
.getAnnotation(Select.class).value());
|
||||
assertThat(page)
|
||||
.contains("fl_ocean_booking_container_allocation customer_allocation")
|
||||
|
||||
+3
-2
@@ -12,11 +12,12 @@ import java.nio.file.Path;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ContainerOverviewSqlContractTest {
|
||||
@Test void overviewIncludesExactlyTheThreeCurrentBusinessDocumentTypes() throws Exception {
|
||||
@Test void overviewIncludesExactlyTheTwoCurrentBusinessDocumentTypes() throws Exception {
|
||||
String sql = String.join(" ", ContainerOverviewMapper.class.getDeclaredMethod("documents", long.class)
|
||||
.getAnnotation(Select.class).value());
|
||||
assertThat(sql)
|
||||
.contains("document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER','PICKUP_ORDER')")
|
||||
.contains("document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER')")
|
||||
.doesNotContain("PICKUP_ORDER")
|
||||
.contains("ORDER BY document_type");
|
||||
}
|
||||
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baiyue.firstleg.mapper.ContainerBookingMapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ContainerWorkbenchSqlContractTest {
|
||||
@Test
|
||||
void pagesDomesticAndOverseasScopesFromOwnedActualFactsWithoutCopyingShipNodeSql() throws Exception {
|
||||
String sql = String.join("\n", ContainerBookingMapper.class
|
||||
.getMethod("selectBookingPage", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class,
|
||||
com.baiyue.firstleg.dto.ContainerDtos.Query.class, String.class)
|
||||
.getAnnotation(Select.class).value());
|
||||
|
||||
assertThat(sql).contains("#{workbench}='DOMESTIC'", "#{workbench}='OVERSEAS'",
|
||||
"#{query.scope}='ACTIVE'", "#{query.scope}='HISTORY'", "fl_outbound_dispatch",
|
||||
"confirmed_at IS NOT NULL", "b.actual_empty_reported_at IS NULL",
|
||||
"b.actual_empty_reported_at IS NOT NULL", "outbound_dispatch_status")
|
||||
.doesNotContain("fl_carrier_schedule_query", "fl_carrier_schedule_node_fact");
|
||||
}
|
||||
|
||||
@Test
|
||||
void baselineDocumentsTheSingleSeventyTwoHourEmptyReturnRule() throws Exception {
|
||||
String sql = Files.readString(Path.of("src/main/resources/db/phase3-smart-loading-schema.sql"));
|
||||
assertThat(sql).contains("预计还空时间(卸柜完成时间加72小时,UTC)")
|
||||
.doesNotContain("卸柜照片上传成功时间加两天");
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -161,10 +161,11 @@ class SmartLoadingSqlContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("货柜简单文件只保留目的港清关与DO/PU")
|
||||
void shouldAllowExactlyThreeSimpleContainerDocumentTypes() throws Exception {
|
||||
for(String sql:java.util.List.of(migration(),unifiedMigration()))assertThat(sql)
|
||||
.contains("'DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER'")
|
||||
@DisplayName("货柜简单文件只保留目的港清关与DO,PU改为数字码")
|
||||
void shouldAllowExactlyTwoSimpleContainerDocumentTypes() throws Exception {
|
||||
assertThat(migration())
|
||||
.contains("'DESTINATION_CLEARANCE', 'DELIVERY_ORDER'")
|
||||
.contains("pickup_code VARCHAR(64) NULL")
|
||||
.contains("file_hash VARCHAR(64) NULL")
|
||||
.contains("completed_by BIGINT UNSIGNED NULL")
|
||||
.contains("completed_at DATETIME(3) NULL");
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.dto.ContainerOverviewDtos.ExceptionRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerOverviewDtos.PickupCodeRequest;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.mapper.ContainerOverviewMapper;
|
||||
import com.baiyue.firstleg.repository.ContainerRepository;
|
||||
@@ -154,6 +155,22 @@ class ContainerOverviewServiceTest {
|
||||
eq("operator"), eq("mark-1"), matches("[0-9a-f]{64}"));
|
||||
}
|
||||
|
||||
@Test void pickupCodePreservesLeadingZerosAndUsesContainerVersion() {
|
||||
ContainerBookingEntity locked = new ContainerBookingEntity(); locked.setId(41L); locked.setVersion(3);
|
||||
ContainerBookingEntity refreshed = new ContainerBookingEntity(); refreshed.setId(41L); refreshed.setVersion(4); refreshed.setPickupCode("00123456");
|
||||
when(containers.findActiveForUpdate(41L)).thenReturn(locked);
|
||||
when(containers.updatePickupCode(41L, "00123456", 3, 7L, "operator")).thenReturn(1);
|
||||
when(containers.find(41L)).thenReturn(refreshed);
|
||||
when(mapper.documents(41L)).thenReturn(java.util.List.of());
|
||||
when(mapper.exceptions(41L)).thenReturn(java.util.List.of());
|
||||
when(mapper.transport(41L)).thenReturn(java.util.List.of());
|
||||
|
||||
var result = service.updatePickupCode(41L, 7L, "operator", new PickupCodeRequest("00123456", 3));
|
||||
|
||||
assertThat(result.container().pickupCode()).isEqualTo("00123456");
|
||||
assertThat(result.container().version()).isEqualTo(4);
|
||||
}
|
||||
|
||||
@Test void exactReplayReturnsWithoutLockingOrWriting() {
|
||||
String hash = hash("MARK|41|3|broken seal");
|
||||
when(mapper.exceptionOperation(41L, 7L, "mark-1")).thenReturn(new ContainerOverviewMapper.ExceptionRow(
|
||||
|
||||
@@ -632,13 +632,13 @@ class LoadingListServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void genericContainerDocumentsOnlyAcceptDeliveryAndPickupOrders() {
|
||||
void genericContainerDocumentsOnlyAcceptDeliveryOrders() {
|
||||
assertThatThrownBy(() -> service.containerLogisticsDocument(30L,
|
||||
LoadingListService.DESTINATION_CLEARANCE)).isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("DELIVERY_ORDER或PICKUP_ORDER");
|
||||
.hasMessageContaining("DELIVERY_ORDER");
|
||||
assertThatThrownBy(() -> service.containerLogisticsDocument(30L,
|
||||
"UNKNOWN_DOCUMENT")).isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("DELIVERY_ORDER或PICKUP_ORDER");
|
||||
.hasMessageContaining("DELIVERY_ORDER");
|
||||
verify(repository, never()).aggregateByBooking(30L);
|
||||
}
|
||||
|
||||
@@ -670,36 +670,6 @@ class LoadingListServiceTest {
|
||||
eq("COMPLETED"), eq("COMPLETED"), anyString(), anyString(), eq(9L), eq("operator"), isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pickupOrderUpdateUsesCurrentVersionAndReturnsUpdatedDocument() {
|
||||
AggregateRow plan = row("COMPLETED", 4);
|
||||
byte[] content = "pickup-order".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
var file = new MockMultipartFile("file", "pu.pdf", "application/pdf", content);
|
||||
String contentHash = sha256Bytes(content);
|
||||
ContainerDocumentEntity current = document(52L, LoadingListService.PICKUP_ORDER, 2, false);
|
||||
ContainerDocumentEntity saved = document(52L, LoadingListService.PICKUP_ORDER, 3, false);
|
||||
saved.setFileId(102L); saved.setFileName("pu.pdf"); saved.setFileHash(contentHash);
|
||||
var metadata = new LoadingDocumentFileClient.Metadata(102L, 0L,
|
||||
"CONTAINER_PICKUP_ORDER", "30", "pu.pdf", contentHash, "application/pdf", (long) content.length);
|
||||
when(repository.aggregateByBooking(30L)).thenReturn(plan);
|
||||
when(repository.history(anyString())).thenReturn(null);
|
||||
when(repository.document(30L, LoadingListService.PICKUP_ORDER)).thenReturn(current, saved);
|
||||
when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("CONTAINER_PICKUP_ORDER"),
|
||||
eq("30"), eq(file))).thenReturn(ApiResponse.success(metadata));
|
||||
when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_PICKUP_ORDER"),
|
||||
eq("30"), eq(102L))).thenReturn(ApiResponse.success(metadata));
|
||||
when(repository.replaceDocument(any())).thenReturn(1);
|
||||
|
||||
var result = service.uploadContainerLogisticsDocument(30L, LoadingListService.PICKUP_ORDER,
|
||||
9L, "operator", "pu-update", 2, null, file);
|
||||
|
||||
assertThat(result.fileId()).isEqualTo(102L);
|
||||
assertThat(result.version()).isEqualTo(3);
|
||||
verify(repository).replaceDocument(any());
|
||||
verify(repository).appendHistory(eq(10L), anyString(), eq("UPLOAD_PICKUP_ORDER"),
|
||||
eq("COMPLETED"), eq("COMPLETED"), anyString(), anyString(), eq(9L), eq("operator"), isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleDeliveryOrderVersionReturnsConflictBeforeUploadingAnotherFile() {
|
||||
AggregateRow plan = row("COMPLETED", 4);
|
||||
|
||||
+2
-2
@@ -41,7 +41,7 @@ class OverseasContainerEventConsumerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void derivesEstimatedEmptyReportedAtFromPhotoUploadAndPersistsInbox() {
|
||||
void derivesEstimatedEmptyReportedAtFromUnloadCompletionAndPersistsInbox() {
|
||||
ContainerBookingEntity container = container();
|
||||
when(containers.selectActiveBookingForUpdate(1001L)).thenReturn(container);
|
||||
Instant completedAt = Instant.parse("2026-08-20T03:00:00Z");
|
||||
@@ -52,7 +52,7 @@ class OverseasContainerEventConsumerTest {
|
||||
|
||||
verify(containers).applyEmptyReport(eq(1001L), eq(LocalDateTime.parse("2026-08-20T03:00:00")),
|
||||
eq(LocalDateTime.parse("2026-08-20T02:30:00")),
|
||||
eq(LocalDateTime.parse("2026-08-22T02:30:00")), isNull(), eq(7L));
|
||||
eq(LocalDateTime.parse("2026-08-23T03:00:00")), isNull(), eq(7L));
|
||||
ArgumentCaptor<ContainerEventInboxEntity> captor = ArgumentCaptor.forClass(ContainerEventInboxEntity.class);
|
||||
verify(inbox).insert(captor.capture());
|
||||
assertThat(captor.getValue().getAggregateId()).isEqualTo("1001");
|
||||
|
||||
+25
-2
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.firstleg.terminaldrayage.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -8,6 +9,17 @@ import java.nio.file.Path;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class TerminalDrayageOpenApiContractTest {
|
||||
@Test
|
||||
void controllerUsesFrozenOverseasPermissionsWithoutGenericContainerFallback() throws Exception {
|
||||
assertPermission("views", "platform:firstleg:terminal-drayage:list");
|
||||
assertPermission("detail", "platform:firstleg:terminal-drayage:list");
|
||||
assertPermission("history", "platform:firstleg:terminal-drayage:list");
|
||||
assertPermission("selectFleet", "platform:firstleg:terminal-drayage:fleet:edit");
|
||||
assertPermission("schedulePickup", "platform:firstleg:terminal-drayage:pickup-appointment:edit");
|
||||
assertPermission("recordPickup", "platform:firstleg:terminal-drayage:pickup:confirm");
|
||||
assertPermission("recordEmptyReturn", "platform:firstleg:terminal-drayage:empty-return:confirm");
|
||||
}
|
||||
|
||||
@Test
|
||||
void freezesActionsPermissionsAndServerWarningFields() throws Exception {
|
||||
String yaml = Files.readString(Path.of("src/main/resources/openapi/terminal-drayage-v1.yaml"));
|
||||
@@ -16,9 +28,20 @@ class TerminalDrayageOpenApiContractTest {
|
||||
"/api/platform/v1/terminal-drayages/{containerBookingId}/pickup-appointment:",
|
||||
"/api/platform/v1/terminal-drayages/{containerBookingId}/pickup:",
|
||||
"/api/platform/v1/terminal-drayages/{containerBookingId}/empty-return:",
|
||||
"platform:firstleg:container:list", "platform:firstleg:container:edit",
|
||||
"platform:firstleg:terminal-drayage:list",
|
||||
"platform:firstleg:terminal-drayage:fleet:edit",
|
||||
"platform:firstleg:terminal-drayage:pickup-appointment:edit",
|
||||
"platform:firstleg:terminal-drayage:pickup:confirm",
|
||||
"platform:firstleg:terminal-drayage:empty-return:confirm",
|
||||
"expectedVersion", "Idempotency-Key", "estimatedEmptyReturnAt",
|
||||
"emptyReturnWarning", "deadlineAt", "overdueHours", "overdueDays");
|
||||
"emptyReturnWarning", "actualFacts", "deadlineAt", "overdueHours", "overdueDays");
|
||||
assertThat(yaml).contains("description: 卸柜完成时间加72小时");
|
||||
}
|
||||
|
||||
private static void assertPermission(String methodName, String permission) {
|
||||
var method = java.util.Arrays.stream(PlatformTerminalDrayageController.class.getDeclaredMethods())
|
||||
.filter(value -> methodName.equals(value.getName())).findFirst().orElseThrow();
|
||||
assertThat(method.getAnnotation(PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('" + permission + "')");
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -22,7 +22,8 @@ class TerminalDrayageSqlContractTest {
|
||||
"服务端按卸柜完成时间加72小时计算的预计还柜时间");
|
||||
assertThat(sql).doesNotContain("ON DELETE CASCADE");
|
||||
assertThat(mapper).contains("WHERE b.id=#{containerBookingId} FOR UPDATE",
|
||||
"WHERE container_booking_id=#{value.containerBookingId} AND version=#{expectedVersion}");
|
||||
"WHERE container_booking_id=#{value.containerBookingId} AND version=#{expectedVersion}")
|
||||
.doesNotContain("fl_carrier_schedule_query", "fl_carrier_schedule_node_fact");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package com.baiyue.firstleg.terminaldrayage.service;
|
||||
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.Query;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.WorkbenchScope;
|
||||
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.TerminalDrayageDtos.View;
|
||||
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.Warning;
|
||||
import com.baiyue.firstleg.terminaldrayage.service.TerminalDrayageService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class ContainerWorkbenchServiceTest {
|
||||
private ContainerRepository repository;
|
||||
private CarrierScheduleQueryService carrierSchedules;
|
||||
private TerminalDrayageService terminalDrayages;
|
||||
private ContainerWorkbenchService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
repository = mock(ContainerRepository.class);
|
||||
carrierSchedules = mock(CarrierScheduleQueryService.class);
|
||||
terminalDrayages = mock(TerminalDrayageService.class);
|
||||
service = new ContainerWorkbenchService();
|
||||
ReflectionTestUtils.setField(service, "repository", repository);
|
||||
ReflectionTestUtils.setField(service, "carrierSchedules", carrierSchedules);
|
||||
ReflectionTestUtils.setField(service, "terminalDrayages", terminalDrayages);
|
||||
}
|
||||
|
||||
@Test
|
||||
void domesticPageKeepsServerPaginationAndUsesOneActualFactBatch() {
|
||||
ContainerBookingEntity container = container();
|
||||
container.setPlanStatus("COMPLETED");
|
||||
container.setCustomsDeclarationDocumentStatus("COMPLETED");
|
||||
when(repository.page(eq("DOMESTIC"), any())).thenReturn(new PageResult<>(List.of(container), 1, 20, 1, 1));
|
||||
when(carrierSchedules.containerActualFacts(List.of(11L))).thenReturn(List.of());
|
||||
|
||||
var result = service.domesticPage(query(WorkbenchScope.ACTIVE), Set.of(
|
||||
"platform:firstleg:customs:manage", "platform:firstleg:outbound-dispatch:confirm"));
|
||||
|
||||
assertThat(result.total()).isEqualTo(1);
|
||||
assertThat(result.records().get(0).workbenchStage()).isEqualTo("WAIT_OUTBOUND");
|
||||
assertThat(result.records().get(0).actualFacts()).isNull();
|
||||
assertThat(result.records().get(0).allowedActions())
|
||||
.containsExactly("MANAGE_CUSTOMS", "CONFIRM_OUTBOUND");
|
||||
verify(carrierSchedules, times(1)).containerActualFacts(List.of(11L));
|
||||
verifyNoInteractions(terminalDrayages);
|
||||
}
|
||||
|
||||
@Test
|
||||
void overseasPageConsumesTerminalAggregateOnceAndFiltersActionsByPermission() {
|
||||
ContainerBookingEntity container = container();
|
||||
container.setUnloadingCompletedAt(LocalDateTime.of(2026, 9, 5, 8, 0));
|
||||
ContainerActualFacts facts = facts("PICKED_UP");
|
||||
View raw = new View(11L, "MSCU1234567", 21L, "UNLOADED", 3, 91L, "车队",
|
||||
LocalDateTime.of(2026, 9, 4, 8, 0), LocalDateTime.of(2026, 9, 4, 9, 0),
|
||||
LocalDateTime.of(2026, 9, 8, 8, 0), null, container.getUnloadingCompletedAt(),
|
||||
new Warning("PENDING", LocalDateTime.of(2026, 9, 8, 8, 0), 0, 0),
|
||||
List.of("RECORD_EMPTY_RETURN"), facts, LocalDateTime.of(2026, 9, 5, 9, 0));
|
||||
Set<String> permissions = Set.of("platform:firstleg:terminal-drayage:empty-return:confirm");
|
||||
when(repository.page(eq("OVERSEAS"), any())).thenReturn(new PageResult<>(List.of(container), 1, 20, 1, 1));
|
||||
when(terminalDrayages.views(List.of(11L))).thenReturn(List.of(raw));
|
||||
when(terminalDrayages.authorize(List.of(raw), permissions)).thenReturn(List.of(raw));
|
||||
|
||||
var result = service.overseasPage(query(WorkbenchScope.ACTIVE), permissions);
|
||||
|
||||
assertThat(result.records().get(0).workbenchStage()).isEqualTo("UNLOADED");
|
||||
assertThat(result.records().get(0).actualFacts()).isSameAs(facts);
|
||||
assertThat(result.records().get(0).allowedActions()).containsExactly("RECORD_EMPTY_RETURN");
|
||||
verify(terminalDrayages, times(1)).views(List.of(11L));
|
||||
verifyNoInteractions(carrierSchedules);
|
||||
}
|
||||
|
||||
private static Query query(WorkbenchScope scope) {
|
||||
return new Query(null, null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, scope, 1, 20);
|
||||
}
|
||||
|
||||
private static ContainerBookingEntity container() {
|
||||
ContainerBookingEntity value = new ContainerBookingEntity();
|
||||
value.setId(11L); value.setContainerNo("MSCU1234567"); value.setPlanId(31L);
|
||||
value.setPlanNo("LP-260908-0001"); value.setPlanStatus("COMPLETED");
|
||||
value.setDestinationClearanceDocumentStatus("MISSING");
|
||||
value.setDeliveryOrderDocumentStatus("MISSING"); value.setOutboundDispatchStatus("NOT_CREATED");
|
||||
return value;
|
||||
}
|
||||
|
||||
private static ContainerActualFacts facts(String node) {
|
||||
LocalDateTime now = LocalDateTime.of(2026, 9, 4, 9, 0);
|
||||
return new ContainerActualFacts(11L, 41L, 3, "READY", node, now,
|
||||
now.minusDays(5), now.minusDays(4), now.minusDays(1), now.minusHours(2), now, null);
|
||||
}
|
||||
}
|
||||
+46
-3
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.terminaldrayage.service;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.client.WarehouseSupplierClient;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
|
||||
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
|
||||
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
|
||||
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.ActualTimeRequest;
|
||||
@@ -21,6 +22,9 @@ import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -41,12 +45,18 @@ class TerminalDrayageServiceTest {
|
||||
private WarehouseSupplierSnapshotService suppliers;
|
||||
private CarrierScheduleQueryService carrierSchedules;
|
||||
private TerminalDrayageService service;
|
||||
private Deque<ContainerActualFacts> facts;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mapper = mock(TerminalDrayageMapper.class);
|
||||
suppliers = mock(WarehouseSupplierSnapshotService.class);
|
||||
carrierSchedules = mock(CarrierScheduleQueryService.class);
|
||||
facts = new ArrayDeque<>();
|
||||
when(carrierSchedules.containerActualFacts(any())).thenAnswer(ignored -> {
|
||||
if (facts.isEmpty()) return List.of();
|
||||
return List.of(facts.size() > 1 ? facts.removeFirst() : facts.peekFirst());
|
||||
});
|
||||
service = new TerminalDrayageService(mapper, suppliers, carrierSchedules, new ObjectMapper().findAndRegisterModules());
|
||||
service.setClock(Clock.fixed(Instant.parse("2026-09-05T02:00:00Z"), ZoneOffset.UTC));
|
||||
}
|
||||
@@ -167,6 +177,32 @@ class TerminalDrayageServiceTest {
|
||||
assertThat(result.allowedActions()).doesNotContain("RECORD_EMPTY_RETURN");
|
||||
}
|
||||
|
||||
@Test
|
||||
void bulkViewOmitsContainerWhenActualFactPortHasNoTrackingRecord() {
|
||||
when(mapper.contexts(List.of(11L))).thenReturn(List.of(
|
||||
new Context(11L, "MSCU1234567", 21L, "ACTIVE", null)));
|
||||
when(mapper.findAll(List.of(11L))).thenReturn(List.of());
|
||||
|
||||
assertThat(service.views(List.of(11L))).isEmpty();
|
||||
verify(carrierSchedules).containerActualFacts(List.of(11L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void allowedActionsAreReducedByFrozenPermissionMatrix() {
|
||||
Context context = context("PICKUP_READY", null);
|
||||
TerminalDrayageEntity row = row(3);
|
||||
row.setPickupAppointmentAt(LocalDateTime.of(2026, 9, 2, 9, 0));
|
||||
when(mapper.context(11L)).thenReturn(context);
|
||||
when(mapper.find(11L)).thenReturn(row);
|
||||
|
||||
var raw = service.detail(11L);
|
||||
var authorized = service.authorize(raw,
|
||||
Set.of("platform:firstleg:terminal-drayage:pickup:confirm"));
|
||||
|
||||
assertThat(raw.allowedActions()).contains("SELECT_FLEET", "SCHEDULE_PICKUP", "RECORD_PICKUP");
|
||||
assertThat(authorized.allowedActions()).containsExactly("RECORD_PICKUP");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameIdempotencyKeyWithDifferentPayloadConflictsWithoutWriting() {
|
||||
Context context = context("ARRIVED_DESTINATION_PORT", null);
|
||||
@@ -249,10 +285,17 @@ class TerminalDrayageServiceTest {
|
||||
case "ARRIVED_DESTINATION_PORT" -> 3;
|
||||
default -> 2;
|
||||
};
|
||||
return new Context(11L, "MSCU1234567", 21L, "ACTIVE", unloadedAt,
|
||||
31L, node, ARRIVED_AT, sequenceVersion, ARRIVED_AT,
|
||||
facts.addLast(new ContainerActualFacts(11L, 31L, sequenceVersion, "READY", node, ARRIVED_AT,
|
||||
ARRIVED_AT.minusDays(5), ARRIVED_AT.minusDays(4),
|
||||
switch (node) {
|
||||
case "IN_TRANSIT" -> null;
|
||||
default -> ARRIVED_AT;
|
||||
},
|
||||
"PICKUP_READY".equals(node) || "PICKED_UP".equals(node) || "EMPTY_RETURNED".equals(node)
|
||||
? ARRIVED_AT.plusHours(2) : null,
|
||||
"PICKED_UP".equals(node) || "EMPTY_RETURNED".equals(node) ? ARRIVED_AT.plusDays(1) : null,
|
||||
"EMPTY_RETURNED".equals(node) ? ARRIVED_AT.plusDays(3) : null);
|
||||
"EMPTY_RETURNED".equals(node) ? ARRIVED_AT.plusDays(3) : null));
|
||||
return new Context(11L, "MSCU1234567", 21L, "ACTIVE", unloadedAt);
|
||||
}
|
||||
|
||||
private TerminalDrayageEntity row(int version) {
|
||||
|
||||
Reference in New Issue
Block a user