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.common.security.AuthenticatedUser;
|
||||||
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
|
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
|
||||||
import com.baiyue.firstleg.dto.ContainerDtos.Query;
|
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.SimulationCreateRequest;
|
||||||
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest;
|
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest;
|
||||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||||
import com.baiyue.firstleg.service.ContainerService;
|
import com.baiyue.firstleg.service.ContainerService;
|
||||||
|
import com.baiyue.firstleg.terminaldrayage.service.ContainerWorkbenchService;
|
||||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||||
import com.baiyue.firstleg.service.LoadingListService;
|
import com.baiyue.firstleg.service.LoadingListService;
|
||||||
import com.baiyue.firstleg.dto.LoadingListDtos.BusinessDocument;
|
import com.baiyue.firstleg.dto.LoadingListDtos.BusinessDocument;
|
||||||
@@ -39,21 +41,50 @@ import org.springframework.web.multipart.MultipartFile;
|
|||||||
@RequestMapping("/api/platform/v1/containers")
|
@RequestMapping("/api/platform/v1/containers")
|
||||||
public class PlatformContainerController {
|
public class PlatformContainerController {
|
||||||
@Resource private ContainerService service;
|
@Resource private ContainerService service;
|
||||||
|
@Resource private ContainerWorkbenchService workbenches;
|
||||||
@Resource private LoadingListService loadingLists;
|
@Resource private LoadingListService loadingLists;
|
||||||
@Resource private ContainerOverviewService overview;
|
@Resource private ContainerOverviewService overview;
|
||||||
@GetMapping @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
|
@GetMapping("/domestic") @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
|
||||||
public ApiResponse<PageResult<ContainerBookingEntity>> page(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<PageResult<WorkbenchItem>> domesticPage(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@Valid Query query) {
|
@Valid Query query) {
|
||||||
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.page(query));
|
FirstlegScopeGuard.platform(user); return ApiResponse.success(workbenches.domesticPage(query, user.permissions()));
|
||||||
}
|
}
|
||||||
@GetMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
|
@GetMapping("/overseas") @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
|
||||||
public ApiResponse<ContainerBookingEntity> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<PageResult<WorkbenchItem>> overseasPage(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@PathVariable @Positive long id) {
|
@Valid Query query) {
|
||||||
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(id));
|
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')")
|
@GetMapping("/{id}/overview") @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
|
||||||
public ApiResponse<Overview> overview(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id) {
|
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")
|
@GetMapping("/{id}/pda/photos/{fileId}/download")
|
||||||
@PreAuthorize("hasAuthority('platform:firstleg:container:list')")
|
@PreAuthorize("hasAuthority('platform:firstleg:container:list')")
|
||||||
@@ -61,6 +92,7 @@ public class PlatformContainerController {
|
|||||||
@PathVariable @Positive long id,
|
@PathVariable @Positive long id,
|
||||||
@PathVariable @Positive long fileId) {
|
@PathVariable @Positive long fileId) {
|
||||||
FirstlegScopeGuard.platform(user);
|
FirstlegScopeGuard.platform(user);
|
||||||
|
service.requireDomestic(id);
|
||||||
var value = overview.downloadPhoto(id, fileId);
|
var value = overview.downloadPhoto(id, fileId);
|
||||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
|
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
|
||||||
.header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.inline()
|
.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,
|
public ApiResponse<Void> markException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,
|
||||||
@RequestHeader("Idempotency-Key") String key,
|
@RequestHeader("Idempotency-Key") String key,
|
||||||
@Valid @RequestBody ExceptionRequest request) {
|
@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')")
|
@DeleteMapping("/{id}/exception") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
|
||||||
public ApiResponse<Void> cancelException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,
|
public ApiResponse<Void> cancelException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,
|
||||||
@RequestHeader("Idempotency-Key") String key,
|
@RequestHeader("Idempotency-Key") String key,
|
||||||
@Valid @RequestBody ExceptionRequest request) {
|
@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')")
|
@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);return ApiResponse.success(loadingLists.containerLogisticsDocument(id,documentType));}
|
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:container:edit')")
|
@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);return ApiResponse.success(loadingLists.uploadContainerLogisticsDocument(id,documentType,user.userId(),user.username(),key,version,reason,file));}
|
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:container:list')")
|
@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);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());}
|
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')")
|
@PostMapping @PreAuthorize("hasAuthority('platform:firstleg:container:create')")
|
||||||
@BusinessAudit(event="CONTAINER_CREATE", resourceType="CONTAINER", description="新增货柜基础资料", captureBefore=false)
|
@BusinessAudit(event="CONTAINER_CREATE", resourceType="CONTAINER", description="新增货柜基础资料", captureBefore=false)
|
||||||
public ApiResponse<ContainerBookingEntity> create(@AuthenticationPrincipal AuthenticatedUser user,
|
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));
|
return ApiResponse.success(service.updateDraft(id, user.userId(), user.username(), request));
|
||||||
}
|
}
|
||||||
@GetMapping("/{id}/clearance")
|
@GetMapping("/{id}/clearance")
|
||||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
|
||||||
@Operation(summary = "查询货柜目的港清关资料")
|
@Operation(summary = "查询货柜目的港清关资料")
|
||||||
public ApiResponse<BusinessDocument> clearance(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<BusinessDocument> clearance(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@PathVariable @Positive long id) {
|
@PathVariable @Positive long id) {
|
||||||
FirstlegScopeGuard.platform(user);
|
FirstlegScopeGuard.platform(user);
|
||||||
|
service.requireOverseas(id);
|
||||||
return ApiResponse.success(loadingLists.containerClearance(id));
|
return ApiResponse.success(loadingLists.containerClearance(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}/clearance/document/{fileId}/download")
|
@GetMapping("/{id}/clearance/document/{fileId}/download")
|
||||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
|
||||||
@Operation(summary = "下载货柜目的港清关附件", description = "校验当前清关附件和 File 业务归属,完成态仍可下载。")
|
@Operation(summary = "下载货柜目的港清关附件", description = "校验当前清关附件和 File 业务归属,完成态仍可下载。")
|
||||||
public ResponseEntity<byte[]> downloadClearance(
|
public ResponseEntity<byte[]> downloadClearance(
|
||||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id,
|
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id,
|
||||||
@PathVariable @Positive long fileId) {
|
@PathVariable @Positive long fileId) {
|
||||||
FirstlegScopeGuard.platform(user);
|
FirstlegScopeGuard.platform(user);
|
||||||
|
service.requireOverseas(id);
|
||||||
var download = loadingLists.downloadContainerClearance(id, fileId);
|
var download = loadingLists.downloadContainerClearance(id, fileId);
|
||||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
|
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
|
||||||
.header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.attachment()
|
.header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.attachment()
|
||||||
@@ -128,19 +162,20 @@ public class PlatformContainerController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/clearance/document")
|
@PostMapping("/{id}/clearance/document")
|
||||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:document:edit')")
|
||||||
@Operation(summary = "上传或替换货柜目的港清关资料")
|
@Operation(summary = "上传或替换货柜目的港清关资料")
|
||||||
public ApiResponse<BusinessDocument> attachClearance(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<BusinessDocument> attachClearance(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@PathVariable @Positive long id,
|
@PathVariable @Positive long id,
|
||||||
@RequestHeader("Idempotency-Key") String key,
|
@RequestHeader("Idempotency-Key") String key,
|
||||||
@Valid @RequestBody DocumentAttachRequest request) {
|
@Valid @RequestBody DocumentAttachRequest request) {
|
||||||
FirstlegScopeGuard.platform(user);
|
FirstlegScopeGuard.platform(user);
|
||||||
|
service.requireOverseas(id);
|
||||||
return ApiResponse.success(loadingLists.attachContainerClearance(
|
return ApiResponse.success(loadingLists.attachContainerClearance(
|
||||||
id, user.userId(), user.username(), key, request));
|
id, user.userId(), user.username(), key, request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping(value = "/{id}/clearance/document/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
@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 = "直接上传或替换货柜目的港清关资料")
|
@Operation(summary = "直接上传或替换货柜目的港清关资料")
|
||||||
public ApiResponse<BusinessDocument> uploadClearance(
|
public ApiResponse<BusinessDocument> uploadClearance(
|
||||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id,
|
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id,
|
||||||
@@ -149,18 +184,20 @@ public class PlatformContainerController {
|
|||||||
@RequestParam("expectedVersion") @Min(0) int expectedVersion,
|
@RequestParam("expectedVersion") @Min(0) int expectedVersion,
|
||||||
@RequestParam(value = "operationReason", required = false) @Size(max = 500) String operationReason) {
|
@RequestParam(value = "operationReason", required = false) @Size(max = 500) String operationReason) {
|
||||||
FirstlegScopeGuard.platform(user);
|
FirstlegScopeGuard.platform(user);
|
||||||
|
service.requireOverseas(id);
|
||||||
return ApiResponse.success(loadingLists.uploadContainerClearance(
|
return ApiResponse.success(loadingLists.uploadContainerClearance(
|
||||||
id, user.userId(), user.username(), key, expectedVersion, operationReason, file));
|
id, user.userId(), user.username(), key, expectedVersion, operationReason, file));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/clearance/complete")
|
@PostMapping("/{id}/clearance/complete")
|
||||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:document:edit')")
|
||||||
@Operation(summary = "确认货柜目的港清关完成")
|
@Operation(summary = "确认货柜目的港清关完成")
|
||||||
public ApiResponse<BusinessDocument> completeClearance(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<BusinessDocument> completeClearance(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@PathVariable @Positive long id,
|
@PathVariable @Positive long id,
|
||||||
@RequestHeader("Idempotency-Key") String key,
|
@RequestHeader("Idempotency-Key") String key,
|
||||||
@Valid @RequestBody DocumentCompleteRequest request) {
|
@Valid @RequestBody DocumentCompleteRequest request) {
|
||||||
FirstlegScopeGuard.platform(user);
|
FirstlegScopeGuard.platform(user);
|
||||||
|
service.requireOverseas(id);
|
||||||
return ApiResponse.success(loadingLists.completeContainerClearance(
|
return ApiResponse.success(loadingLists.completeContainerClearance(
|
||||||
id, user.userId(), user.username(), key, request));
|
id, user.userId(), user.username(), key, request));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import java.time.LocalDateTime;
|
|||||||
public final class ContainerDtos {
|
public final class ContainerDtos {
|
||||||
private ContainerDtos() {}
|
private ContainerDtos() {}
|
||||||
|
|
||||||
|
public enum WorkbenchScope { ACTIVE, HISTORY }
|
||||||
|
|
||||||
@Schema(name = "ContainerQuery", description = "货柜分页筛选条件")
|
@Schema(name = "ContainerQuery", description = "货柜分页筛选条件")
|
||||||
public record Query(
|
public record Query(
|
||||||
@Size(max = 64) String containerNo,
|
@Size(max = 64) String containerNo,
|
||||||
@@ -35,6 +37,7 @@ public final class ContainerDtos {
|
|||||||
SmartLoadingStatus status,
|
SmartLoadingStatus status,
|
||||||
LocalDateTime estimatedSailingFrom,
|
LocalDateTime estimatedSailingFrom,
|
||||||
LocalDateTime estimatedSailingTo,
|
LocalDateTime estimatedSailingTo,
|
||||||
|
@NotNull WorkbenchScope scope,
|
||||||
@Min(1) long page,
|
@Min(1) long page,
|
||||||
@Min(1) @Max(200) long size
|
@Min(1) @Max(200) long size
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import jakarta.validation.constraints.NotBlank;
|
|||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
import jakarta.validation.constraints.Min;
|
import jakarta.validation.constraints.Min;
|
||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -21,7 +22,8 @@ public final class ContainerOverviewDtos {
|
|||||||
LocalDateTime estimatedWarehouseArrivalAt, LocalDateTime actualWarehouseArrivalAt,
|
LocalDateTime estimatedWarehouseArrivalAt, LocalDateTime actualWarehouseArrivalAt,
|
||||||
LocalDateTime unloadingCompletedAt, LocalDateTime unloadingPhotoUploadedAt,
|
LocalDateTime unloadingCompletedAt, LocalDateTime unloadingPhotoUploadedAt,
|
||||||
LocalDateTime estimatedEmptyReportedAt, LocalDateTime actualEmptyReportedAt,
|
LocalDateTime estimatedEmptyReportedAt, LocalDateTime actualEmptyReportedAt,
|
||||||
Long emptyReportSourceVersion,
|
Long emptyReportSourceVersion, String pickupCode, Long pickupCodeUpdatedBy,
|
||||||
|
String pickupCodeUpdatedByName, LocalDateTime pickupCodeUpdatedAt,
|
||||||
Integer version) {}
|
Integer version) {}
|
||||||
public record PdaSummary(Long taskId, Long loadingPlanId, Long containerBookingId, String taskStatus,
|
public record PdaSummary(Long taskId, Long loadingPlanId, Long containerBookingId, String taskStatus,
|
||||||
String workflowStatus, Integer taskVersion, Integer itemCount, Integer completedItemCount, Integer unresolvedItemCount,
|
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 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 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 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 estimatedEmptyReportedAt;
|
||||||
private LocalDateTime actualEmptyReportedAt;
|
private LocalDateTime actualEmptyReportedAt;
|
||||||
private Long emptyReportSourceVersion;
|
private Long emptyReportSourceVersion;
|
||||||
|
private String pickupCode;
|
||||||
|
private Long pickupCodeUpdatedBy;
|
||||||
|
private String pickupCodeUpdatedByName;
|
||||||
|
private LocalDateTime pickupCodeUpdatedAt;
|
||||||
private Long createdBy;
|
private Long createdBy;
|
||||||
private String createdByName;
|
private String createdByName;
|
||||||
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
|
@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 destinationClearanceDocumentStatus;
|
||||||
@TableField(exist = false) private String customsDeclarationDocumentStatus;
|
@TableField(exist = false) private String customsDeclarationDocumentStatus;
|
||||||
@TableField(exist = false) private String deliveryOrderDocumentStatus;
|
@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 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
|
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,
|
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
|
COALESCE((SELECT CASE WHEN MIN(d.confirmed_at) IS NOT NULL
|
||||||
AND d.document_type='PICKUP_ORDER' LIMIT 1),'MISSING') AS pickup_order_document_status,
|
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'
|
CASE WHEN p.id IS NULL THEN 'WAIT_LOADING'
|
||||||
WHEN p.plan_status != 'COMPLETED' AND NOT EXISTS (
|
WHEN p.plan_status != 'COMPLETED' AND NOT EXISTS (
|
||||||
SELECT 1 FROM fl_loading_plan_item active_item
|
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})
|
ELSE p.plan_status END = #{query.status})
|
||||||
AND (#{query.estimatedSailingFrom} IS NULL OR b.estimated_sailing_at >= #{query.estimatedSailingFrom})
|
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 (#{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}, '%')
|
AND (#{query.keyword} IS NULL OR b.container_no LIKE CONCAT('%', #{query.keyword}, '%')
|
||||||
OR b.cabinet_no LIKE CONCAT('%', #{query.keyword}, '%')
|
OR b.cabinet_no LIKE CONCAT('%', #{query.keyword}, '%')
|
||||||
OR b.bill_of_lading_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
|
ORDER BY b.updated_at DESC, b.id DESC
|
||||||
</script>
|
</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("""
|
||||||
SELECT b.*, p.id AS plan_id, p.plan_no AS plan_no,
|
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 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
|
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,
|
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
|
COALESCE((SELECT CASE WHEN MIN(d.confirmed_at) IS NOT NULL
|
||||||
AND d.document_type='PICKUP_ORDER' LIMIT 1),'MISSING') AS pickup_order_document_status,
|
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'
|
CASE WHEN p.id IS NULL THEN 'WAIT_LOADING'
|
||||||
WHEN p.plan_status != 'COMPLETED' AND NOT EXISTS (
|
WHEN p.plan_status != 'COMPLETED' AND NOT EXISTS (
|
||||||
SELECT 1 FROM fl_loading_plan_item active_item
|
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")
|
@Select("SELECT * FROM fl_container_booking WHERE id=#{id} AND record_status='ACTIVE' FOR UPDATE")
|
||||||
ContainerBookingEntity selectActiveBookingForUpdate(@Param("id") long id);
|
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'")
|
@Select("SELECT * FROM fl_container_booking WHERE container_no=#{containerNo} AND record_status='ACTIVE'")
|
||||||
ContainerBookingEntity selectActiveByContainerNo(@Param("containerNo") String containerNo);
|
ContainerBookingEntity selectActiveByContainerNo(@Param("containerNo") String containerNo);
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import com.baiyue.firstleg.entity.ContainerDocumentEntity;
|
|||||||
import org.apache.ibatis.annotations.*;
|
import org.apache.ibatis.annotations.*;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@Mapper public interface ContainerOverviewMapper {
|
@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("""
|
||||||
SELECT history.id,plan.container_booking_id containerBookingId,
|
SELECT history.id,plan.container_booking_id containerBookingId,
|
||||||
JSON_UNQUOTE(JSON_EXTRACT(history.before_snapshot,'$.containerNo')) beforeContainerNo,
|
JSON_UNQUOTE(JSON_EXTRACT(history.before_snapshot,'$.containerNo')) beforeContainerNo,
|
||||||
|
|||||||
@@ -12,15 +12,16 @@ import org.springframework.stereotype.Repository;
|
|||||||
@Repository
|
@Repository
|
||||||
public class ContainerRepository {
|
public class ContainerRepository {
|
||||||
@Resource private ContainerBookingMapper mapper;
|
@Resource private ContainerBookingMapper mapper;
|
||||||
public PageResult<ContainerBookingEntity> page(Query query) {
|
public PageResult<ContainerBookingEntity> page(String workbench, Query query) {
|
||||||
Page<ContainerBookingEntity> result = mapper.selectBookingPage(
|
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);
|
result.getRecords().forEach(this::resolveLoadingTime);
|
||||||
return PageResult.from(result);
|
return PageResult.from(result);
|
||||||
}
|
}
|
||||||
public ContainerBookingEntity find(long id) { return resolveLoadingTime(mapper.selectBooking(id)); }
|
public ContainerBookingEntity find(long id) { return resolveLoadingTime(mapper.selectBooking(id)); }
|
||||||
public ContainerBookingEntity findForUpdate(long id) { return mapper.selectBookingForUpdate(id); }
|
public ContainerBookingEntity findForUpdate(long id) { return mapper.selectBookingForUpdate(id); }
|
||||||
public ContainerBookingEntity findActiveForUpdate(long id) { return mapper.selectActiveBookingForUpdate(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) {
|
public boolean containerNoExists(String no, Long excludedId) {
|
||||||
if (no == null) return false;
|
if (no == null) return false;
|
||||||
return mapper.selectCount(new LambdaQueryWrapper<ContainerBookingEntity>()
|
return mapper.selectCount(new LambdaQueryWrapper<ContainerBookingEntity>()
|
||||||
@@ -45,6 +46,9 @@ public class ContainerRepository {
|
|||||||
public int updateDraft(ContainerBookingEntity value, int version, long operatorId, String operatorName) {
|
public int updateDraft(ContainerBookingEntity value, int version, long operatorId, String operatorName) {
|
||||||
return mapper.updateDraft(value, version, operatorId, 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 hasLoadingPlan(long id) { return mapper.hasLoadingPlan(id) > 0; }
|
||||||
public boolean hasActiveLoadingItems(long id) { return mapper.hasActiveLoadingItems(id) > 0; }
|
public boolean hasActiveLoadingItems(long id) { return mapper.hasActiveLoadingItems(id) > 0; }
|
||||||
public boolean hasPickTask(long id) { return mapper.hasPickTask(id) > 0; }
|
public boolean hasPickTask(long id) { return mapper.hasPickTask(id) > 0; }
|
||||||
|
|||||||
@@ -51,7 +51,9 @@ public class ContainerOverviewService {
|
|||||||
booking.getActualPortArrivalAt(), booking.getEstimatedWarehouseArrivalAt(),
|
booking.getActualPortArrivalAt(), booking.getEstimatedWarehouseArrivalAt(),
|
||||||
booking.getActualWarehouseArrivalAt(), booking.getUnloadingCompletedAt(),
|
booking.getActualWarehouseArrivalAt(), booking.getUnloadingCompletedAt(),
|
||||||
booking.getUnloadingPhotoUploadedAt(), booking.getEstimatedEmptyReportedAt(),
|
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,
|
task == null ? booking.getPlanId() : task.loadingPlanId(), pda, documents,
|
||||||
new ExceptionState(latestException != null && "MARK".equals(latestException.action()),
|
new ExceptionState(latestException != null && "MARK".equals(latestException.action()),
|
||||||
latestException == null ? null : latestException.reason(),
|
latestException == null ? null : latestException.reason(),
|
||||||
@@ -72,6 +74,18 @@ public class ContainerOverviewService {
|
|||||||
changeException(id, operatorId, operatorName, idempotencyKey, request, "CANCEL");
|
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,
|
private void changeException(long id, long operatorId, String operatorName, String rawKey,
|
||||||
ExceptionRequest request, String action) {
|
ExceptionRequest request, String action) {
|
||||||
String operationId = operationId(rawKey);
|
String operationId = operationId(rawKey);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.baiyue.firstleg.service;
|
package com.baiyue.firstleg.service;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
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.ApiResponse;
|
||||||
import com.baiyue.common.core.api.ResultCode;
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
import com.baiyue.common.core.exception.BizException;
|
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.SmartLoadingStatus;
|
||||||
import com.baiyue.firstleg.domain.ContainerSpecificationCatalog;
|
import com.baiyue.firstleg.domain.ContainerSpecificationCatalog;
|
||||||
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
|
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.SaveRequest;
|
||||||
import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest;
|
import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest;
|
||||||
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest;
|
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest;
|
||||||
@@ -41,11 +39,15 @@ public class ContainerService {
|
|||||||
@Resource private SmartLoadingService smartLoadingService;
|
@Resource private SmartLoadingService smartLoadingService;
|
||||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||||
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
|
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
|
||||||
public PageResult<ContainerBookingEntity> page(Query query) {
|
public void requireOverseas(long id) {
|
||||||
validateRange(query.estimatedSailingFrom(), query.estimatedSailingTo(), "预计船期筛选");
|
required(id);
|
||||||
return repository.page(normalize(query));
|
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
|
@Transactional
|
||||||
public ContainerBookingEntity createSimulation(long operatorId, String operatorName, SimulationCreateRequest request) {
|
public ContainerBookingEntity createSimulation(long operatorId, String operatorName, SimulationCreateRequest request) {
|
||||||
@@ -220,12 +222,6 @@ public class ContainerService {
|
|||||||
if (from != null && to != null && to.isBefore(from))
|
if (from != null && to != null && to.isBefore(from))
|
||||||
throw new BizException(ResultCode.VALIDATION_FAILED, label + "先后顺序不正确");
|
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) {
|
private static String trim(String value) {
|
||||||
if (value == null) return null;
|
if (value == null) return null;
|
||||||
String result = value.trim(); return result.isEmpty() ? null : result;
|
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");
|
private static final Set<String> PRELOAD_STATUSES = Set.of("EDITING", "LOADING");
|
||||||
public static final String DESTINATION_CLEARANCE = "DESTINATION_CLEARANCE";
|
public static final String DESTINATION_CLEARANCE = "DESTINATION_CLEARANCE";
|
||||||
public static final String DELIVERY_ORDER = "DELIVERY_ORDER";
|
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 CLEARANCE_FILE_TYPE = "CONTAINER_CLEARANCE_DOCUMENT";
|
||||||
private static final String DO_FILE_TYPE = "CONTAINER_DELIVERY_ORDER";
|
private static final String DO_FILE_TYPE = "CONTAINER_DELIVERY_ORDER";
|
||||||
private static final String PU_FILE_TYPE = "CONTAINER_PICKUP_ORDER";
|
|
||||||
@Resource
|
@Resource
|
||||||
private LoadingListRepository repository;
|
private LoadingListRepository repository;
|
||||||
@Resource
|
@Resource
|
||||||
@@ -190,8 +188,8 @@ public class LoadingListService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
public record DocumentDownload(String fileName,MediaType contentType,byte[] content) {}
|
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 void requireLogisticsType(String type){ if(!DELIVERY_ORDER.equals(type)) throw badRequest("documentType只能是DELIVERY_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 String fileType(String type){ if (DELIVERY_ORDER.equals(type)) return DO_FILE_TYPE; throw badRequest("documentType只能是DELIVERY_ORDER"); }
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@BusinessAudit(event = "CONTAINER_CLEARANCE_DOCUMENT_ATTACH", resourceType = "CONTAINER",
|
@BusinessAudit(event = "CONTAINER_CLEARANCE_DOCUMENT_ATTACH", resourceType = "CONTAINER",
|
||||||
|
|||||||
+3
-2
@@ -63,8 +63,9 @@ public class OverseasContainerEventConsumer {
|
|||||||
|
|
||||||
ContainerBookingEntity container = requiredContainer(payload.sourceContainerBookingId(), payload.containerNo());
|
ContainerBookingEntity container = requiredContainer(payload.sourceContainerBookingId(), payload.containerNo());
|
||||||
LocalDateTime uploadedAt = utc(payload.unloadingPhotoUploadedAt());
|
LocalDateTime uploadedAt = utc(payload.unloadingPhotoUploadedAt());
|
||||||
containers.applyEmptyReport(container.getId(), utc(payload.unloadingCompletedAt()), uploadedAt,
|
LocalDateTime unloadingCompletedAt = utc(payload.unloadingCompletedAt());
|
||||||
uploadedAt.plusDays(2), utc(payload.actualEmptyReportedAt()), event.aggregateVersion());
|
containers.applyEmptyReport(container.getId(), unloadingCompletedAt, uploadedAt,
|
||||||
|
unloadingCompletedAt.plusHours(72), utc(payload.actualEmptyReportedAt()), event.aggregateVersion());
|
||||||
row.setStatus("PROCESSED");
|
row.setStatus("PROCESSED");
|
||||||
row.setProcessedAt(LocalDateTime.now(ZoneOffset.UTC));
|
row.setProcessedAt(LocalDateTime.now(ZoneOffset.UTC));
|
||||||
inbox.updateById(row);
|
inbox.updateById(row);
|
||||||
|
|||||||
+17
-13
@@ -39,24 +39,24 @@ public class PlatformTerminalDrayageController {
|
|||||||
private final TerminalDrayageService service;
|
private final TerminalDrayageService service;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@PreAuthorize("hasAuthority('platform:firstleg:container:list')")
|
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
|
||||||
public ApiResponse<List<View>> views(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<List<View>> views(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@RequestParam @Size(min = 1, max = 200)
|
@RequestParam @Size(min = 1, max = 200)
|
||||||
List<@Positive Long> containerBookingIds) {
|
List<@Positive Long> containerBookingIds) {
|
||||||
FirstlegScopeGuard.platform(user);
|
FirstlegScopeGuard.platform(user);
|
||||||
return ApiResponse.success(service.views(containerBookingIds));
|
return ApiResponse.success(service.authorize(service.views(containerBookingIds), user.permissions()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{containerBookingId}")
|
@GetMapping("/{containerBookingId}")
|
||||||
@PreAuthorize("hasAuthority('platform:firstleg:container:list')")
|
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
|
||||||
public ApiResponse<View> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<View> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@PathVariable @Positive long containerBookingId) {
|
@PathVariable @Positive long containerBookingId) {
|
||||||
FirstlegScopeGuard.platform(user);
|
FirstlegScopeGuard.platform(user);
|
||||||
return ApiResponse.success(service.detail(containerBookingId));
|
return ApiResponse.success(service.authorize(service.detail(containerBookingId), user.permissions()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{containerBookingId}/history")
|
@GetMapping("/{containerBookingId}/history")
|
||||||
@PreAuthorize("hasAuthority('platform:firstleg:container:list')")
|
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
|
||||||
public ApiResponse<List<History>> history(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<List<History>> history(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@PathVariable @Positive long containerBookingId) {
|
@PathVariable @Positive long containerBookingId) {
|
||||||
FirstlegScopeGuard.platform(user);
|
FirstlegScopeGuard.platform(user);
|
||||||
@@ -64,7 +64,7 @@ public class PlatformTerminalDrayageController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{containerBookingId}/fleet")
|
@PostMapping("/{containerBookingId}/fleet")
|
||||||
@PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
|
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:fleet:edit')")
|
||||||
@Operation(summary = "选择或更换目的港码头车队")
|
@Operation(summary = "选择或更换目的港码头车队")
|
||||||
@BusinessAudit(event = "TERMINAL_DRAYAGE_SELECT_FLEET", resourceType = "CONTAINER",
|
@BusinessAudit(event = "TERMINAL_DRAYAGE_SELECT_FLEET", resourceType = "CONTAINER",
|
||||||
resourceId = "#containerBookingId", description = "选择目的港码头车队")
|
resourceId = "#containerBookingId", description = "选择目的港码头车队")
|
||||||
@@ -73,11 +73,12 @@ public class PlatformTerminalDrayageController {
|
|||||||
@RequestHeader("Idempotency-Key") String key,
|
@RequestHeader("Idempotency-Key") String key,
|
||||||
@Valid @RequestBody FleetSelectionRequest request) {
|
@Valid @RequestBody FleetSelectionRequest request) {
|
||||||
FirstlegScopeGuard.platform(user);
|
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")
|
@PostMapping("/{containerBookingId}/pickup-appointment")
|
||||||
@PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
|
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:pickup-appointment:edit')")
|
||||||
@Operation(summary = "预约目的港提柜时间")
|
@Operation(summary = "预约目的港提柜时间")
|
||||||
@BusinessAudit(event = "TERMINAL_DRAYAGE_SCHEDULE_PICKUP", resourceType = "CONTAINER",
|
@BusinessAudit(event = "TERMINAL_DRAYAGE_SCHEDULE_PICKUP", resourceType = "CONTAINER",
|
||||||
resourceId = "#containerBookingId", description = "预约目的港提柜")
|
resourceId = "#containerBookingId", description = "预约目的港提柜")
|
||||||
@@ -86,11 +87,12 @@ public class PlatformTerminalDrayageController {
|
|||||||
@RequestHeader("Idempotency-Key") String key,
|
@RequestHeader("Idempotency-Key") String key,
|
||||||
@Valid @RequestBody PickupAppointmentRequest request) {
|
@Valid @RequestBody PickupAppointmentRequest request) {
|
||||||
FirstlegScopeGuard.platform(user);
|
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")
|
@PostMapping("/{containerBookingId}/pickup")
|
||||||
@PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
|
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:pickup:confirm')")
|
||||||
@Operation(summary = "确认目的港实际提柜")
|
@Operation(summary = "确认目的港实际提柜")
|
||||||
@BusinessAudit(event = "TERMINAL_DRAYAGE_RECORD_PICKUP", resourceType = "CONTAINER",
|
@BusinessAudit(event = "TERMINAL_DRAYAGE_RECORD_PICKUP", resourceType = "CONTAINER",
|
||||||
resourceId = "#containerBookingId", description = "确认目的港实际提柜")
|
resourceId = "#containerBookingId", description = "确认目的港实际提柜")
|
||||||
@@ -99,11 +101,12 @@ public class PlatformTerminalDrayageController {
|
|||||||
@RequestHeader("Idempotency-Key") String key,
|
@RequestHeader("Idempotency-Key") String key,
|
||||||
@Valid @RequestBody ActualTimeRequest request) {
|
@Valid @RequestBody ActualTimeRequest request) {
|
||||||
FirstlegScopeGuard.platform(user);
|
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")
|
@PostMapping("/{containerBookingId}/empty-return")
|
||||||
@PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
|
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:empty-return:confirm')")
|
||||||
@Operation(summary = "确认实际还空柜")
|
@Operation(summary = "确认实际还空柜")
|
||||||
@BusinessAudit(event = "TERMINAL_DRAYAGE_RECORD_EMPTY_RETURN", resourceType = "CONTAINER",
|
@BusinessAudit(event = "TERMINAL_DRAYAGE_RECORD_EMPTY_RETURN", resourceType = "CONTAINER",
|
||||||
resourceId = "#containerBookingId", description = "确认实际还空柜")
|
resourceId = "#containerBookingId", description = "确认实际还空柜")
|
||||||
@@ -112,6 +115,7 @@ public class PlatformTerminalDrayageController {
|
|||||||
@RequestHeader("Idempotency-Key") String key,
|
@RequestHeader("Idempotency-Key") String key,
|
||||||
@Valid @RequestBody ActualTimeRequest request) {
|
@Valid @RequestBody ActualTimeRequest request) {
|
||||||
FirstlegScopeGuard.platform(user);
|
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.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
|
||||||
|
|
||||||
public final class TerminalDrayageDtos {
|
public final class TerminalDrayageDtos {
|
||||||
private TerminalDrayageDtos() {}
|
private TerminalDrayageDtos() {}
|
||||||
@@ -33,7 +34,8 @@ public final class TerminalDrayageDtos {
|
|||||||
LocalDateTime pickupAppointmentAt, LocalDateTime actualPickupAt,
|
LocalDateTime pickupAppointmentAt, LocalDateTime actualPickupAt,
|
||||||
LocalDateTime estimatedEmptyReturnAt, LocalDateTime actualEmptyReturnAt,
|
LocalDateTime estimatedEmptyReturnAt, LocalDateTime actualEmptyReturnAt,
|
||||||
LocalDateTime unloadingCompletedAt, Warning emptyReturnWarning,
|
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,
|
public record History(String action, String fromStatus, String toStatus,
|
||||||
Integer fromVersion, Integer toVersion, String reason,
|
Integer fromVersion, Integer toVersion, String reason,
|
||||||
|
|||||||
+4
-39
@@ -16,38 +16,16 @@ import java.util.List;
|
|||||||
public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity> {
|
public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity> {
|
||||||
@Select("""
|
@Select("""
|
||||||
SELECT b.id AS containerBookingId,b.container_no AS containerNo,b.ocean_booking_id AS oceanBookingId,
|
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,
|
b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt
|
||||||
q.id AS queryId,q.latest_standard_node AS latestActualNode,
|
|
||||||
q.latest_node_observed_at AS latestActualAt,q.version AS queryVersion,
|
|
||||||
(SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id
|
|
||||||
AND f.standard_node='ARRIVED_DESTINATION_PORT') AS actualArrivalNodeAt,
|
|
||||||
(SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id
|
|
||||||
AND f.standard_node='PICKED_UP') AS actualPickupNodeAt,
|
|
||||||
(SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id
|
|
||||||
AND f.standard_node='EMPTY_RETURNED') AS actualEmptyReturnNodeAt
|
|
||||||
FROM fl_container_booking b
|
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
|
WHERE b.id=#{containerBookingId} FOR UPDATE
|
||||||
""")
|
""")
|
||||||
Context lockContext(@Param("containerBookingId") long containerBookingId);
|
Context lockContext(@Param("containerBookingId") long containerBookingId);
|
||||||
|
|
||||||
@Select("""
|
@Select("""
|
||||||
SELECT b.id AS containerBookingId,b.container_no AS containerNo,b.ocean_booking_id AS oceanBookingId,
|
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,
|
b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt
|
||||||
q.id AS queryId,q.latest_standard_node AS latestActualNode,
|
|
||||||
q.latest_node_observed_at AS latestActualAt,q.version AS queryVersion,
|
|
||||||
(SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id
|
|
||||||
AND f.standard_node='ARRIVED_DESTINATION_PORT') AS actualArrivalNodeAt,
|
|
||||||
(SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id
|
|
||||||
AND f.standard_node='PICKED_UP') AS actualPickupNodeAt,
|
|
||||||
(SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id
|
|
||||||
AND f.standard_node='EMPTY_RETURNED') AS actualEmptyReturnNodeAt
|
|
||||||
FROM fl_container_booking b
|
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}
|
WHERE b.id=#{containerBookingId}
|
||||||
""")
|
""")
|
||||||
Context context(@Param("containerBookingId") long containerBookingId);
|
Context context(@Param("containerBookingId") long containerBookingId);
|
||||||
@@ -55,19 +33,8 @@ public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity>
|
|||||||
@Select("""
|
@Select("""
|
||||||
<script>
|
<script>
|
||||||
SELECT b.id AS containerBookingId,b.container_no AS containerNo,b.ocean_booking_id AS oceanBookingId,
|
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,
|
b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt
|
||||||
q.id AS queryId,q.latest_standard_node AS latestActualNode,
|
|
||||||
q.latest_node_observed_at AS latestActualAt,q.version AS queryVersion,
|
|
||||||
(SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id
|
|
||||||
AND f.standard_node='ARRIVED_DESTINATION_PORT') AS actualArrivalNodeAt,
|
|
||||||
(SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id
|
|
||||||
AND f.standard_node='PICKED_UP') AS actualPickupNodeAt,
|
|
||||||
(SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id
|
|
||||||
AND f.standard_node='EMPTY_RETURNED') AS actualEmptyReturnNodeAt
|
|
||||||
FROM fl_container_booking b
|
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
|
WHERE b.id IN
|
||||||
<foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
<foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||||
ORDER BY b.id
|
ORDER BY b.id
|
||||||
@@ -141,8 +108,6 @@ public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity>
|
|||||||
@Param("operatorId") long operatorId);
|
@Param("operatorId") long operatorId);
|
||||||
|
|
||||||
record Context(Long containerBookingId, String containerNo, Long oceanBookingId, String recordStatus,
|
record Context(Long containerBookingId, String containerNo, Long oceanBookingId, String recordStatus,
|
||||||
LocalDateTime unloadingCompletedAt, Long queryId, String latestActualNode,
|
LocalDateTime unloadingCompletedAt) {}
|
||||||
LocalDateTime latestActualAt, Integer queryVersion, LocalDateTime actualArrivalNodeAt,
|
|
||||||
LocalDateTime actualPickupNodeAt, LocalDateTime actualEmptyReturnNodeAt) {}
|
|
||||||
record Operation(String operation, String requestHash, Integer resultVersion) {}
|
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.api.ResultCode;
|
||||||
import com.baiyue.common.core.exception.BizException;
|
import com.baiyue.common.core.exception.BizException;
|
||||||
import com.baiyue.firstleg.portschedule.domain.CarrierScheduleNode;
|
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.dto.CarrierScheduleQueryDtos.ManualNodeRequest;
|
||||||
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
|
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
|
||||||
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
|
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
|
||||||
@@ -35,6 +36,7 @@ import java.util.HexFormat;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -56,7 +58,10 @@ public class TerminalDrayageService {
|
|||||||
if (ids.size() != containerBookingIds.size()) throw badRequest("containerBookingIds不能包含空值或重复值");
|
if (ids.size() != containerBookingIds.size()) throw badRequest("containerBookingIds不能包含空值或重复值");
|
||||||
Map<Long, TerminalDrayageEntity> rows = new HashMap<>();
|
Map<Long, TerminalDrayageEntity> rows = new HashMap<>();
|
||||||
mapper.findAll(ids).forEach(value -> rows.put(value.getContainerBookingId(), value));
|
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) {
|
public List<History> histories(long containerBookingId) {
|
||||||
@@ -64,11 +69,25 @@ public class TerminalDrayageService {
|
|||||||
return mapper.histories(containerBookingId);
|
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
|
@Transactional
|
||||||
public View selectFleet(long containerBookingId, long operatorId, String operatorName, String key,
|
public View selectFleet(long containerBookingId, long operatorId, String operatorName, String key,
|
||||||
FleetSelectionRequest request) {
|
FleetSelectionRequest request) {
|
||||||
String action = "SELECT_FLEET";
|
String action = "SELECT_FLEET";
|
||||||
Context context = requiredLockedContext(containerBookingId);
|
WorkContext context = requiredLockedContext(containerBookingId);
|
||||||
View replay = replay(context, action, key, request);
|
View replay = replay(context, action, key, request);
|
||||||
if (replay != null) return replay;
|
if (replay != null) return replay;
|
||||||
requireArrived(context);
|
requireArrived(context);
|
||||||
@@ -90,7 +109,7 @@ public class TerminalDrayageService {
|
|||||||
public View schedulePickup(long containerBookingId, long operatorId, String operatorName, String key,
|
public View schedulePickup(long containerBookingId, long operatorId, String operatorName, String key,
|
||||||
PickupAppointmentRequest request) {
|
PickupAppointmentRequest request) {
|
||||||
String action = "SCHEDULE_PICKUP";
|
String action = "SCHEDULE_PICKUP";
|
||||||
Context context = requiredLockedContext(containerBookingId);
|
WorkContext context = requiredLockedContext(containerBookingId);
|
||||||
View replay = replay(context, action, key, request);
|
View replay = replay(context, action, key, request);
|
||||||
if (replay != null) return replay;
|
if (replay != null) return replay;
|
||||||
requireArrived(context);
|
requireArrived(context);
|
||||||
@@ -98,7 +117,8 @@ public class TerminalDrayageService {
|
|||||||
requireVersion(value, request.expectedVersion());
|
requireVersion(value, request.expectedVersion());
|
||||||
if (value.getFleetSupplierId() == null) throw conflict("请先选择有效码头车队");
|
if (value.getFleetSupplierId() == null) throw conflict("请先选择有效码头车队");
|
||||||
if (actualPickupAt(context, value) != 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("预约提柜时间不能早于实际到港时间");
|
throw badRequest("预约提柜时间不能早于实际到港时间");
|
||||||
}
|
}
|
||||||
int fromVersion = version(value);
|
int fromVersion = version(value);
|
||||||
@@ -113,7 +133,7 @@ public class TerminalDrayageService {
|
|||||||
public View recordPickup(long containerBookingId, long operatorId, String operatorName, String key,
|
public View recordPickup(long containerBookingId, long operatorId, String operatorName, String key,
|
||||||
ActualTimeRequest request) {
|
ActualTimeRequest request) {
|
||||||
String action = "RECORD_PICKUP";
|
String action = "RECORD_PICKUP";
|
||||||
Context context = requiredLockedContext(containerBookingId);
|
WorkContext context = requiredLockedContext(containerBookingId);
|
||||||
View replay = replay(context, action, key, request);
|
View replay = replay(context, action, key, request);
|
||||||
if (replay != null) return replay;
|
if (replay != null) return replay;
|
||||||
requireAtLeast(context, CarrierScheduleNode.PICKUP_READY, "货柜尚未清关放行,不允许确认实际提柜");
|
requireAtLeast(context, CarrierScheduleNode.PICKUP_READY, "货柜尚未清关放行,不允许确认实际提柜");
|
||||||
@@ -123,7 +143,7 @@ public class TerminalDrayageService {
|
|||||||
throw conflict("选择码头车队并预约提柜后才能确认实际提柜");
|
throw conflict("选择码头车队并预约提柜后才能确认实际提柜");
|
||||||
}
|
}
|
||||||
if (actualPickupAt(context, value) != null) 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("实际提柜时间不能早于当前实际船节点时间");
|
throw badRequest("实际提柜时间不能早于当前实际船节点时间");
|
||||||
}
|
}
|
||||||
advanceNode(context, CarrierScheduleNode.PICKED_UP, request.actualAt(), action, key,
|
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,
|
public View recordEmptyReturn(long containerBookingId, long operatorId, String operatorName, String key,
|
||||||
ActualTimeRequest request) {
|
ActualTimeRequest request) {
|
||||||
String action = "RECORD_EMPTY_RETURN";
|
String action = "RECORD_EMPTY_RETURN";
|
||||||
Context context = requiredLockedContext(containerBookingId);
|
WorkContext context = requiredLockedContext(containerBookingId);
|
||||||
View replay = replay(context, action, key, request);
|
View replay = replay(context, action, key, request);
|
||||||
if (replay != null) return replay;
|
if (replay != null) return replay;
|
||||||
TerminalDrayageEntity value = requiredAggregate(containerBookingId);
|
TerminalDrayageEntity value = requiredAggregate(containerBookingId);
|
||||||
requireVersion(value, request.expectedVersion());
|
requireVersion(value, request.expectedVersion());
|
||||||
LocalDateTime pickupAt = actualPickupAt(context, value);
|
LocalDateTime pickupAt = actualPickupAt(context, value);
|
||||||
if (pickupAt == null) throw conflict("实际提柜后才能确认还柜");
|
if (pickupAt == null) throw conflict("实际提柜后才能确认还柜");
|
||||||
if (context.unloadingCompletedAt() == null) throw conflict("海外卸柜收货完成后才能确认还柜");
|
if (context.base().unloadingCompletedAt() == null) throw conflict("海外卸柜收货完成后才能确认还柜");
|
||||||
LocalDateTime earliest = pickupAt.isAfter(context.unloadingCompletedAt())
|
LocalDateTime earliest = pickupAt.isAfter(context.base().unloadingCompletedAt())
|
||||||
? pickupAt : context.unloadingCompletedAt();
|
? pickupAt : context.base().unloadingCompletedAt();
|
||||||
if (request.actualAt().isBefore(earliest)) throw badRequest("实际还柜时间不能早于提柜或卸柜完成时间");
|
if (request.actualAt().isBefore(earliest)) throw badRequest("实际还柜时间不能早于提柜或卸柜完成时间");
|
||||||
if (actualEmptyReturnAt(context, value) != null) throw conflict("实际还柜时间已确认");
|
if (actualEmptyReturnAt(context, value) != null) throw conflict("实际还柜时间已确认");
|
||||||
advanceNode(context, CarrierScheduleNode.EMPTY_RETURNED, request.actualAt(), action, key,
|
advanceNode(context, CarrierScheduleNode.EMPTY_RETURNED, request.actualAt(), action, key,
|
||||||
@@ -164,40 +184,40 @@ public class TerminalDrayageService {
|
|||||||
fromVersion, request.reason(), operatorId, operatorName);
|
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) {
|
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("货柜尚未建立船节点跟踪任务");
|
throw conflict("货柜尚未建立船节点跟踪任务");
|
||||||
}
|
}
|
||||||
CarrierScheduleNode current = node(context.latestActualNode());
|
CarrierScheduleNode current = node(context.facts().latestActualNode());
|
||||||
if (current != null && current.sequence() >= target.sequence()) return;
|
if (current != null && current.sequence() >= target.sequence()) return;
|
||||||
carrierSchedules.addManualNode(context.queryId(), operatorId, operatorName,
|
carrierSchedules.addManualNode(context.facts().queryId(), operatorId, operatorName,
|
||||||
"td-" + hash(context.containerBookingId() + "|" + action + "|" + key.trim()),
|
"td-" + hash(context.base().containerBookingId() + "|" + action + "|" + key.trim()),
|
||||||
new ManualNodeRequest(context.queryVersion(), target.name(), LocalDateTime.now(clock), null,
|
new ManualNodeRequest(context.facts().queryVersion(), target.name(), LocalDateTime.now(clock), null,
|
||||||
actualAt, null, null, StringUtils.hasText(reason) ? reason.trim() : "码头拖车操作同步"));
|
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,
|
String fromStatus, int fromVersion, String reason,
|
||||||
long operatorId, String operatorName) {
|
long operatorId, String operatorName) {
|
||||||
TerminalDrayageEntity stored = mapper.find(context.containerBookingId());
|
TerminalDrayageEntity stored = mapper.find(context.base().containerBookingId());
|
||||||
Context currentContext = requiredContext(context.containerBookingId());
|
WorkContext currentContext = requiredContext(context.base().containerBookingId());
|
||||||
String toStatus = status(currentContext, stored);
|
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);
|
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);
|
version(stored), operatorId);
|
||||||
return view(currentContext, stored);
|
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字符");
|
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 (operation == null) return null;
|
||||||
if (!action.equals(operation.operation()) || !hash(request).equals(operation.requestHash())) {
|
if (!action.equals(operation.operation()) || !hash(request).equals(operation.requestHash())) {
|
||||||
throw conflict("Idempotency-Key已被不同的码头拖车操作占用");
|
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) {
|
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();
|
if (mapper.updateAggregate(value, expectedVersion, operatorId, operatorName) != 1) throw concurrent();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void applyAuthoritativeDeadline(Context context, TerminalDrayageEntity value) {
|
private void applyAuthoritativeDeadline(WorkContext context, TerminalDrayageEntity value) {
|
||||||
if (context.unloadingCompletedAt() != null) {
|
if (context.base().unloadingCompletedAt() != null) {
|
||||||
value.setEstimatedEmptyReturnAt(context.unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS));
|
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);
|
String status = status(context, value);
|
||||||
LocalDateTime deadline = context.unloadingCompletedAt() == null ? null
|
LocalDateTime deadline = context.base().unloadingCompletedAt() == null ? null
|
||||||
: context.unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS);
|
: context.base().unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS);
|
||||||
LocalDateTime returnedAt = actualEmptyReturnAt(context, value);
|
LocalDateTime returnedAt = actualEmptyReturnAt(context, value);
|
||||||
String warningStatus;
|
String warningStatus;
|
||||||
long overdueHours = 0;
|
long overdueHours = 0;
|
||||||
@@ -236,18 +256,18 @@ public class TerminalDrayageService {
|
|||||||
overdueHours = Math.max(0, Duration.between(deadline, LocalDateTime.now(clock)).toHours());
|
overdueHours = Math.max(0, Duration.between(deadline, LocalDateTime.now(clock)).toHours());
|
||||||
} else warningStatus = "PENDING";
|
} else warningStatus = "PENDING";
|
||||||
Warning warning = new Warning(warningStatus, deadline, overdueHours, overdueHours / 24);
|
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(),
|
version(value), value == null ? null : value.getFleetSupplierId(),
|
||||||
value == null ? null : value.getFleetSupplierName(),
|
value == null ? null : value.getFleetSupplierName(),
|
||||||
value == null ? null : value.getPickupAppointmentAt(),
|
value == null ? null : value.getPickupAppointmentAt(),
|
||||||
actualPickupAt(context, value), deadline, returnedAt,
|
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());
|
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<>();
|
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()
|
if (current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence()
|
||||||
&& current.sequence() < CarrierScheduleNode.PICKED_UP.sequence()
|
&& current.sequence() < CarrierScheduleNode.PICKED_UP.sequence()
|
||||||
&& actualPickupAt(context, value) == null) {
|
&& actualPickupAt(context, value) == null) {
|
||||||
@@ -257,27 +277,27 @@ public class TerminalDrayageService {
|
|||||||
if (current != null && current.sequence() >= CarrierScheduleNode.PICKUP_READY.sequence()
|
if (current != null && current.sequence() >= CarrierScheduleNode.PICKUP_READY.sequence()
|
||||||
&& value != null && value.getFleetSupplierId() != null && value.getPickupAppointmentAt() != null
|
&& value != null && value.getFleetSupplierId() != null && value.getPickupAppointmentAt() != null
|
||||||
&& actualPickupAt(context, value) == null) result.add("RECORD_PICKUP");
|
&& 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");
|
&& actualEmptyReturnAt(context, value) == null) result.add("RECORD_EMPTY_RETURN");
|
||||||
return List.copyOf(result);
|
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 (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 (actualPickupAt(context, value) != null) return "PICKED_UP";
|
||||||
if (value != null && value.getPickupAppointmentAt() != null) return "PICKUP_SCHEDULED";
|
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()
|
return current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence()
|
||||||
? "ARRIVED" : "WAITING_FOR_ARRIVAL";
|
? "ARRIVED" : "WAITING_FOR_ARRIVAL";
|
||||||
}
|
}
|
||||||
|
|
||||||
private void requireArrived(Context context) {
|
private void requireArrived(WorkContext context) {
|
||||||
requireAtLeast(context, CarrierScheduleNode.ARRIVED_DESTINATION_PORT, "货柜实际到达目的港后才能安排码头车队");
|
requireAtLeast(context, CarrierScheduleNode.ARRIVED_DESTINATION_PORT, "货柜实际到达目的港后才能安排码头车队");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void requireAtLeast(Context context, CarrierScheduleNode required, String message) {
|
private void requireAtLeast(WorkContext context, CarrierScheduleNode required, String message) {
|
||||||
CarrierScheduleNode current = node(context.latestActualNode());
|
CarrierScheduleNode current = node(context.facts().latestActualNode());
|
||||||
if (current == null || current.sequence() < required.sequence()) throw conflict(message);
|
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;
|
return StringUtils.hasText(value) ? CarrierScheduleNode.require(value) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private LocalDateTime actualPickupAt(Context context, TerminalDrayageEntity value) {
|
private LocalDateTime actualPickupAt(WorkContext context, TerminalDrayageEntity value) {
|
||||||
return value != null && value.getActualPickupAt() != null ? value.getActualPickupAt() : context.actualPickupNodeAt();
|
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
|
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);
|
Context value = mapper.context(id);
|
||||||
if (value == null || !"ACTIVE".equals(value.recordStatus())) throw notFound();
|
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);
|
Context value = mapper.lockContext(id);
|
||||||
if (value == null || !"ACTIVE".equals(value.recordStatus())) throw notFound();
|
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) {
|
private TerminalDrayageEntity requiredAggregate(long id) {
|
||||||
TerminalDrayageEntity value = mapper.lock(id);
|
TerminalDrayageEntity value = mapper.lock(id);
|
||||||
if (value == null) throw conflict("请先选择码头车队");
|
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 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 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 conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
|
||||||
private static BizException concurrent() { return conflict("码头拖车记录已变化,请刷新后重试"); }
|
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 '实际到仓时间',
|
actual_warehouse_arrival_at DATETIME(3) NULL COMMENT '实际到仓时间',
|
||||||
unloading_completed_at DATETIME(3) NULL COMMENT '海外仓卸柜完成时间(UTC)',
|
unloading_completed_at DATETIME(3) NULL COMMENT '海外仓卸柜完成时间(UTC)',
|
||||||
unloading_photo_uploaded_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)',
|
actual_empty_reported_at DATETIME(3) NULL COMMENT '实际报空时间(UTC)',
|
||||||
empty_report_source_version BIGINT UNSIGNED NULL COMMENT '海外货柜事件聚合版本,用于阻止乱序覆盖',
|
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 BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
|
||||||
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
|
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
|
||||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) 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_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_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_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
|
CONSTRAINT chk_container_booking_iso6346 CHECK
|
||||||
(container_no IS NULL OR container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$')
|
(container_no IS NULL OR container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$')
|
||||||
) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划';
|
) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划';
|
||||||
@@ -162,7 +167,7 @@ BEGIN
|
|||||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
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
|
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
|
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;
|
END IF;
|
||||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
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
|
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
|
ALTER TABLE fl_container_booking ADD COLUMN empty_report_source_version BIGINT UNSIGNED NULL
|
||||||
COMMENT '海外货柜事件聚合版本,用于阻止乱序覆盖' AFTER actual_empty_reported_at;
|
COMMENT '海外货柜事件聚合版本,用于阻止乱序覆盖' AFTER actual_empty_reported_at;
|
||||||
END IF;
|
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()
|
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
|
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);
|
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
|
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}$');
|
CHECK (container_no IS NULL OR container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$');
|
||||||
END IF;
|
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$$
|
END$$
|
||||||
DELIMITER ;
|
DELIMITER ;
|
||||||
CALL phase3_container_real_unit_columns();
|
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))
|
CONSTRAINT chk_loading_item_deleted CHECK (deleted IN (0, 1))
|
||||||
) ENGINE=InnoDB COMMENT='智能装柜客户货物明细;客户归属仅在明细层';
|
) ENGINE=InnoDB COMMENT='智能装柜客户货物明细;客户归属仅在明细层';
|
||||||
|
|
||||||
-- 柜次级简单文件仅承载目的港清关、提货单和取货单;出口报关由 customs-v1 柜级聚合承载。
|
-- 柜次级简单文件仅承载目的港清关和提货单;PU资料作为数字码保存在货柜主表。
|
||||||
CREATE TABLE IF NOT EXISTS fl_container_document (
|
CREATE TABLE IF NOT EXISTS fl_container_document (
|
||||||
id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)',
|
id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)',
|
||||||
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订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_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID,不建立跨库外键',
|
||||||
file_name VARCHAR(255) NOT NULL COMMENT '文件名称快照',
|
file_name VARCHAR(255) NOT NULL COMMENT '文件名称快照',
|
||||||
file_hash VARCHAR(64) NULL COMMENT 'File服务SHA-256快照',
|
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)
|
CONSTRAINT fk_container_document_booking FOREIGN KEY (container_booking_id)
|
||||||
REFERENCES fl_container_booking (id),
|
REFERENCES fl_container_booking (id),
|
||||||
CONSTRAINT chk_container_document_type
|
CONSTRAINT chk_container_document_type
|
||||||
CHECK (document_type IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER'))
|
CHECK (document_type IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER'))
|
||||||
) ENGINE=InnoDB COMMENT='平台货柜清关、DO与PU文件关联';
|
) 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 (
|
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',
|
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
|
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
|
||||||
WHERE container_booking_id = NEW.container_booking_id;
|
WHERE container_booking_id = NEW.container_booking_id;
|
||||||
IF current_plan_status = 'COMPLETED'
|
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 = '装柜完成后货柜文件不可新增';
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增';
|
||||||
END IF;
|
END IF;
|
||||||
END$$
|
END$$
|
||||||
@@ -1222,7 +1261,7 @@ BEGIN
|
|||||||
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
|
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
|
||||||
WHERE container_booking_id = OLD.container_booking_id;
|
WHERE container_booking_id = OLD.container_booking_id;
|
||||||
IF current_plan_status = 'COMPLETED'
|
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 = '装柜完成后货柜文件不可修改';
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改';
|
||||||
END IF;
|
END IF;
|
||||||
END$$
|
END$$
|
||||||
|
|||||||
@@ -406,25 +406,6 @@ paths:
|
|||||||
'200': {description: 成功或业务失败,按响应体 code 判断}
|
'200': {description: 成功或业务失败,按响应体 code 判断}
|
||||||
'403': {description: 无分配权限}
|
'403': {description: 无分配权限}
|
||||||
/api/platform/v1/containers:
|
/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:
|
post:
|
||||||
summary: 新增内部模拟柜
|
summary: 新增内部模拟柜
|
||||||
description: 需要 platform:firstleg:container:create 权限;服务端生成稳定 containerBookingId/cabinetNo,不要求真实柜号或订舱
|
description: 需要 platform:firstleg:container:create 权限;服务端生成稳定 containerBookingId/cabinetNo,不要求真实柜号或订舱
|
||||||
@@ -436,6 +417,43 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'200': {description: 成功或重复号、参数校验等业务失败}
|
'200': {description: 成功或重复号、参数校验等业务失败}
|
||||||
'403': {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:
|
/api/platform/v1/containers/{id}/ocean-booking-link:
|
||||||
post:
|
post:
|
||||||
summary: 将内部模拟柜关联到已发布订舱需求
|
summary: 将内部模拟柜关联到已发布订舱需求
|
||||||
@@ -444,14 +462,6 @@ paths:
|
|||||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingLinkRequest'}}}}
|
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingLinkRequest'}}}}
|
||||||
responses: {'200': {description: 关联后的同一模拟柜 containerBookingId/cabinetNo;列表仍只有这一条 ACTIVE 操作身份}}
|
responses: {'200': {description: 关联后的同一模拟柜 containerBookingId/cabinetNo;列表仍只有这一条 ACTIVE 操作身份}}
|
||||||
/api/platform/v1/containers/{id}:
|
/api/platform/v1/containers/{id}:
|
||||||
get:
|
|
||||||
summary: 查询平台货柜详情
|
|
||||||
description: 需要 platform:firstleg:container:list 权限
|
|
||||||
parameters:
|
|
||||||
- {$ref: '#/components/parameters/ContainerBookingId'}
|
|
||||||
responses:
|
|
||||||
'200': {description: 成功或货柜不存在}
|
|
||||||
'403': {description: 无货柜查询权限}
|
|
||||||
put:
|
put:
|
||||||
summary: 草稿阶段编辑货柜基础资料
|
summary: 草稿阶段编辑货柜基础资料
|
||||||
description: 需要 platform:firstleg:container:edit 权限;仅待装柜或计划为 EDITING 时允许;请求不包含 containerNo/cabinetNo,不形成真实柜号或内部柜次号写入口
|
description: 需要 platform:firstleg:container:edit 权限;仅待装柜或计划为 EDITING 时允许;请求不包含 containerNo/cabinetNo,不形成真实柜号或内部柜次号写入口
|
||||||
@@ -465,16 +475,23 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'200': {description: 成功或状态、版本、重复号冲突}
|
'200': {description: 成功或状态、版本、重复号冲突}
|
||||||
'403': {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:
|
/api/platform/v1/containers/{id}/clearance:
|
||||||
get:
|
get:
|
||||||
summary: 查询货柜目的港清关资料
|
summary: 查询货柜目的港清关资料
|
||||||
description: 需要 platform:firstleg:customs:manage 权限
|
description: 需要 platform:firstleg:terminal-drayage:list 权限;仅国外工作台货柜
|
||||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
|
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
|
||||||
responses: {'200': {description: 成功或货柜不存在}}
|
responses: {'200': {description: 成功或货柜不存在}}
|
||||||
/api/platform/v1/containers/{id}/clearance/document:
|
/api/platform/v1/containers/{id}/clearance/document:
|
||||||
post:
|
post:
|
||||||
summary: 上传或替换货柜目的港清关资料
|
summary: 上传或替换货柜目的港清关资料
|
||||||
description: 需要 platform:firstleg:customs:manage 权限,fileId 必须严格绑定当前货柜
|
description: 需要 platform:firstleg:terminal-drayage:document:edit 权限,fileId 必须严格绑定当前货柜
|
||||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@@ -483,7 +500,7 @@ paths:
|
|||||||
/api/platform/v1/containers/{id}/clearance/document/{fileId}/download:
|
/api/platform/v1/containers/{id}/clearance/document/{fileId}/download:
|
||||||
get:
|
get:
|
||||||
summary: 下载货柜目的港清关附件
|
summary: 下载货柜目的港清关附件
|
||||||
description: 需要 platform:firstleg:customs:manage 权限;服务端校验当前 DESTINATION_CLEARANCE fileId 及 File 业务归属,完成态仍可下载,响应禁止缓存
|
description: 需要 platform:firstleg:terminal-drayage:list 权限;服务端校验当前 DESTINATION_CLEARANCE fileId 及 File 业务归属,完成态仍可下载,响应禁止缓存
|
||||||
parameters:
|
parameters:
|
||||||
- {$ref: '#/components/parameters/ContainerBookingId'}
|
- {$ref: '#/components/parameters/ContainerBookingId'}
|
||||||
- {name: fileId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
- {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:
|
/api/platform/v1/containers/{id}/clearance/document/upload:
|
||||||
post:
|
post:
|
||||||
summary: 直接选择文件上传或替换货柜目的港清关资料
|
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'}]
|
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@@ -504,7 +521,7 @@ paths:
|
|||||||
/api/platform/v1/containers/{id}/clearance/complete:
|
/api/platform/v1/containers/{id}/clearance/complete:
|
||||||
post:
|
post:
|
||||||
summary: 点击完成目的港清关
|
summary: 点击完成目的港清关
|
||||||
description: 需要 platform:firstleg:customs:manage 权限
|
description: 需要 platform:firstleg:terminal-drayage:document:edit 权限
|
||||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@@ -1986,7 +2003,7 @@ components:
|
|||||||
publicRemark: {type: string, nullable: true}
|
publicRemark: {type: string, nullable: true}
|
||||||
expressCompanyName: {type: string, nullable: true}
|
expressCompanyName: {type: string, nullable: true}
|
||||||
expressTrackingNo: {type: string, nullable: true}
|
expressTrackingNo: {type: string, nullable: true}
|
||||||
ContainerBookingPageEnvelope:
|
ContainerWorkbenchPageEnvelope:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
code: {type: string, example: '200'}
|
code: {type: string, example: '200'}
|
||||||
@@ -1994,14 +2011,78 @@ components:
|
|||||||
data:
|
data:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
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]*$'}
|
page: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||||
size: {type: string, pattern: '^[1-9][0-9]*$'}
|
size: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||||
total: {type: string, pattern: '^[0-9]+$'}
|
total: {type: string, pattern: '^[0-9]+$'}
|
||||||
pages: {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:
|
ContainerBooking:
|
||||||
type: object
|
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:
|
properties:
|
||||||
id: {type: string, pattern: '^[1-9][0-9]*$'}
|
id: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||||
planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 关联智能装柜计划ID;未创建计划时为空}
|
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}
|
actualWarehouseArrivalAt: {type: string, format: date-time, nullable: true}
|
||||||
unloadingCompletedAt: {type: string, format: date-time, nullable: true, description: 海外仓卸柜完成时间}
|
unloadingCompletedAt: {type: string, format: date-time, nullable: true, description: 海外仓卸柜完成时间}
|
||||||
unloadingPhotoUploadedAt: {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: 实际报空时间}
|
actualEmptyReportedAt: {type: string, format: date-time, nullable: true, description: 实际报空时间}
|
||||||
emptyReportSourceVersion: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 海外货柜事件聚合版本}
|
emptyReportSourceVersion: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 海外货柜事件聚合版本}
|
||||||
planStatus: {type: string, enum: [WAIT_LOADING, EDITING, LOADING, COMPLETED]}
|
planStatus: {type: string, enum: [WAIT_LOADING, EDITING, LOADING, COMPLETED]}
|
||||||
@@ -2051,12 +2132,17 @@ components:
|
|||||||
destinationClearanceDocumentStatus: {$ref: '#/components/schemas/ContainerDocumentStatus'}
|
destinationClearanceDocumentStatus: {$ref: '#/components/schemas/ContainerDocumentStatus'}
|
||||||
customsDeclarationDocumentStatus: {$ref: '#/components/schemas/ContainerDocumentStatus'}
|
customsDeclarationDocumentStatus: {$ref: '#/components/schemas/ContainerDocumentStatus'}
|
||||||
deliveryOrderDocumentStatus: {$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}
|
version: {type: integer, minimum: 0}
|
||||||
ContainerDocumentStatus:
|
ContainerDocumentStatus:
|
||||||
type: string
|
type: string
|
||||||
enum: [MISSING, UPLOADED, COMPLETED]
|
enum: [MISSING, UPLOADED, COMPLETED]
|
||||||
description: 单证聚合状态;DO/PU 无完成动作,因此只返回 MISSING 或 UPLOADED
|
description: 单证聚合状态;DO 无完成动作,因此只返回 MISSING 或 UPLOADED
|
||||||
ContainerSaveRequest:
|
ContainerSaveRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [warehouseId, warehouseName, containerType, capacityCbm, maxWeightKg]
|
required: [warehouseId, warehouseName, containerType, capacityCbm, maxWeightKg]
|
||||||
@@ -2366,6 +2452,12 @@ components:
|
|||||||
file: {type: string, format: binary, description: 用户直接选择的清关资料}
|
file: {type: string, format: binary, description: 用户直接选择的清关资料}
|
||||||
expectedVersion: {type: integer, minimum: 0, description: 当前文档乐观锁版本,首次上传为 0}
|
expectedVersion: {type: integer, minimum: 0, description: 当前文档乐观锁版本,首次上传为 0}
|
||||||
operationReason: {type: string, maxLength: 500, nullable: true}
|
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:
|
MerchantInboundBillingEnvelope:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@@ -7,6 +7,19 @@ paths:
|
|||||||
description: '权限:platform:firstleg:container:list'
|
description: '权限:platform:firstleg:container:list'
|
||||||
parameters: [{$ref: '#/components/parameters/ContainerId'}]
|
parameters: [{$ref: '#/components/parameters/ContainerId'}]
|
||||||
responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/OverviewEnvelope'}}}}}
|
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:
|
/api/platform/v1/containers/{id}/exception:
|
||||||
parameters: [{$ref: '#/components/parameters/ContainerId'}]
|
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}}}
|
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}:
|
/api/platform/v1/containers/{id}/documents/{documentType}:
|
||||||
get:
|
get:
|
||||||
operationId: getContainerDocument
|
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'}]
|
parameters: [{$ref: '#/components/parameters/ContainerId'}, {$ref: '#/components/parameters/DocumentType'}]
|
||||||
responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentEnvelope'}}}}}
|
responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentEnvelope'}}}}}
|
||||||
/api/platform/v1/containers/{id}/documents/{documentType}/upload:
|
/api/platform/v1/containers/{id}/documents/{documentType}/upload:
|
||||||
post:
|
post:
|
||||||
operationId: uploadContainerDocument
|
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}}]
|
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}}}}}}
|
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'}}}}}
|
responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentEnvelope'}}}}}
|
||||||
/api/platform/v1/containers/{id}/documents/{documentType}/{fileId}/download:
|
/api/platform/v1/containers/{id}/documents/{documentType}/{fileId}/download:
|
||||||
get:
|
get:
|
||||||
operationId: downloadContainerDocument
|
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}}]
|
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}}}}}
|
responses: {'200': {description: file bytes, content: {application/octet-stream: {schema: {type: string, format: binary}}}}}
|
||||||
/api/platform/v1/containers/{id}/clearance/document/{fileId}/download:
|
/api/platform/v1/containers/{id}/clearance/document/{fileId}/download:
|
||||||
get:
|
get:
|
||||||
operationId: downloadContainerDestinationClearance
|
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}}]
|
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}}
|
responses: {'200': {description: clearance file bytes, content: {application/octet-stream: {schema: {type: string, format: binary}}}}, '404': {description: current file or ownership mismatch}}
|
||||||
components:
|
components:
|
||||||
parameters:
|
parameters:
|
||||||
ContainerId: {name: id, in: path, required: true, schema: {type: integer, format: int64}}
|
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:
|
requestBodies:
|
||||||
ExceptionRequest: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ExceptionRequest'}}}}
|
ExceptionRequest: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ExceptionRequest'}}}}
|
||||||
schemas:
|
schemas:
|
||||||
@@ -57,16 +70,17 @@ components:
|
|||||||
container: {$ref: '#/components/schemas/Container'}
|
container: {$ref: '#/components/schemas/Container'}
|
||||||
loadingPlanId: {type: integer, format: int64}
|
loadingPlanId: {type: integer, format: int64}
|
||||||
pda: {$ref: '#/components/schemas/PdaSummary'}
|
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'}
|
exception: {$ref: '#/components/schemas/ExceptionState'}
|
||||||
exceptionHistory: {type: array, items: {$ref: '#/components/schemas/ExceptionHistory'}}
|
exceptionHistory: {type: array, items: {$ref: '#/components/schemas/ExceptionHistory'}}
|
||||||
timeline: {type: array, items: {$ref: '#/components/schemas/TimelineEntry'}}
|
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}}}}
|
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}}}
|
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}}}
|
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}}}
|
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}}}
|
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}}}
|
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}}}
|
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:
|
/api/platform/v1/terminal-drayages:
|
||||||
get:
|
get:
|
||||||
summary: 批量查询货柜码头拖车及服务端报空预警
|
summary: 批量查询货柜码头拖车及服务端报空预警
|
||||||
x-permission: platform:firstleg:container:list
|
x-permission: platform:firstleg:terminal-drayage:list
|
||||||
parameters:
|
parameters:
|
||||||
- name: containerBookingIds
|
- name: containerBookingIds
|
||||||
in: query
|
in: query
|
||||||
@@ -14,40 +14,40 @@ paths:
|
|||||||
/api/platform/v1/terminal-drayages/{containerBookingId}:
|
/api/platform/v1/terminal-drayages/{containerBookingId}:
|
||||||
get:
|
get:
|
||||||
summary: 查询单柜码头拖车详情
|
summary: 查询单柜码头拖车详情
|
||||||
x-permission: platform:firstleg:container:list
|
x-permission: platform:firstleg:terminal-drayage:list
|
||||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
|
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
|
||||||
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
|
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
|
||||||
/api/platform/v1/terminal-drayages/{containerBookingId}/history:
|
/api/platform/v1/terminal-drayages/{containerBookingId}/history:
|
||||||
get:
|
get:
|
||||||
summary: 查询单柜码头拖车操作历史
|
summary: 查询单柜码头拖车操作历史
|
||||||
x-permission: platform:firstleg:container:list
|
x-permission: platform:firstleg:terminal-drayage:list
|
||||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
|
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
|
||||||
responses: {'200': {$ref: '#/components/responses/HistoryResponse'}}
|
responses: {'200': {$ref: '#/components/responses/HistoryResponse'}}
|
||||||
/api/platform/v1/terminal-drayages/{containerBookingId}/fleet:
|
/api/platform/v1/terminal-drayages/{containerBookingId}/fleet:
|
||||||
post:
|
post:
|
||||||
summary: 到港后选择或更换码头车队
|
summary: 到港后选择或更换码头车队
|
||||||
x-permission: platform:firstleg:container:edit
|
x-permission: platform:firstleg:terminal-drayage:fleet:edit
|
||||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/FleetSelectionRequest'}}}}
|
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/FleetSelectionRequest'}}}}
|
||||||
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
|
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
|
||||||
/api/platform/v1/terminal-drayages/{containerBookingId}/pickup-appointment:
|
/api/platform/v1/terminal-drayages/{containerBookingId}/pickup-appointment:
|
||||||
post:
|
post:
|
||||||
summary: 预约目的港提柜时间
|
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'}]
|
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PickupAppointmentRequest'}}}}
|
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PickupAppointmentRequest'}}}}
|
||||||
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
|
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
|
||||||
/api/platform/v1/terminal-drayages/{containerBookingId}/pickup:
|
/api/platform/v1/terminal-drayages/{containerBookingId}/pickup:
|
||||||
post:
|
post:
|
||||||
summary: 清关可提柜后确认实际提柜并推进PICKED_UP节点
|
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'}]
|
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualTimeRequest'}}}}
|
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualTimeRequest'}}}}
|
||||||
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
|
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
|
||||||
/api/platform/v1/terminal-drayages/{containerBookingId}/empty-return:
|
/api/platform/v1/terminal-drayages/{containerBookingId}/empty-return:
|
||||||
post:
|
post:
|
||||||
summary: 海外卸柜收货完成后确认实际还柜并推进EMPTY_RETURNED节点
|
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'}]
|
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualTimeRequest'}}}}
|
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualTimeRequest'}}}}
|
||||||
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
|
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
|
||||||
@@ -88,7 +88,7 @@ components:
|
|||||||
overdueDays: {type: integer, format: int64, minimum: 0}
|
overdueDays: {type: integer, format: int64, minimum: 0}
|
||||||
TerminalDrayageView:
|
TerminalDrayageView:
|
||||||
type: object
|
type: object
|
||||||
required: [containerBookingId, status, version, emptyReturnWarning, allowedActions]
|
required: [containerBookingId, status, version, emptyReturnWarning, allowedActions, actualFacts]
|
||||||
properties:
|
properties:
|
||||||
containerBookingId: {type: string, pattern: '^[1-9][0-9]*$'}
|
containerBookingId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||||
containerNo: {type: string, nullable: true}
|
containerNo: {type: string, nullable: true}
|
||||||
@@ -104,7 +104,24 @@ components:
|
|||||||
unloadingCompletedAt: {type: string, format: date-time, nullable: true}
|
unloadingCompletedAt: {type: string, format: date-time, nullable: true}
|
||||||
emptyReturnWarning: {$ref: '#/components/schemas/Warning'}
|
emptyReturnWarning: {$ref: '#/components/schemas/Warning'}
|
||||||
allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN]}}
|
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}
|
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:
|
History:
|
||||||
type: object
|
type: object
|
||||||
required: [action, fromStatus, toStatus, fromVersion, toVersion, operatorId, createdAt]
|
required: [action, fromStatus, toStatus, fromVersion, toVersion, operatorId, createdAt]
|
||||||
|
|||||||
+19
-10
@@ -18,30 +18,35 @@ class ContainerControllerContractTest {
|
|||||||
@Test
|
@Test
|
||||||
@DisplayName("查询、新增和编辑按钮权限彼此独立")
|
@DisplayName("查询、新增和编辑按钮权限彼此独立")
|
||||||
void separatesButtonPermissions() {
|
void separatesButtonPermissions() {
|
||||||
assertPermission("page", "platform:firstleg:container:list");
|
assertPermission("domesticPage", "platform:firstleg:container:list");
|
||||||
assertPermission("detail", "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("create", "platform:firstleg:container:create");
|
||||||
assertPermission("linkOceanBooking", "platform:firstleg:container:edit");
|
assertPermission("linkOceanBooking", "platform:firstleg:container:edit");
|
||||||
assertPermission("updateDraft", "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("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("downloadPdaPhoto", "platform:firstleg:container:list");
|
||||||
assertPermission("markException", "platform:firstleg:container:edit");
|
assertPermission("markException", "platform:firstleg:container:edit");
|
||||||
assertPermission("cancelException", "platform:firstleg:container:edit");
|
assertPermission("cancelException", "platform:firstleg:container:edit");
|
||||||
assertPermission("logisticsDocument", "platform:firstleg:container:list");
|
assertPermission("logisticsDocument", "platform:firstleg:terminal-drayage:list");
|
||||||
assertPermission("uploadLogisticsDocument", "platform:firstleg:container:edit");
|
assertPermission("uploadLogisticsDocument", "platform:firstleg:terminal-drayage:document:edit");
|
||||||
assertPermission("downloadDocument", "platform:firstleg:container:list");
|
assertPermission("downloadDocument", "platform:firstleg:terminal-drayage:list");
|
||||||
assertPermission("downloadClearance", "platform:firstleg:customs:manage");
|
assertPermission("downloadClearance", "platform:firstleg:terminal-drayage:list");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test void overviewOpenApiFreezesContainerIdBasedPdaContract() throws Exception {
|
@Test void overviewOpenApiFreezesContainerIdBasedPdaContract() throws Exception {
|
||||||
String openapi = new ClassPathResource("openapi/phase3-container-overview-v1.yaml")
|
String openapi = new ClassPathResource("openapi/phase3-container-overview-v1.yaml")
|
||||||
.getContentAsString(StandardCharsets.UTF_8);
|
.getContentAsString(StandardCharsets.UTF_8);
|
||||||
assertThat(openapi).contains("/api/platform/v1/containers/{id}/overview:",
|
assertThat(openapi).contains("/api/platform/v1/containers/{id}/overview:",
|
||||||
|
"/api/platform/v1/containers/overseas/{id}/overview:",
|
||||||
"/documents/{documentType}/{fileId}/download:", "ExceptionRequest",
|
"/documents/{documentType}/{fileId}/download:", "ExceptionRequest",
|
||||||
"/clearance/document/{fileId}/download:",
|
"/clearance/document/{fileId}/download:",
|
||||||
"DESTINATION_CLEARANCE、DELIVERY_ORDER、PICKUP_ORDER",
|
"/api/platform/v1/containers/{id}/pickup-code:", "PickupCodeRequest",
|
||||||
"enum: [DELIVERY_ORDER, PICKUP_ORDER]", "Idempotency-Key", "photoCounts:",
|
"enum: [DELIVERY_ORDER]", "Idempotency-Key", "photoCounts:",
|
||||||
"/pda/photos/{fileId}/download:", "downloadPath:", "workflowStatus:",
|
"/pda/photos/{fileId}/download:", "downloadPath:", "workflowStatus:",
|
||||||
"AWAITING_REVIEW", "version: {type: integer, minimum: 0}",
|
"AWAITING_REVIEW", "version: {type: integer, minimum: 0}",
|
||||||
"downloadPath: {type: string, nullable: true",
|
"downloadPath: {type: string, nullable: true",
|
||||||
@@ -59,6 +64,9 @@ class ContainerControllerContractTest {
|
|||||||
|
|
||||||
assertThat(section)
|
assertThat(section)
|
||||||
.contains("/api/platform/v1/containers/{id}/ocean-booking-link:")
|
.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("首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜")
|
||||||
.contains("RELEASED oceanBookingId")
|
.contains("RELEASED oceanBookingId")
|
||||||
.contains("未满配 requirementId")
|
.contains("未满配 requirementId")
|
||||||
@@ -72,7 +80,7 @@ class ContainerControllerContractTest {
|
|||||||
.contains("/api/platform/v1/containers/{id}/clearance/document/upload:")
|
.contains("/api/platform/v1/containers/{id}/clearance/document/upload:")
|
||||||
.contains("multipart/form-data")
|
.contains("multipart/form-data")
|
||||||
.contains("ContainerDocumentUploadRequest")
|
.contains("ContainerDocumentUploadRequest")
|
||||||
.contains("platform:firstleg:container:list")
|
.contains("platform:firstleg:terminal-drayage:list")
|
||||||
.contains("EDITING")
|
.contains("EDITING")
|
||||||
.contains("WAIT_LOADING")
|
.contains("WAIT_LOADING")
|
||||||
.contains("LOADING")
|
.contains("LOADING")
|
||||||
@@ -80,6 +88,7 @@ class ContainerControllerContractTest {
|
|||||||
.doesNotContain("tenantId")
|
.doesNotContain("tenantId")
|
||||||
.doesNotContain("accountId");
|
.doesNotContain("accountId");
|
||||||
assertThat(openapi)
|
assertThat(openapi)
|
||||||
|
.contains("ContainerWorkbenchItem:", "allowedActions")
|
||||||
.contains("/api/platform/v1/containers/{id}/clearance/document/{fileId}/download:")
|
.contains("/api/platform/v1/containers/{id}/clearance/document/{fileId}/download:")
|
||||||
.contains("Content-Disposition 与 Cache-Control no-store")
|
.contains("Content-Disposition 与 Cache-Control no-store")
|
||||||
.contains("required: [oceanBookingId, requirementId, expectedVersion]")
|
.contains("required: [oceanBookingId, requirementId, expectedVersion]")
|
||||||
|
|||||||
+1
-1
@@ -46,7 +46,7 @@ class ContainerBookingMapperContractTest {
|
|||||||
void filtersPageByStableCustomerId() throws Exception {
|
void filtersPageByStableCustomerId() throws Exception {
|
||||||
String page = String.join("\n", ContainerBookingMapper.class
|
String page = String.join("\n", ContainerBookingMapper.class
|
||||||
.getDeclaredMethod("selectBookingPage", com.baomidou.mybatisplus.extension.plugins.pagination.Page.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());
|
.getAnnotation(Select.class).value());
|
||||||
assertThat(page)
|
assertThat(page)
|
||||||
.contains("fl_ocean_booking_container_allocation customer_allocation")
|
.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;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
class ContainerOverviewSqlContractTest {
|
class ContainerOverviewSqlContractTest {
|
||||||
@Test void overviewIncludesExactlyTheThreeCurrentBusinessDocumentTypes() throws Exception {
|
@Test void overviewIncludesExactlyTheTwoCurrentBusinessDocumentTypes() throws Exception {
|
||||||
String sql = String.join(" ", ContainerOverviewMapper.class.getDeclaredMethod("documents", long.class)
|
String sql = String.join(" ", ContainerOverviewMapper.class.getDeclaredMethod("documents", long.class)
|
||||||
.getAnnotation(Select.class).value());
|
.getAnnotation(Select.class).value());
|
||||||
assertThat(sql)
|
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");
|
.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
|
@Test
|
||||||
@DisplayName("货柜简单文件只保留目的港清关与DO/PU")
|
@DisplayName("货柜简单文件只保留目的港清关与DO,PU改为数字码")
|
||||||
void shouldAllowExactlyThreeSimpleContainerDocumentTypes() throws Exception {
|
void shouldAllowExactlyTwoSimpleContainerDocumentTypes() throws Exception {
|
||||||
for(String sql:java.util.List.of(migration(),unifiedMigration()))assertThat(sql)
|
assertThat(migration())
|
||||||
.contains("'DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER'")
|
.contains("'DESTINATION_CLEARANCE', 'DELIVERY_ORDER'")
|
||||||
|
.contains("pickup_code VARCHAR(64) NULL")
|
||||||
.contains("file_hash VARCHAR(64) NULL")
|
.contains("file_hash VARCHAR(64) NULL")
|
||||||
.contains("completed_by BIGINT UNSIGNED NULL")
|
.contains("completed_by BIGINT UNSIGNED NULL")
|
||||||
.contains("completed_at DATETIME(3) 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.common.core.exception.BizException;
|
||||||
import com.baiyue.firstleg.dto.ContainerOverviewDtos.ExceptionRequest;
|
import com.baiyue.firstleg.dto.ContainerOverviewDtos.ExceptionRequest;
|
||||||
|
import com.baiyue.firstleg.dto.ContainerOverviewDtos.PickupCodeRequest;
|
||||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||||
import com.baiyue.firstleg.mapper.ContainerOverviewMapper;
|
import com.baiyue.firstleg.mapper.ContainerOverviewMapper;
|
||||||
import com.baiyue.firstleg.repository.ContainerRepository;
|
import com.baiyue.firstleg.repository.ContainerRepository;
|
||||||
@@ -154,6 +155,22 @@ class ContainerOverviewServiceTest {
|
|||||||
eq("operator"), eq("mark-1"), matches("[0-9a-f]{64}"));
|
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() {
|
@Test void exactReplayReturnsWithoutLockingOrWriting() {
|
||||||
String hash = hash("MARK|41|3|broken seal");
|
String hash = hash("MARK|41|3|broken seal");
|
||||||
when(mapper.exceptionOperation(41L, 7L, "mark-1")).thenReturn(new ContainerOverviewMapper.ExceptionRow(
|
when(mapper.exceptionOperation(41L, 7L, "mark-1")).thenReturn(new ContainerOverviewMapper.ExceptionRow(
|
||||||
|
|||||||
@@ -632,13 +632,13 @@ class LoadingListServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void genericContainerDocumentsOnlyAcceptDeliveryAndPickupOrders() {
|
void genericContainerDocumentsOnlyAcceptDeliveryOrders() {
|
||||||
assertThatThrownBy(() -> service.containerLogisticsDocument(30L,
|
assertThatThrownBy(() -> service.containerLogisticsDocument(30L,
|
||||||
LoadingListService.DESTINATION_CLEARANCE)).isInstanceOf(BizException.class)
|
LoadingListService.DESTINATION_CLEARANCE)).isInstanceOf(BizException.class)
|
||||||
.hasMessageContaining("DELIVERY_ORDER或PICKUP_ORDER");
|
.hasMessageContaining("DELIVERY_ORDER");
|
||||||
assertThatThrownBy(() -> service.containerLogisticsDocument(30L,
|
assertThatThrownBy(() -> service.containerLogisticsDocument(30L,
|
||||||
"UNKNOWN_DOCUMENT")).isInstanceOf(BizException.class)
|
"UNKNOWN_DOCUMENT")).isInstanceOf(BizException.class)
|
||||||
.hasMessageContaining("DELIVERY_ORDER或PICKUP_ORDER");
|
.hasMessageContaining("DELIVERY_ORDER");
|
||||||
verify(repository, never()).aggregateByBooking(30L);
|
verify(repository, never()).aggregateByBooking(30L);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -670,36 +670,6 @@ class LoadingListServiceTest {
|
|||||||
eq("COMPLETED"), eq("COMPLETED"), anyString(), anyString(), eq(9L), eq("operator"), isNull());
|
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
|
@Test
|
||||||
void staleDeliveryOrderVersionReturnsConflictBeforeUploadingAnotherFile() {
|
void staleDeliveryOrderVersionReturnsConflictBeforeUploadingAnotherFile() {
|
||||||
AggregateRow plan = row("COMPLETED", 4);
|
AggregateRow plan = row("COMPLETED", 4);
|
||||||
|
|||||||
+2
-2
@@ -41,7 +41,7 @@ class OverseasContainerEventConsumerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void derivesEstimatedEmptyReportedAtFromPhotoUploadAndPersistsInbox() {
|
void derivesEstimatedEmptyReportedAtFromUnloadCompletionAndPersistsInbox() {
|
||||||
ContainerBookingEntity container = container();
|
ContainerBookingEntity container = container();
|
||||||
when(containers.selectActiveBookingForUpdate(1001L)).thenReturn(container);
|
when(containers.selectActiveBookingForUpdate(1001L)).thenReturn(container);
|
||||||
Instant completedAt = Instant.parse("2026-08-20T03:00:00Z");
|
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")),
|
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-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);
|
ArgumentCaptor<ContainerEventInboxEntity> captor = ArgumentCaptor.forClass(ContainerEventInboxEntity.class);
|
||||||
verify(inbox).insert(captor.capture());
|
verify(inbox).insert(captor.capture());
|
||||||
assertThat(captor.getValue().getAggregateId()).isEqualTo("1001");
|
assertThat(captor.getValue().getAggregateId()).isEqualTo("1001");
|
||||||
|
|||||||
+25
-2
@@ -1,6 +1,7 @@
|
|||||||
package com.baiyue.firstleg.terminaldrayage.controller;
|
package com.baiyue.firstleg.terminaldrayage.controller;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
@@ -8,6 +9,17 @@ import java.nio.file.Path;
|
|||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
class TerminalDrayageOpenApiContractTest {
|
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
|
@Test
|
||||||
void freezesActionsPermissionsAndServerWarningFields() throws Exception {
|
void freezesActionsPermissionsAndServerWarningFields() throws Exception {
|
||||||
String yaml = Files.readString(Path.of("src/main/resources/openapi/terminal-drayage-v1.yaml"));
|
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-appointment:",
|
||||||
"/api/platform/v1/terminal-drayages/{containerBookingId}/pickup:",
|
"/api/platform/v1/terminal-drayages/{containerBookingId}/pickup:",
|
||||||
"/api/platform/v1/terminal-drayages/{containerBookingId}/empty-return:",
|
"/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",
|
"expectedVersion", "Idempotency-Key", "estimatedEmptyReturnAt",
|
||||||
"emptyReturnWarning", "deadlineAt", "overdueHours", "overdueDays");
|
"emptyReturnWarning", "actualFacts", "deadlineAt", "overdueHours", "overdueDays");
|
||||||
assertThat(yaml).contains("description: 卸柜完成时间加72小时");
|
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小时计算的预计还柜时间");
|
"服务端按卸柜完成时间加72小时计算的预计还柜时间");
|
||||||
assertThat(sql).doesNotContain("ON DELETE CASCADE");
|
assertThat(sql).doesNotContain("ON DELETE CASCADE");
|
||||||
assertThat(mapper).contains("WHERE b.id=#{containerBookingId} FOR UPDATE",
|
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
|
@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.common.core.exception.BizException;
|
||||||
import com.baiyue.firstleg.client.WarehouseSupplierClient;
|
import com.baiyue.firstleg.client.WarehouseSupplierClient;
|
||||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest;
|
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.portschedule.service.CarrierScheduleQueryService;
|
||||||
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
|
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
|
||||||
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.ActualTimeRequest;
|
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.ActualTimeRequest;
|
||||||
@@ -21,6 +22,9 @@ import java.time.Instant;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.ArrayDeque;
|
||||||
|
import java.util.Deque;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
@@ -41,12 +45,18 @@ class TerminalDrayageServiceTest {
|
|||||||
private WarehouseSupplierSnapshotService suppliers;
|
private WarehouseSupplierSnapshotService suppliers;
|
||||||
private CarrierScheduleQueryService carrierSchedules;
|
private CarrierScheduleQueryService carrierSchedules;
|
||||||
private TerminalDrayageService service;
|
private TerminalDrayageService service;
|
||||||
|
private Deque<ContainerActualFacts> facts;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
mapper = mock(TerminalDrayageMapper.class);
|
mapper = mock(TerminalDrayageMapper.class);
|
||||||
suppliers = mock(WarehouseSupplierSnapshotService.class);
|
suppliers = mock(WarehouseSupplierSnapshotService.class);
|
||||||
carrierSchedules = mock(CarrierScheduleQueryService.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 = new TerminalDrayageService(mapper, suppliers, carrierSchedules, new ObjectMapper().findAndRegisterModules());
|
||||||
service.setClock(Clock.fixed(Instant.parse("2026-09-05T02:00:00Z"), ZoneOffset.UTC));
|
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");
|
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
|
@Test
|
||||||
void sameIdempotencyKeyWithDifferentPayloadConflictsWithoutWriting() {
|
void sameIdempotencyKeyWithDifferentPayloadConflictsWithoutWriting() {
|
||||||
Context context = context("ARRIVED_DESTINATION_PORT", null);
|
Context context = context("ARRIVED_DESTINATION_PORT", null);
|
||||||
@@ -249,10 +285,17 @@ class TerminalDrayageServiceTest {
|
|||||||
case "ARRIVED_DESTINATION_PORT" -> 3;
|
case "ARRIVED_DESTINATION_PORT" -> 3;
|
||||||
default -> 2;
|
default -> 2;
|
||||||
};
|
};
|
||||||
return new Context(11L, "MSCU1234567", 21L, "ACTIVE", unloadedAt,
|
facts.addLast(new ContainerActualFacts(11L, 31L, sequenceVersion, "READY", node, ARRIVED_AT,
|
||||||
31L, node, ARRIVED_AT, sequenceVersion, 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,
|
"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) {
|
private TerminalDrayageEntity row(int version) {
|
||||||
|
|||||||
Reference in New Issue
Block a user