feat(firstleg): close phase3 outbound customs and schedule gates

This commit is contained in:
2026-08-08 22:36:41 +08:00
parent 789d97675b
commit 66d1aa0d3d
81 changed files with 5196 additions and 36 deletions
@@ -1,3 +1,3 @@
package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse;import org.springframework.cloud.openfeign.FeignClient;import org.springframework.web.bind.annotation.*;import org.springframework.web.multipart.MultipartFile;
@FeignClient(name="file-service",contextId="truckAppointmentFileClient",url="${baiyue.clients.file.url:}",path="/internal/file/v1/business-files") public interface TruckAppointmentFileClient{@PostMapping(value="/{tenantId}/{businessType}/{businessId}",consumes="multipart/form-data") ApiResponse<Metadata> upload(@RequestHeader("X-Internal-Service-Token") String token,@RequestHeader("X-Operator-Id") long operatorId,@PathVariable long tenantId,@PathVariable String businessType,@PathVariable String businessId,@RequestPart("file") MultipartFile file);@GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}") ApiResponse<Metadata> metadata(@RequestHeader("X-Internal-Service-Token") String token,@PathVariable long tenantId,@PathVariable String businessType,@PathVariable String businessId,@PathVariable long fileId);record Metadata(Long fileId,Long tenantId,String businessType,String businessId,String fileName,String sha256){} }
@FeignClient(name="file-service",contextId="truckAppointmentFileClient",url="${baiyue.clients.file.url:}",path="/internal/file/v1/business-files") public interface TruckAppointmentFileClient{@PostMapping(value="/{tenantId}/{businessType}/{businessId}",consumes="multipart/form-data") ApiResponse<Metadata> upload(@RequestHeader("X-Internal-Service-Token") String token,@RequestHeader("X-Operator-Id") long operatorId,@PathVariable long tenantId,@PathVariable String businessType,@PathVariable String businessId,@RequestPart("file") MultipartFile file);@GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}") ApiResponse<Metadata> metadata(@RequestHeader("X-Internal-Service-Token") String token,@PathVariable long tenantId,@PathVariable String businessType,@PathVariable String businessId,@PathVariable long fileId);record Metadata(Long fileId,Long tenantId,String businessType,String businessId,String fileName,String sha256,String contentType,Long sizeBytes){} }
@@ -0,0 +1,36 @@
package com.baiyue.firstleg.customs.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@FeignClient(name = "file-service", contextId = "customsFileClient",
url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/business-files")
public interface CustomsFileClient {
@PostMapping(value = "/{tenantId}/{businessType}/{businessId}", consumes = "multipart/form-data")
ApiResponse<Metadata> upload(@RequestHeader("X-Internal-Service-Token") String token,
@RequestHeader("X-Operator-Id") long operatorId,
@PathVariable long tenantId,
@PathVariable String businessType,
@PathVariable String businessId,
@RequestPart("file") MultipartFile file);
@GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}")
ApiResponse<Metadata> metadata(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId,
@PathVariable String businessType,
@PathVariable String businessId,
@PathVariable long fileId);
@GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}/content")
ResponseEntity<byte[]> download(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId,
@PathVariable String businessType,
@PathVariable String businessId,
@PathVariable long fileId);
record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
String fileName, String sha256, String contentType, Long sizeBytes) {}
}
@@ -0,0 +1,88 @@
package com.baiyue.firstleg.customs.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.customs.dto.CustomsDtos.*;
import com.baiyue.firstleg.customs.entity.CustomsDocumentEntity;
import com.baiyue.firstleg.customs.service.CustomsService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import org.springframework.http.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@Tag(name = "客户端报关资料与回执")
@Validated
@RestController
@RequestMapping("/api/merchant/v1/customs")
public class MerchantCustomsController {
@Resource private CustomsService service;
@GetMapping("/orders/{orderId}")
@PreAuthorize("hasAuthority('merchant:order:view')")
public ApiResponse<CaseDetail> byOrder(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long orderId) {
return ApiResponse.success(service.merchantOrder(FirstlegScopeGuard.merchant(user), orderId));
}
@GetMapping("/cases/{caseId}")
@PreAuthorize("hasAuthority('merchant:order:view')")
public ApiResponse<CaseDetail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long caseId) {
return ApiResponse.success(service.merchantDetail(FirstlegScopeGuard.merchant(user), caseId));
}
@PostMapping(value = "/cases/{caseId}/documents/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('merchant:order:edit')")
public ApiResponse<CustomsDocumentEntity> upload(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long caseId,
@RequestHeader("Idempotency-Key") String key,
@RequestParam @NotBlank String itemCode,
@RequestParam @Min(0) int expectedVersion,
@RequestPart("file") MultipartFile file) {
long tenantId = FirstlegScopeGuard.merchant(user);
return ApiResponse.success(service.uploadDocument(tenantId, caseId, "MERCHANT", user.userId(), user.username(), key,
new DocumentUploadData(itemCode, expectedVersion), file));
}
@PostMapping("/cases/{caseId}/documents")
@PreAuthorize("hasAuthority('merchant:order:edit')")
public ApiResponse<CustomsDocumentEntity> reference(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long caseId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody DocumentReferenceRequest request) {
long tenantId = FirstlegScopeGuard.merchant(user);
return ApiResponse.success(service.referenceDocument(tenantId, caseId, "MERCHANT", user.userId(), user.username(), key, request));
}
@GetMapping("/cases/{caseId}/documents/{documentId}/download")
@PreAuthorize("hasAuthority('merchant:order:view')")
public ResponseEntity<byte[]> downloadDocument(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long caseId,
@PathVariable @Positive long documentId) {
return response(service.downloadDocument(FirstlegScopeGuard.merchant(user), caseId, documentId, true));
}
@GetMapping("/cases/{caseId}/receipts/{receiptId}/download")
@PreAuthorize("hasAuthority('merchant:order:view')")
public ResponseEntity<byte[]> downloadReceipt(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long caseId,
@PathVariable @Positive long receiptId) {
return response(service.downloadReceipt(FirstlegScopeGuard.merchant(user), caseId, receiptId, true));
}
private static ResponseEntity<byte[]> response(Download value) {
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(value.contentType()))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + safeName(value.fileName()) + "\"")
.contentLength(value.content().length).body(value.content());
}
private static String safeName(String value) { return value.replace("\r", "").replace("\n", "").replace("\"", ""); }
}
@@ -0,0 +1,161 @@
package com.baiyue.firstleg.customs.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.*;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.customs.dto.CustomsDtos.*;
import com.baiyue.firstleg.customs.entity.*;
import com.baiyue.firstleg.customs.service.CustomsService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import org.springframework.http.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@Tag(name = "三期报关协同")
@Validated
@RestController
@RequestMapping("/api/platform/v1/customs")
public class PlatformCustomsController {
@Resource private CustomsService service;
@GetMapping("/cases")
@PreAuthorize("hasAuthority('platform:firstleg:customs:list')")
public ApiResponse<PageResult<CustomsCaseEntity>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid Query query) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.page(query));
}
@GetMapping("/cases/{caseId}")
@PreAuthorize("hasAuthority('platform:firstleg:customs:view')")
public ApiResponse<CaseDetail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long caseId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.platformDetail(caseId));
}
@PostMapping("/checklist-versions")
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
@BusinessAudit(event = "CUSTOMS_CHECKLIST_CREATE", resourceType = "CUSTOMS_CHECKLIST", captureBefore = false)
public ApiResponse<ChecklistView> createChecklist(@AuthenticationPrincipal AuthenticatedUser user,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody CreateChecklistRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.createChecklist(user.userId(), user.username(), key, request));
}
@PostMapping("/cases")
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
@BusinessAudit(event = "CUSTOMS_CASE_CREATE", resourceType = "CUSTOMS_CASE", captureBefore = false)
public ApiResponse<CaseDetail> createCase(@AuthenticationPrincipal AuthenticatedUser user,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody CreateCaseRequest request) {
FirstlegScopeGuard.platform(user, request.customerId());
return ApiResponse.success(service.createCase(user.userId(), user.username(), key, request));
}
@PostMapping(value = "/cases/{caseId}/documents/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
public ApiResponse<CustomsDocumentEntity> uploadDocument(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long caseId,
@RequestHeader("Idempotency-Key") String key,
@RequestParam @NotBlank String itemCode,
@RequestParam @Min(0) int expectedVersion,
@RequestPart("file") MultipartFile file) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.uploadDocument(0, caseId, "PLATFORM", user.userId(), user.username(), key,
new DocumentUploadData(itemCode, expectedVersion), file));
}
@PostMapping("/cases/{caseId}/documents")
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
public ApiResponse<CustomsDocumentEntity> referenceDocument(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long caseId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody DocumentReferenceRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.referenceDocument(0, caseId, "PLATFORM", user.userId(), user.username(), key, request));
}
@PostMapping("/cases/{caseId}/documents/{documentId}/review")
@PreAuthorize("hasAuthority('platform:firstleg:customs:review')")
@BusinessAudit(event = "CUSTOMS_DOCUMENT_REVIEW", resourceType = "CUSTOMS_DOCUMENT")
public ApiResponse<CaseDetail> review(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long caseId,
@PathVariable @Positive long documentId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ReviewRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.review(caseId, documentId, user.userId(), user.username(), key, request));
}
@PostMapping("/cases/{caseId}/submission-batches")
@PreAuthorize("hasAuthority('platform:firstleg:customs:submit')")
@BusinessAudit(event = "CUSTOMS_AGENT_SUBMIT", resourceType = "CUSTOMS_SUBMISSION_BATCH")
public ApiResponse<CustomsSubmissionBatchEntity> submit(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long caseId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody SubmitBatchRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.submitBatch(caseId, user.userId(), user.username(), key, request));
}
@PostMapping(value = "/submission-batches/{batchId}/receipts/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:firstleg:customs:receipt')")
public ApiResponse<CustomsReceiptEntity> uploadReceipt(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long batchId,
@RequestHeader("Idempotency-Key") String key,
@RequestParam @Pattern(regexp = "RECEIVED|DECLARED|REJECTED|INSPECTION|RELEASED|OTHER") String receiptType,
@RequestParam @Pattern(regexp = "PLATFORM|CUSTOMER") String visibility,
@RequestParam(required = false) @Size(max = 500) String note,
@RequestParam @Min(0) int expectedVersion,
@RequestPart("file") MultipartFile file) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.uploadReceipt(batchId, user.userId(), user.username(), key,
new ReceiptUploadData(receiptType, visibility, note, expectedVersion), file));
}
@PostMapping("/submission-batches/{batchId}/receipts")
@PreAuthorize("hasAuthority('platform:firstleg:customs:receipt')")
public ApiResponse<CustomsReceiptEntity> referenceReceipt(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long batchId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ReceiptReferenceRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.referenceReceipt(batchId, user.userId(), user.username(), key, request));
}
@GetMapping("/cases/{caseId}/documents/{documentId}/download")
@PreAuthorize("hasAuthority('platform:firstleg:customs:view')")
public ResponseEntity<byte[]> downloadDocument(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long caseId,
@PathVariable @Positive long documentId) {
FirstlegScopeGuard.platform(user);
return response(service.downloadDocument(0, caseId, documentId, false));
}
@GetMapping("/cases/{caseId}/receipts/{receiptId}/download")
@PreAuthorize("hasAuthority('platform:firstleg:customs:view')")
public ResponseEntity<byte[]> downloadReceipt(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long caseId,
@PathVariable @Positive long receiptId) {
FirstlegScopeGuard.platform(user);
return response(service.downloadReceipt(0, caseId, receiptId, false));
}
private static ResponseEntity<byte[]> response(Download value) {
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(value.contentType()))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + safeName(value.fileName()) + "\"")
.contentLength(value.content().length).body(value.content());
}
private static String safeName(String value) { return value.replace("\r", "").replace("\n", "").replace("\"", ""); }
}
@@ -0,0 +1,79 @@
package com.baiyue.firstleg.customs.dto;
import com.baiyue.firstleg.customs.entity.*;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.time.LocalDateTime;
import java.util.List;
public final class CustomsDtos {
private CustomsDtos() {}
public record ChecklistItem(@NotBlank @Size(max = 40) String code,
@NotBlank @Size(max = 100) String name,
boolean required) {}
public record CreateChecklistRequest(@NotBlank @Size(max = 40) String checklistCode,
@NotBlank @Size(max = 32) String versionNo,
@NotBlank @Size(max = 32) String countryCode,
@NotBlank @Size(max = 40) String channelCode,
@NotBlank @Size(max = 40) String cargoType,
@NotEmpty List<@Valid ChecklistItem> items) {}
public record ChecklistView(CustomsChecklistVersionEntity version, List<ChecklistItem> items) {}
public record CreateCaseRequest(@Positive long customerId,
@Positive long orderId,
@Positive long inboundId,
@Positive Long containerBookingId,
@Positive long checklistVersionId,
@Size(max = 500) String remark,
@Min(0) int expectedVersion) {}
public record Query(Long customerId, Long orderId, Long inboundId, Long containerBookingId, String status,
@Min(1) int page, @Min(1) @Max(200) int size) {
public Query {
if (page == 0) page = 1;
if (size == 0) size = 20;
}
}
public record DocumentReferenceRequest(@NotBlank @Size(max = 40) String itemCode,
@Positive long fileId,
@Min(0) int expectedVersion) {}
public record DocumentUploadData(@NotBlank @Size(max = 40) String itemCode,
@Min(0) int expectedVersion) {}
public record ReviewRequest(@NotBlank @Pattern(regexp = "APPROVE|RETURN") String decision,
@Size(max = 500) String reason,
LocalDateTime supplementDueAt,
@Min(0) int expectedVersion) {}
public record SubmitBatchRequest(@NotBlank @Size(max = 128) String customsAgentName,
@Size(max = 128) String externalReference,
@Size(max = 500) String note,
@Min(0) int expectedVersion) {}
public record ReceiptReferenceRequest(@NotBlank @Pattern(regexp = "RECEIVED|DECLARED|REJECTED|INSPECTION|RELEASED|OTHER") String receiptType,
@Positive long fileId,
@NotBlank @Pattern(regexp = "PLATFORM|CUSTOMER") String visibility,
@Size(max = 500) String note,
@Min(0) int expectedVersion) {}
public record ReceiptUploadData(@NotBlank @Pattern(regexp = "RECEIVED|DECLARED|REJECTED|INSPECTION|RELEASED|OTHER") String receiptType,
@NotBlank @Pattern(regexp = "PLATFORM|CUSTOMER") String visibility,
@Size(max = 500) String note,
@Min(0) int expectedVersion) {}
public record CaseDetail(CustomsCaseEntity customsCase,
ChecklistView checklist,
List<CustomsDocumentEntity> documents,
List<CustomsSupplementRequestEntity> supplements,
List<CustomsSubmissionBatchEntity> submissionBatches,
List<CustomsReceiptEntity> receipts,
List<CustomsHistoryEntity> history) {}
public record Download(String fileName, String contentType, byte[] content) {}
}
@@ -0,0 +1,28 @@
package com.baiyue.firstleg.customs.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter @Setter @TableName("fl_customs_case")
public class CustomsCaseEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String customsNo;
private Long tenantId;
private Long orderId;
private Long inboundId;
private Long containerBookingId;
private Long checklistVersionId;
private String checklistCode;
private String checklistVersionNo;
private String status;
private String remark;
private Long createdBy;
private String createdByName;
private LocalDateTime createdAt;
private Long updatedBy;
private String updatedByName;
private LocalDateTime updatedAt;
@Version private Integer version;
}
@@ -0,0 +1,21 @@
package com.baiyue.firstleg.customs.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter @Setter @TableName("fl_customs_checklist_version")
public class CustomsChecklistVersionEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String checklistCode;
private String versionNo;
private String countryCode;
private String channelCode;
private String cargoType;
private String itemsJson;
private String status;
private Long createdBy;
private String createdByName;
private LocalDateTime createdAt;
}
@@ -0,0 +1,27 @@
package com.baiyue.firstleg.customs.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter @Setter @TableName("fl_customs_document")
public class CustomsDocumentEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long customsCaseId;
private String checklistItemCode;
private Long fileId;
private Integer documentVersion;
private Long replacesDocumentId;
private String fileName;
private String fileHash;
private String status;
private String visibility;
private Long uploadedBy;
private String uploadedByType;
private LocalDateTime uploadedAt;
private Long reviewedBy;
private String reviewedByName;
private LocalDateTime reviewedAt;
private String reviewReason;
}
@@ -0,0 +1,22 @@
package com.baiyue.firstleg.customs.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter @Setter @TableName("fl_customs_history")
public class CustomsHistoryEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long customsCaseId;
private String action;
private String fromStatus;
private String toStatus;
private String resourceType;
private Long resourceId;
private String note;
private Long operatorId;
private String operatorName;
private String operatorType;
private LocalDateTime createdAt;
}
@@ -0,0 +1,20 @@
package com.baiyue.firstleg.customs.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter @Setter @TableName("fl_customs_operation")
public class CustomsOperationEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long operatorId;
private String action;
private String idempotencyKey;
private String requestHash;
private String status;
private String resultType;
private Long resultId;
private LocalDateTime createdAt;
private LocalDateTime completedAt;
}
@@ -0,0 +1,24 @@
package com.baiyue.firstleg.customs.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter @Setter @TableName("fl_customs_receipt")
public class CustomsReceiptEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long customsCaseId;
private Long submissionBatchId;
private String receiptType;
private Long fileId;
private Integer receiptVersion;
private Long replacesReceiptId;
private String fileName;
private String fileHash;
private String visibility;
private String note;
private Long uploadedBy;
private String uploadedByName;
private LocalDateTime uploadedAt;
}
@@ -0,0 +1,22 @@
package com.baiyue.firstleg.customs.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter @Setter @TableName("fl_customs_submission_batch")
public class CustomsSubmissionBatchEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long customsCaseId;
private String batchNo;
private Integer batchVersion;
private String customsAgentName;
private String externalReference;
private String fileSnapshotJson;
private String status;
private String note;
private Long submittedBy;
private String submittedByName;
private LocalDateTime submittedAt;
}
@@ -0,0 +1,23 @@
package com.baiyue.firstleg.customs.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter @Setter @TableName("fl_customs_supplement_request")
public class CustomsSupplementRequestEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long customsCaseId;
private Long sourceDocumentId;
private Long resolvedDocumentId;
private String checklistItemCode;
private String reason;
private LocalDateTime dueAt;
private String status;
private Long requestedBy;
private String requestedByName;
private LocalDateTime requestedAt;
private Long resolvedBy;
private LocalDateTime resolvedAt;
}
@@ -0,0 +1,5 @@
package com.baiyue.firstleg.customs.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.customs.entity.CustomsCaseEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface CustomsCaseMapper extends BaseMapper<CustomsCaseEntity> {}
@@ -0,0 +1,5 @@
package com.baiyue.firstleg.customs.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.customs.entity.CustomsChecklistVersionEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface CustomsChecklistVersionMapper extends BaseMapper<CustomsChecklistVersionEntity> {}
@@ -0,0 +1,5 @@
package com.baiyue.firstleg.customs.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.customs.entity.CustomsDocumentEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface CustomsDocumentMapper extends BaseMapper<CustomsDocumentEntity> {}
@@ -0,0 +1,5 @@
package com.baiyue.firstleg.customs.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.customs.entity.CustomsHistoryEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface CustomsHistoryMapper extends BaseMapper<CustomsHistoryEntity> {}
@@ -0,0 +1,5 @@
package com.baiyue.firstleg.customs.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.customs.entity.CustomsOperationEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface CustomsOperationMapper extends BaseMapper<CustomsOperationEntity> {}
@@ -0,0 +1,5 @@
package com.baiyue.firstleg.customs.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.customs.entity.CustomsReceiptEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface CustomsReceiptMapper extends BaseMapper<CustomsReceiptEntity> {}
@@ -0,0 +1,5 @@
package com.baiyue.firstleg.customs.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.customs.entity.CustomsSubmissionBatchEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface CustomsSubmissionBatchMapper extends BaseMapper<CustomsSubmissionBatchEntity> {}
@@ -0,0 +1,5 @@
package com.baiyue.firstleg.customs.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.customs.entity.CustomsSupplementRequestEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface CustomsSupplementRequestMapper extends BaseMapper<CustomsSupplementRequestEntity> {}
@@ -0,0 +1,188 @@
package com.baiyue.firstleg.customs.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.firstleg.customs.dto.CustomsDtos.Query;
import com.baiyue.firstleg.customs.entity.*;
import com.baiyue.firstleg.customs.mapper.*;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.mapper.InboundOrderMapper;
import com.baiyue.firstleg.mapper.LoadingPlanItemMapper;
import com.baiyue.firstleg.mapper.LoadingPlanMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
@Repository
public class CustomsRepository {
@Resource private CustomsChecklistVersionMapper checklists;
@Resource private CustomsCaseMapper cases;
@Resource private CustomsDocumentMapper documents;
@Resource private CustomsSupplementRequestMapper supplements;
@Resource private CustomsSubmissionBatchMapper batches;
@Resource private CustomsReceiptMapper receipts;
@Resource private CustomsHistoryMapper histories;
@Resource private CustomsOperationMapper operations;
@Resource private InboundOrderMapper inboundOrders;
@Resource private LoadingPlanMapper loadingPlans;
@Resource private LoadingPlanItemMapper loadingPlanItems;
public void insert(CustomsChecklistVersionEntity value) { checklists.insert(value); }
public CustomsChecklistVersionEntity checklist(long id) { return checklists.selectById(id); }
public CustomsChecklistVersionEntity checklist(String code, String version) {
return checklists.selectOne(new LambdaQueryWrapper<CustomsChecklistVersionEntity>()
.eq(CustomsChecklistVersionEntity::getChecklistCode, code)
.eq(CustomsChecklistVersionEntity::getVersionNo, version));
}
public void insert(CustomsCaseEntity value) { cases.insert(value); }
public CustomsCaseEntity customsCase(long id) { return cases.selectById(id); }
public CustomsCaseEntity customsCase(long tenantId, long id) {
return cases.selectOne(new LambdaQueryWrapper<CustomsCaseEntity>()
.eq(CustomsCaseEntity::getId, id).eq(CustomsCaseEntity::getTenantId, tenantId));
}
public CustomsCaseEntity caseByOrder(long tenantId, long orderId) {
return cases.selectOne(new LambdaQueryWrapper<CustomsCaseEntity>()
.eq(CustomsCaseEntity::getTenantId, tenantId).eq(CustomsCaseEntity::getOrderId, orderId)
.orderByDesc(CustomsCaseEntity::getUpdatedAt).last("LIMIT 1"));
}
public PageResult<CustomsCaseEntity> page(Query query) {
var wrapper = new LambdaQueryWrapper<CustomsCaseEntity>()
.eq(query.customerId() != null, CustomsCaseEntity::getTenantId, query.customerId())
.eq(query.orderId() != null, CustomsCaseEntity::getOrderId, query.orderId())
.eq(query.inboundId() != null, CustomsCaseEntity::getInboundId, query.inboundId())
.eq(query.containerBookingId() != null, CustomsCaseEntity::getContainerBookingId, query.containerBookingId())
.eq(query.status() != null && !query.status().isBlank(), CustomsCaseEntity::getStatus, query.status())
.orderByDesc(CustomsCaseEntity::getUpdatedAt).orderByDesc(CustomsCaseEntity::getId);
return PageResult.from(cases.selectPage(new Page<>(query.page(), query.size()), wrapper));
}
public int transition(long id, int expectedVersion, String status, long operatorId, String operatorName) {
return cases.update(null, new LambdaUpdateWrapper<CustomsCaseEntity>()
.eq(CustomsCaseEntity::getId, id).eq(CustomsCaseEntity::getVersion, expectedVersion)
.set(CustomsCaseEntity::getStatus, status).set(CustomsCaseEntity::getUpdatedBy, operatorId)
.set(CustomsCaseEntity::getUpdatedByName, operatorName).set(CustomsCaseEntity::getUpdatedAt, LocalDateTime.now())
.set(CustomsCaseEntity::getVersion, expectedVersion + 1));
}
public boolean inboundBelongsTo(long tenantId, long orderId, long inboundId) {
return inboundOrders.selectCount(new LambdaQueryWrapper<com.baiyue.firstleg.entity.InboundOrderEntity>()
.eq(com.baiyue.firstleg.entity.InboundOrderEntity::getTenantId, tenantId)
.eq(com.baiyue.firstleg.entity.InboundOrderEntity::getOrderId, orderId)
.eq(com.baiyue.firstleg.entity.InboundOrderEntity::getId, inboundId)) > 0;
}
public CustomsCaseEntity gateCase(long tenantId, long orderId, long inboundId, long containerBookingId) {
return cases.selectOne(new LambdaQueryWrapper<CustomsCaseEntity>()
.eq(CustomsCaseEntity::getTenantId, tenantId).eq(CustomsCaseEntity::getOrderId, orderId)
.eq(CustomsCaseEntity::getInboundId, inboundId)
.eq(CustomsCaseEntity::getContainerBookingId, containerBookingId)
.orderByDesc(CustomsCaseEntity::getUpdatedAt).last("LIMIT 1"));
}
public boolean containerCarriesOrder(long containerId, long tenantId, long orderId) {
LoadingPlanEntity plan = loadingPlans.selectOne(new LambdaQueryWrapper<LoadingPlanEntity>()
.eq(LoadingPlanEntity::getContainerBookingId, containerId));
return plan != null && loadingPlanItems.selectCount(new LambdaQueryWrapper<com.baiyue.firstleg.entity.LoadingPlanItemEntity>()
.eq(com.baiyue.firstleg.entity.LoadingPlanItemEntity::getLoadingPlanId, plan.getId())
.eq(com.baiyue.firstleg.entity.LoadingPlanItemEntity::getAccountId, tenantId)
.eq(com.baiyue.firstleg.entity.LoadingPlanItemEntity::getOrderId, orderId)
.eq(com.baiyue.firstleg.entity.LoadingPlanItemEntity::getDeleted, false)) > 0;
}
public void insert(CustomsDocumentEntity value) { documents.insert(value); }
public CustomsDocumentEntity document(long id) { return documents.selectById(id); }
public List<CustomsDocumentEntity> documents(long caseId) {
return documents.selectList(new LambdaQueryWrapper<CustomsDocumentEntity>()
.eq(CustomsDocumentEntity::getCustomsCaseId, caseId)
.orderByAsc(CustomsDocumentEntity::getChecklistItemCode)
.orderByAsc(CustomsDocumentEntity::getDocumentVersion));
}
public CustomsDocumentEntity latestDocument(long caseId, String itemCode) {
return documents.selectOne(new LambdaQueryWrapper<CustomsDocumentEntity>()
.eq(CustomsDocumentEntity::getCustomsCaseId, caseId)
.eq(CustomsDocumentEntity::getChecklistItemCode, itemCode)
.orderByDesc(CustomsDocumentEntity::getDocumentVersion).last("LIMIT 1"));
}
public int markDocument(long id, String status, long reviewerId, String reviewerName, String reason) {
return documents.update(null, new LambdaUpdateWrapper<CustomsDocumentEntity>()
.eq(CustomsDocumentEntity::getId, id)
.set(CustomsDocumentEntity::getStatus, status).set(CustomsDocumentEntity::getReviewedBy, reviewerId)
.set(CustomsDocumentEntity::getReviewedByName, reviewerName)
.set(CustomsDocumentEntity::getReviewedAt, LocalDateTime.now())
.set(CustomsDocumentEntity::getReviewReason, reason));
}
public int supersede(long id) {
return documents.update(null, new LambdaUpdateWrapper<CustomsDocumentEntity>()
.eq(CustomsDocumentEntity::getId, id).in(CustomsDocumentEntity::getStatus, "UPLOADED", "RETURNED")
.set(CustomsDocumentEntity::getStatus, "SUPERSEDED"));
}
public int freeze(long id) {
return documents.update(null, new LambdaUpdateWrapper<CustomsDocumentEntity>()
.eq(CustomsDocumentEntity::getId, id).eq(CustomsDocumentEntity::getStatus, "APPROVED")
.set(CustomsDocumentEntity::getStatus, "FROZEN"));
}
public void insert(CustomsSupplementRequestEntity value) { supplements.insert(value); }
public List<CustomsSupplementRequestEntity> supplements(long caseId) {
return supplements.selectList(new LambdaQueryWrapper<CustomsSupplementRequestEntity>()
.eq(CustomsSupplementRequestEntity::getCustomsCaseId, caseId)
.orderByAsc(CustomsSupplementRequestEntity::getRequestedAt));
}
public void resolveSupplements(long caseId, String itemCode, long documentId, long operatorId) {
supplements.update(null, new LambdaUpdateWrapper<CustomsSupplementRequestEntity>()
.eq(CustomsSupplementRequestEntity::getCustomsCaseId, caseId)
.eq(CustomsSupplementRequestEntity::getChecklistItemCode, itemCode)
.eq(CustomsSupplementRequestEntity::getStatus, "OPEN")
.set(CustomsSupplementRequestEntity::getStatus, "RESOLVED")
.set(CustomsSupplementRequestEntity::getResolvedDocumentId, documentId)
.set(CustomsSupplementRequestEntity::getResolvedBy, operatorId)
.set(CustomsSupplementRequestEntity::getResolvedAt, LocalDateTime.now()));
}
public void insert(CustomsSubmissionBatchEntity value) { batches.insert(value); }
public CustomsSubmissionBatchEntity batch(long id) { return batches.selectById(id); }
public List<CustomsSubmissionBatchEntity> batches(long caseId) {
return batches.selectList(new LambdaQueryWrapper<CustomsSubmissionBatchEntity>()
.eq(CustomsSubmissionBatchEntity::getCustomsCaseId, caseId)
.orderByAsc(CustomsSubmissionBatchEntity::getBatchVersion));
}
public void insert(CustomsReceiptEntity value) { receipts.insert(value); }
public CustomsReceiptEntity receipt(long id) { return receipts.selectById(id); }
public CustomsReceiptEntity latestReceipt(long batchId, String type) {
return receipts.selectOne(new LambdaQueryWrapper<CustomsReceiptEntity>()
.eq(CustomsReceiptEntity::getSubmissionBatchId, batchId)
.eq(CustomsReceiptEntity::getReceiptType, type)
.orderByDesc(CustomsReceiptEntity::getReceiptVersion).last("LIMIT 1"));
}
public List<CustomsReceiptEntity> receipts(long caseId, boolean customerOnly) {
return receipts.selectList(new LambdaQueryWrapper<CustomsReceiptEntity>()
.eq(CustomsReceiptEntity::getCustomsCaseId, caseId)
.eq(customerOnly, CustomsReceiptEntity::getVisibility, "CUSTOMER")
.orderByAsc(CustomsReceiptEntity::getUploadedAt));
}
public void insert(CustomsHistoryEntity value) { histories.insert(value); }
public List<CustomsHistoryEntity> history(long caseId) {
return histories.selectList(new LambdaQueryWrapper<CustomsHistoryEntity>()
.eq(CustomsHistoryEntity::getCustomsCaseId, caseId).orderByAsc(CustomsHistoryEntity::getCreatedAt));
}
public void insert(CustomsOperationEntity value) { operations.insert(value); }
public CustomsOperationEntity operation(long operatorId, String action, String key) {
return operations.selectOne(new LambdaQueryWrapper<CustomsOperationEntity>()
.eq(CustomsOperationEntity::getOperatorId, operatorId)
.eq(CustomsOperationEntity::getAction, action)
.eq(CustomsOperationEntity::getIdempotencyKey, key));
}
public int completeOperation(long id, String resultType, long resultId) {
return operations.update(null, new LambdaUpdateWrapper<CustomsOperationEntity>()
.eq(CustomsOperationEntity::getId, id).eq(CustomsOperationEntity::getStatus, "PROCESSING")
.set(CustomsOperationEntity::getStatus, "SUCCESS")
.set(CustomsOperationEntity::getResultType, resultType)
.set(CustomsOperationEntity::getResultId, resultId)
.set(CustomsOperationEntity::getCompletedAt, LocalDateTime.now()));
}
}
@@ -0,0 +1,30 @@
package com.baiyue.firstleg.customs.service;
import com.baiyue.firstleg.customs.entity.CustomsCaseEntity;
import com.baiyue.firstleg.customs.repository.CustomsRepository;
import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.GateItem;
import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
@Component
public class CustomsDispatchGateContributor implements OutboundDispatchGateContributor {
@Resource private CustomsRepository repository;
@Override public String code() { return "CUSTOMS_CLEARANCE"; }
@Override
public GateItem assess(Context context) {
if (context.tenantId() <= 0 || context.customerId() <= 0 || context.tenantId() != context.customerId()) {
return gate(false, "INVALID_CUSTOMER_SCOPE");
}
CustomsCaseEntity value = repository.gateCase(context.tenantId(), context.orderId(), context.inboundId(),
context.containerBookingId());
if (value == null) return gate(false, "MISSING");
return gate("CLEARANCE_COMPLETED".equals(value.getStatus()), value.getStatus());
}
private static GateItem gate(boolean passed, String actual) {
return new GateItem("CUSTOMS_CLEARANCE", "报关放行", passed, actual, "CLEARANCE_COMPLETED");
}
}
@@ -0,0 +1,477 @@
package com.baiyue.firstleg.customs.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
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.customs.client.CustomsFileClient;
import com.baiyue.firstleg.customs.dto.CustomsDtos.*;
import com.baiyue.firstleg.customs.entity.*;
import com.baiyue.firstleg.customs.repository.CustomsRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import feign.FeignException;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
public class CustomsService {
private static final String DOCUMENT_TYPE = "CUSTOMS_DOCUMENT";
private static final String RECEIPT_TYPE = "CUSTOMS_RECEIPT";
private static final Set<String> EDITABLE = Set.of("DOCUMENTS_PENDING", "REVIEWING", "SUPPLEMENT_REQUIRED", "READY_TO_SUBMIT");
@Resource private CustomsRepository repository;
@Resource private CustomsFileClient files;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
public PageResult<CustomsCaseEntity> page(Query query) { return repository.page(query); }
public CaseDetail platformDetail(long caseId) { return detail(required(caseId), false); }
public CaseDetail merchantDetail(long tenantId, long caseId) { return detail(required(tenantId, caseId), true); }
public CaseDetail merchantOrder(long tenantId, long orderId) {
CustomsCaseEntity value = repository.caseByOrder(tenantId, orderId);
if (value == null) throw notFound("订单报关协同单不存在");
return detail(value, true);
}
@Transactional
public ChecklistView createChecklist(long operatorId, String operatorName, String key, CreateChecklistRequest request) {
validateChecklist(request.items());
List<ChecklistItem> normalizedItems = request.items().stream()
.map(item -> new ChecklistItem(normalize(item.code()), item.name().trim(), item.required())).toList();
String action = "CREATE_CHECKLIST";
CustomsOperationEntity operation = begin(operatorId, action, key, hash(action, request));
if (completed(operation)) return checklist(requiredChecklist(operation.getResultId()));
if (repository.checklist(normalize(request.checklistCode()), normalize(request.versionNo())) != null) {
throw conflict("资料清单版本已存在");
}
CustomsChecklistVersionEntity value = new CustomsChecklistVersionEntity();
value.setId(IdWorker.getId()); value.setChecklistCode(normalize(request.checklistCode()));
value.setVersionNo(normalize(request.versionNo())); value.setCountryCode(normalize(request.countryCode()));
value.setChannelCode(normalize(request.channelCode())); value.setCargoType(normalize(request.cargoType()));
value.setItemsJson(json(normalizedItems)); value.setStatus("ACTIVE"); value.setCreatedBy(operatorId);
value.setCreatedByName(operatorName); value.setCreatedAt(LocalDateTime.now());
repository.insert(value); complete(operation, "CHECKLIST", value.getId());
return checklist(value);
}
@Transactional
public CaseDetail createCase(long operatorId, String operatorName, String key, CreateCaseRequest request) {
if (request.expectedVersion() != 0) throw bad("新增 expectedVersion 必须为0");
CustomsChecklistVersionEntity checklist = requiredChecklist(request.checklistVersionId());
if (!"ACTIVE".equals(checklist.getStatus())) throw conflict("资料清单版本不可用");
if (!repository.inboundBelongsTo(request.customerId(), request.orderId(), request.inboundId())) {
throw bad("入库单、订单与目标客户归属不一致");
}
long containerId = request.containerBookingId() == null ? 0 : request.containerBookingId();
if (containerId > 0 && !repository.containerCarriesOrder(containerId, request.customerId(), request.orderId())) {
throw bad("柜次未承载该客户订单");
}
String action = "CREATE_CASE";
CustomsOperationEntity operation = begin(operatorId, action, key, hash(action, request));
if (completed(operation)) return platformDetail(operation.getResultId());
CustomsCaseEntity value = new CustomsCaseEntity();
value.setId(IdWorker.getId()); value.setCustomsNo("CD" + value.getId()); value.setTenantId(request.customerId());
value.setOrderId(request.orderId()); value.setInboundId(request.inboundId()); value.setContainerBookingId(containerId);
value.setChecklistVersionId(checklist.getId()); value.setChecklistCode(checklist.getChecklistCode());
value.setChecklistVersionNo(checklist.getVersionNo()); value.setStatus("DOCUMENTS_PENDING");
value.setRemark(trim(request.remark())); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setCreatedAt(LocalDateTime.now()); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName);
value.setUpdatedAt(value.getCreatedAt()); value.setVersion(0);
try { repository.insert(value); } catch (DuplicateKeyException ex) { throw conflict("订单和柜次已有报关协同单"); }
history(value, "CREATE", null, value.getStatus(), "CASE", value.getId(), value.getRemark(), operatorId, operatorName, "PLATFORM");
complete(operation, "CASE", value.getId());
return platformDetail(value.getId());
}
@Transactional
public CustomsDocumentEntity uploadDocument(long tenantId, long caseId, String actorType, long operatorId,
String operatorName, String key, DocumentUploadData data,
MultipartFile file) {
CustomsCaseEntity customsCase = scopedCase(tenantId, caseId, actorType);
String action = actorType + "_UPLOAD_DOCUMENT";
String fileHash = multipartHash(file);
CustomsOperationEntity operation = begin(operatorId, action, key,
hash(action, caseId, data, fileHash, file == null ? null : file.getOriginalFilename()));
if (completed(operation)) return requiredDocument(operation.getResultId(), customsCase.getId());
validateDocumentCommand(customsCase, data.itemCode(), data.expectedVersion());
CustomsFileClient.Metadata metadata = upload(customsCase, DOCUMENT_TYPE, Long.toString(caseId), operatorId, file);
CustomsDocumentEntity value = addDocument(customsCase, data.itemCode(), metadata, actorType, operatorId, operatorName);
complete(operation, "DOCUMENT", value.getId());
return value;
}
@Transactional
public CustomsDocumentEntity referenceDocument(long tenantId, long caseId, String actorType, long operatorId,
String operatorName, String key, DocumentReferenceRequest request) {
CustomsCaseEntity customsCase = scopedCase(tenantId, caseId, actorType);
String action = actorType + "_REFERENCE_DOCUMENT";
CustomsOperationEntity operation = begin(operatorId, action, key, hash(action, caseId, request));
if (completed(operation)) return requiredDocument(operation.getResultId(), customsCase.getId());
validateDocumentCommand(customsCase, request.itemCode(), request.expectedVersion());
CustomsFileClient.Metadata metadata = metadata(customsCase, DOCUMENT_TYPE, Long.toString(caseId), request.fileId());
CustomsDocumentEntity value = addDocument(customsCase, request.itemCode(), metadata, actorType, operatorId, operatorName);
complete(operation, "DOCUMENT", value.getId());
return value;
}
@Transactional
public CaseDetail review(long caseId, long documentId, long operatorId, String operatorName,
String key, ReviewRequest request) {
CustomsCaseEntity customsCase = required(caseId);
String action = "REVIEW_DOCUMENT_" + request.decision();
CustomsOperationEntity operation = begin(operatorId, action, key, hash(action, caseId, documentId, request));
if (completed(operation)) return platformDetail(caseId);
requiredVersion(customsCase, request.expectedVersion());
if (!EDITABLE.contains(customsCase.getStatus())) throw conflict("当前报关状态不可审核资料");
CustomsDocumentEntity document = requiredDocument(documentId, caseId);
CustomsDocumentEntity latest = repository.latestDocument(caseId, document.getChecklistItemCode());
if (!Objects.equals(document.getId(), latest.getId()) || !"UPLOADED".equals(document.getStatus())) {
throw conflict("只能审核当前待审文件版本");
}
if ("RETURN".equals(request.decision()) && !StringUtils.hasText(request.reason())) throw bad("退回原因不能为空");
repository.markDocument(documentId, "APPROVE".equals(request.decision()) ? "APPROVED" : "RETURNED",
operatorId, operatorName, trim(request.reason()));
if ("RETURN".equals(request.decision())) {
CustomsSupplementRequestEntity supplement = new CustomsSupplementRequestEntity();
supplement.setId(IdWorker.getId()); supplement.setCustomsCaseId(caseId); supplement.setSourceDocumentId(documentId);
supplement.setChecklistItemCode(document.getChecklistItemCode()); supplement.setReason(request.reason().trim());
supplement.setDueAt(request.supplementDueAt()); supplement.setStatus("OPEN"); supplement.setRequestedBy(operatorId);
supplement.setRequestedByName(operatorName); supplement.setRequestedAt(LocalDateTime.now()); repository.insert(supplement);
}
String next = calculateStatus(customsCase);
transition(customsCase, next, operatorId, operatorName);
history(customsCase, action, customsCase.getStatus(), next, "DOCUMENT", documentId, request.reason(),
operatorId, operatorName, "PLATFORM");
complete(operation, "CASE", caseId);
return platformDetail(caseId);
}
@Transactional
public CustomsSubmissionBatchEntity submitBatch(long caseId, long operatorId, String operatorName,
String key, SubmitBatchRequest request) {
CustomsCaseEntity customsCase = required(caseId);
String action = "SUBMIT_AGENT_BATCH";
CustomsOperationEntity operation = begin(operatorId, action, key, hash(action, caseId, request));
if (completed(operation)) return requiredBatch(operation.getResultId(), caseId);
requiredVersion(customsCase, request.expectedVersion());
if (!"READY_TO_SUBMIT".equals(customsCase.getStatus())) throw conflict("报关资料未全部审核通过");
List<CustomsDocumentEntity> current = currentDocuments(customsCase);
for (CustomsDocumentEntity document : current) {
if (!"APPROVED".equals(document.getStatus()) || repository.freeze(document.getId()) != 1) {
throw conflict("文件版本已变化,请刷新后重试");
}
}
CustomsSubmissionBatchEntity batch = new CustomsSubmissionBatchEntity();
batch.setId(IdWorker.getId()); batch.setCustomsCaseId(caseId); batch.setBatchVersion(repository.batches(caseId).size() + 1);
batch.setBatchNo("CB" + batch.getId()); batch.setCustomsAgentName(request.customsAgentName().trim());
batch.setExternalReference(trim(request.externalReference())); batch.setFileSnapshotJson(json(current.stream()
.map(v -> Map.of("documentId", v.getId(), "fileId", v.getFileId(), "itemCode", v.getChecklistItemCode(),
"documentVersion", v.getDocumentVersion(), "fileHash", v.getFileHash())).toList()));
batch.setStatus("SUBMITTED"); batch.setNote(trim(request.note())); batch.setSubmittedBy(operatorId);
batch.setSubmittedByName(operatorName); batch.setSubmittedAt(LocalDateTime.now()); repository.insert(batch);
transition(customsCase, "RECEIPT_PENDING", operatorId, operatorName);
history(customsCase, action, customsCase.getStatus(), "RECEIPT_PENDING", "SUBMISSION_BATCH", batch.getId(),
request.note(), operatorId, operatorName, "PLATFORM");
complete(operation, "BATCH", batch.getId());
return batch;
}
@Transactional
public CustomsReceiptEntity uploadReceipt(long batchId, long operatorId, String operatorName, String key,
ReceiptUploadData data, MultipartFile file) {
CustomsSubmissionBatchEntity batch = requiredBatch(batchId, null);
CustomsCaseEntity customsCase = required(batch.getCustomsCaseId());
String action = "UPLOAD_RECEIPT_" + data.receiptType();
String fileHash = multipartHash(file);
CustomsOperationEntity operation = begin(operatorId, action, key,
hash(action, batchId, data, fileHash, file == null ? null : file.getOriginalFilename()));
if (completed(operation)) return requiredReceipt(operation.getResultId(), customsCase.getId(), false);
requiredVersion(customsCase, data.expectedVersion());
validateReceiptStatus(customsCase);
CustomsFileClient.Metadata metadata = upload(customsCase, RECEIPT_TYPE, Long.toString(batchId), operatorId, file);
CustomsReceiptEntity value = addReceipt(customsCase, batch, data.receiptType(), data.visibility(), data.note(),
metadata, operatorId, operatorName);
complete(operation, "RECEIPT", value.getId());
return value;
}
@Transactional
public CustomsReceiptEntity referenceReceipt(long batchId, long operatorId, String operatorName, String key,
ReceiptReferenceRequest request) {
CustomsSubmissionBatchEntity batch = requiredBatch(batchId, null);
CustomsCaseEntity customsCase = required(batch.getCustomsCaseId());
String action = "REFERENCE_RECEIPT_" + request.receiptType();
CustomsOperationEntity operation = begin(operatorId, action, key, hash(action, batchId, request));
if (completed(operation)) return requiredReceipt(operation.getResultId(), customsCase.getId(), false);
requiredVersion(customsCase, request.expectedVersion());
validateReceiptStatus(customsCase);
CustomsFileClient.Metadata metadata = metadata(customsCase, RECEIPT_TYPE, Long.toString(batchId), request.fileId());
CustomsReceiptEntity value = addReceipt(customsCase, batch, request.receiptType(), request.visibility(), request.note(),
metadata, operatorId, operatorName);
complete(operation, "RECEIPT", value.getId());
return value;
}
public Download downloadDocument(long tenantId, long caseId, long documentId, boolean merchant) {
CustomsCaseEntity customsCase = merchant ? required(tenantId, caseId) : required(caseId);
CustomsDocumentEntity document = requiredDocument(documentId, caseId);
return download(customsCase, DOCUMENT_TYPE, Long.toString(caseId), document.getFileId(), document.getFileName());
}
public Download downloadReceipt(long tenantId, long caseId, long receiptId, boolean merchant) {
CustomsCaseEntity customsCase = merchant ? required(tenantId, caseId) : required(caseId);
CustomsReceiptEntity receipt = requiredReceipt(receiptId, caseId, merchant);
CustomsSubmissionBatchEntity batch = requiredBatch(receipt.getSubmissionBatchId(), caseId);
return download(customsCase, RECEIPT_TYPE, Long.toString(batch.getId()), receipt.getFileId(), receipt.getFileName());
}
private CustomsDocumentEntity addDocument(CustomsCaseEntity customsCase, String rawItemCode,
CustomsFileClient.Metadata metadata, String actorType,
long operatorId, String operatorName) {
String itemCode = normalize(rawItemCode);
CustomsDocumentEntity previous = repository.latestDocument(customsCase.getId(), itemCode);
if (previous != null && Set.of("UPLOADED", "RETURNED").contains(previous.getStatus())
&& repository.supersede(previous.getId()) != 1) throw conflict("文件版本已变化,请刷新后重试");
CustomsDocumentEntity value = new CustomsDocumentEntity();
value.setId(IdWorker.getId()); value.setCustomsCaseId(customsCase.getId()); value.setChecklistItemCode(itemCode);
value.setFileId(metadata.fileId()); value.setDocumentVersion(previous == null ? 1 : previous.getDocumentVersion() + 1);
value.setReplacesDocumentId(previous == null ? null : previous.getId()); value.setFileName(metadata.fileName());
value.setFileHash(metadata.sha256()); value.setStatus("UPLOADED"); value.setVisibility("CUSTOMER");
value.setUploadedBy(operatorId); value.setUploadedByType(actorType); value.setUploadedAt(LocalDateTime.now());
try { repository.insert(value); } catch (DuplicateKeyException ex) { throw conflict("文件版本已存在"); }
repository.resolveSupplements(customsCase.getId(), itemCode, value.getId(), operatorId);
String next = hasEveryRequiredDocument(customsCase) ? "REVIEWING" : "DOCUMENTS_PENDING";
transition(customsCase, next, operatorId, operatorName);
history(customsCase, previous == null ? "UPLOAD_DOCUMENT" : "REPLACE_DOCUMENT", customsCase.getStatus(), next,
"DOCUMENT", value.getId(), itemCode, operatorId, operatorName, actorType);
return value;
}
private CustomsReceiptEntity addReceipt(CustomsCaseEntity customsCase, CustomsSubmissionBatchEntity batch,
String receiptType, String visibility, String note,
CustomsFileClient.Metadata metadata, long operatorId, String operatorName) {
CustomsReceiptEntity previous = repository.latestReceipt(batch.getId(), receiptType);
CustomsReceiptEntity value = new CustomsReceiptEntity(); value.setId(IdWorker.getId());
value.setCustomsCaseId(customsCase.getId()); value.setSubmissionBatchId(batch.getId());
value.setReceiptType(receiptType); value.setFileId(metadata.fileId());
value.setReceiptVersion(previous == null ? 1 : previous.getReceiptVersion() + 1);
value.setReplacesReceiptId(previous == null ? null : previous.getId()); value.setFileName(metadata.fileName());
value.setFileHash(metadata.sha256()); value.setVisibility(visibility); value.setNote(trim(note));
value.setUploadedBy(operatorId); value.setUploadedByName(operatorName); value.setUploadedAt(LocalDateTime.now());
repository.insert(value);
String next = "REJECTED".equals(receiptType) ? "SUPPLEMENT_REQUIRED"
: "RELEASED".equals(receiptType) ? "CLEARANCE_COMPLETED" : "RECEIPT_AVAILABLE";
transition(customsCase, next, operatorId, operatorName);
history(customsCase, "UPLOAD_RECEIPT", customsCase.getStatus(), next, "RECEIPT", value.getId(), note,
operatorId, operatorName, "PLATFORM");
return value;
}
private List<CustomsDocumentEntity> currentDocuments(CustomsCaseEntity customsCase) {
Map<String, CustomsDocumentEntity> current = repository.documents(customsCase.getId()).stream()
.collect(Collectors.toMap(CustomsDocumentEntity::getChecklistItemCode, Function.identity(),
(left, right) -> left.getDocumentVersion() > right.getDocumentVersion() ? left : right));
return checklistItems(customsCase).stream().filter(ChecklistItem::required).map(v -> normalize(v.code()))
.map(current::get).filter(Objects::nonNull).toList();
}
private String calculateStatus(CustomsCaseEntity customsCase) {
List<CustomsSupplementRequestEntity> supplements = repository.supplements(customsCase.getId());
if (supplements.stream().anyMatch(v -> "OPEN".equals(v.getStatus()))) return "SUPPLEMENT_REQUIRED";
Map<String, CustomsDocumentEntity> current = repository.documents(customsCase.getId()).stream()
.collect(Collectors.toMap(CustomsDocumentEntity::getChecklistItemCode, Function.identity(),
(left, right) -> left.getDocumentVersion() > right.getDocumentVersion() ? left : right));
List<String> required = checklistItems(customsCase).stream().filter(ChecklistItem::required)
.map(v -> normalize(v.code())).toList();
if (required.stream().allMatch(code -> current.containsKey(code) && "APPROVED".equals(current.get(code).getStatus()))) {
return "READY_TO_SUBMIT";
}
return required.stream().allMatch(current::containsKey) ? "REVIEWING" : "DOCUMENTS_PENDING";
}
private boolean hasEveryRequiredDocument(CustomsCaseEntity customsCase) {
Set<String> codes = repository.documents(customsCase.getId()).stream()
.filter(v -> !"SUPERSEDED".equals(v.getStatus()))
.map(CustomsDocumentEntity::getChecklistItemCode).collect(Collectors.toSet());
return checklistItems(customsCase).stream().filter(ChecklistItem::required)
.allMatch(v -> codes.contains(normalize(v.code())));
}
private void validateDocumentCommand(CustomsCaseEntity customsCase, String itemCode, int expectedVersion) {
requiredVersion(customsCase, expectedVersion);
if (!EDITABLE.contains(customsCase.getStatus())) throw conflict("当前报关状态不可上传或替换资料");
String normalized = normalize(itemCode);
if (checklistItems(customsCase).stream().noneMatch(v -> normalize(v.code()).equals(normalized))) {
throw bad("文件类型不在本单资料清单中");
}
}
private void validateReceiptStatus(CustomsCaseEntity customsCase) {
if (!Set.of("RECEIPT_PENDING", "RECEIPT_AVAILABLE", "SUPPLEMENT_REQUIRED").contains(customsCase.getStatus())) {
throw conflict("当前报关状态不可上传回执");
}
}
private CustomsCaseEntity scopedCase(long tenantId, long caseId, String actorType) {
return "MERCHANT".equals(actorType) ? required(tenantId, caseId) : required(caseId);
}
private CustomsFileClient.Metadata upload(CustomsCaseEntity customsCase, String type, String businessId,
long operatorId, MultipartFile file) {
try {
var response = files.upload(internalToken, operatorId, customsCase.getTenantId(), type, businessId, file);
if (response == null || !response.successful() || response.data() == null) throw bad("附件上传失败");
return response.data();
} catch (FeignException ex) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); }
}
private CustomsFileClient.Metadata metadata(CustomsCaseEntity customsCase, String type, String businessId, long fileId) {
try {
var response = files.metadata(internalToken, customsCase.getTenantId(), type, businessId, fileId);
if (response == null || !response.successful() || response.data() == null) throw bad("附件不存在或归属不匹配");
return response.data();
} catch (FeignException ex) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); }
}
private Download download(CustomsCaseEntity customsCase, String type, String businessId, long fileId, String fileName) {
try {
var response = files.download(internalToken, customsCase.getTenantId(), type, businessId, fileId);
if (response == null || !response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
throw notFound("附件内容不存在");
}
String contentType = response.getHeaders().getContentType() == null
? "application/octet-stream" : response.getHeaders().getContentType().toString();
if (contentType.toLowerCase(Locale.ROOT).contains("application/json")) throw notFound("附件内容不存在");
return new Download(fileName, contentType, response.getBody());
} catch (FeignException ex) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); }
}
private CustomsOperationEntity begin(long operatorId, String action, String key, String requestHash) {
if (!StringUtils.hasText(key) || key.length() > 128) throw bad("Idempotency-Key不能为空且不能超过128个字符");
CustomsOperationEntity value = new CustomsOperationEntity(); value.setId(IdWorker.getId());
value.setOperatorId(operatorId); value.setAction(action); value.setIdempotencyKey(key);
value.setRequestHash(requestHash); value.setStatus("PROCESSING"); value.setCreatedAt(LocalDateTime.now());
try { repository.insert(value); return value; }
catch (DuplicateKeyException ex) {
CustomsOperationEntity existing = repository.operation(operatorId, action, key);
if (existing == null || !Objects.equals(requestHash, existing.getRequestHash())) throw conflict("幂等键已用于不同请求");
if (!"SUCCESS".equals(existing.getStatus()) || existing.getResultId() == null) throw conflict("相同请求正在处理中,请稍后重试");
return existing;
}
}
private void complete(CustomsOperationEntity operation, String resultType, long resultId) {
if (repository.completeOperation(operation.getId(), resultType, resultId) != 1) throw conflict("幂等请求完成状态冲突");
}
private static boolean completed(CustomsOperationEntity operation) { return "SUCCESS".equals(operation.getStatus()); }
private void transition(CustomsCaseEntity value, String next, long operatorId, String operatorName) {
if (repository.transition(value.getId(), value.getVersion(), next, operatorId, operatorName) != 1) throw concurrent();
}
private void history(CustomsCaseEntity customsCase, String action, String from, String to,
String resourceType, long resourceId, String note, long operatorId,
String operatorName, String operatorType) {
CustomsHistoryEntity value = new CustomsHistoryEntity(); value.setId(IdWorker.getId());
value.setCustomsCaseId(customsCase.getId()); value.setAction(action); value.setFromStatus(from);
value.setToStatus(to); value.setResourceType(resourceType); value.setResourceId(resourceId);
value.setNote(trim(note)); value.setOperatorId(operatorId); value.setOperatorName(operatorName);
value.setOperatorType(operatorType); value.setCreatedAt(LocalDateTime.now()); repository.insert(value);
}
private CaseDetail detail(CustomsCaseEntity value, boolean customerOnly) {
return new CaseDetail(value, checklist(requiredChecklist(value.getChecklistVersionId())),
repository.documents(value.getId()), repository.supplements(value.getId()), repository.batches(value.getId()),
repository.receipts(value.getId(), customerOnly), repository.history(value.getId()));
}
private ChecklistView checklist(CustomsChecklistVersionEntity value) {
return new ChecklistView(value, parseItems(value.getItemsJson()));
}
private List<ChecklistItem> checklistItems(CustomsCaseEntity value) {
return parseItems(requiredChecklist(value.getChecklistVersionId()).getItemsJson());
}
private List<ChecklistItem> parseItems(String json) {
try { return objectMapper.readValue(json, new TypeReference<>() {}); }
catch (JsonProcessingException ex) { throw new IllegalStateException("无法读取报关清单版本", ex); }
}
private CustomsCaseEntity required(long id) {
CustomsCaseEntity value = repository.customsCase(id); if (value == null) throw notFound("报关协同单不存在"); return value;
}
private CustomsCaseEntity required(long tenantId, long id) {
CustomsCaseEntity value = repository.customsCase(tenantId, id); if (value == null) throw notFound("报关协同单不存在"); return value;
}
private CustomsCaseEntity requiredVersion(CustomsCaseEntity value, int expectedVersion) {
if (!Integer.valueOf(expectedVersion).equals(value.getVersion())) throw concurrent(); return value;
}
private CustomsChecklistVersionEntity requiredChecklist(long id) {
CustomsChecklistVersionEntity value = repository.checklist(id); if (value == null) throw notFound("资料清单版本不存在"); return value;
}
private CustomsDocumentEntity requiredDocument(long id, long caseId) {
CustomsDocumentEntity value = repository.document(id);
if (value == null || !Objects.equals(value.getCustomsCaseId(), caseId)) throw notFound("报关文件版本不存在");
return value;
}
private CustomsSubmissionBatchEntity requiredBatch(long id, Long caseId) {
CustomsSubmissionBatchEntity value = repository.batch(id);
if (value == null || caseId != null && !Objects.equals(value.getCustomsCaseId(), caseId)) throw notFound("代理提交批次不存在");
return value;
}
private CustomsReceiptEntity requiredReceipt(long id, long caseId, boolean customerOnly) {
CustomsReceiptEntity value = repository.receipt(id);
if (value == null || !Objects.equals(value.getCustomsCaseId(), caseId)
|| customerOnly && !"CUSTOMER".equals(value.getVisibility())) throw notFound("报关回执不存在");
return value;
}
private static void validateChecklist(List<ChecklistItem> items) {
Set<String> codes = new HashSet<>();
for (ChecklistItem item : items) if (!codes.add(normalize(item.code()))) throw bad("资料清单项目编码不能重复");
if (items.stream().noneMatch(ChecklistItem::required)) throw bad("资料清单至少包含一个必填项目");
}
private String hash(Object... values) {
try { return sha256(objectMapper.writeValueAsBytes(values)); }
catch (JsonProcessingException ex) { throw new IllegalStateException("无法计算报关请求摘要", ex); }
}
private static String multipartHash(MultipartFile file) {
if (file == null || file.isEmpty()) throw bad("附件不能为空");
try (InputStream input = file.getInputStream()) {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] buffer = new byte[8192];
for (int count; (count = input.read(buffer)) >= 0;) digest.update(buffer, 0, count);
return HexFormat.of().formatHex(digest.digest());
}
catch (Exception ex) { throw bad("附件内容读取失败"); }
}
private static String sha256(byte[] bytes) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); }
catch (Exception ex) { throw new IllegalStateException(ex); }
}
private String json(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException ex) { throw new IllegalStateException("无法保存报关版本快照", ex); }
}
private static String normalize(String value) { return value.trim().toUpperCase(Locale.ROOT); }
private static String trim(String value) { if (value == null) return null; String result = value.trim(); return result.isEmpty() ? null : result; }
private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
private static BizException concurrent() { return conflict("数据已被其他人修改,请刷新后重试"); }
private static BizException notFound(String message) { return new BizException(ResultCode.NOT_FOUND, message); }
}
@@ -12,7 +12,7 @@ public final class LoadingRequests {
public record UpsertEvidenceRequest(
@NotNull @PositiveOrZero Integer expectedVersion,
@NotBlank @Size(max = 32) String evidenceType,
@Positive Long fileId,
@NotNull @Positive Long fileId,
@Size(max = 255) String fileName,
@Size(max = 64) String sealNo,
@Size(max = 1000) String remark) {}
@@ -47,6 +47,13 @@ public class LoadingRepository {
.orderByAsc(LoadingEvidenceEntity::getEvidenceType));
}
public LoadingEvidenceEntity findEvidenceBySealNo(long tenantId, String sealNo) {
return evidences.selectOne(new LambdaQueryWrapper<LoadingEvidenceEntity>()
.eq(LoadingEvidenceEntity::getTenantId, tenantId)
.eq(LoadingEvidenceEntity::getEvidenceType, "SEAL")
.eq(LoadingEvidenceEntity::getSealNo, sealNo));
}
public void createEvidence(LoadingEvidenceEntity entity) { evidences.insert(entity); }
public int updateEvidence(long tenantId, long inboundId, String type, Long fileId, String fileName,
@@ -20,6 +20,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import feign.FeignException;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@@ -28,8 +29,11 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/** 装柜证据、装柜完成和派送完成;不检查证据完整度,不阻断业务流转。 */
/** 装柜证据、拣货复核、装柜完成和派送完成。 */
@Service
public class LoadingDeliveryService {
private static final String UPSERT_EVIDENCE = "UPSERT_LOADING_EVIDENCE";
@@ -52,21 +56,37 @@ public class LoadingDeliveryService {
if (!LoadingEvidenceType.VALUES.contains(request.evidenceType())) {
throw new BizException(ResultCode.BAD_REQUEST, "不支持的装柜证据类型");
}
var file = request.fileId() == null ? null : validateFile(tenantId, inboundId, request.fileId(), request.evidenceType());
String fileName = file == null ? request.fileName() : file.fileName();
LoadingEvidenceEntity existing = repository.findEvidence(tenantId, inboundId, request.evidenceType());
if (existing == null) {
if (request.expectedVersion() != 0) throw concurrent();
LoadingEvidenceEntity entity = new LoadingEvidenceEntity();
entity.setTenantId(tenantId); entity.setInboundId(inboundId);
entity.setEvidenceType(request.evidenceType()); entity.setFileId(request.fileId());
entity.setFileName(fileName); entity.setSealNo(request.sealNo()); entity.setRemark(request.remark());
entity.setVersion(0); entity.setCreatedBy(operatorId); entity.setCreatedByName(operatorName);
entity.setUpdatedBy(operatorId); entity.setUpdatedByName(operatorName);
repository.createEvidence(entity);
} else if (repository.updateEvidence(tenantId, inboundId, request.evidenceType(), request.fileId(), fileName,
request.sealNo(), request.remark(), operatorId, operatorName, request.expectedVersion()) != 1) {
throw concurrent();
if (request.fileId() == null) throw new BizException(ResultCode.BAD_REQUEST, "装柜证据必须先上传真实文件");
if (LoadingEvidenceType.SEAL.equals(request.evidenceType()) && !StringUtils.hasText(request.sealNo())) {
throw new BizException(ResultCode.BAD_REQUEST, "铅封证据必须填写锁码");
}
if (LoadingEvidenceType.SEAL.equals(request.evidenceType())) {
LoadingEvidenceEntity duplicate = repository.findEvidenceBySealNo(tenantId, request.sealNo().trim());
if (duplicate != null && !Long.valueOf(inboundId).equals(duplicate.getInboundId())) {
throw new BizException(ResultCode.CONFLICT, "锁码已用于其他装柜任务");
}
}
var file = validateFile(tenantId, inboundId, request.fileId(), request.evidenceType());
String fileName = file.fileName();
String sealNo = LoadingEvidenceType.SEAL.equals(request.evidenceType()) ? request.sealNo().trim() : null;
try {
LoadingEvidenceEntity existing = repository.findEvidence(tenantId, inboundId, request.evidenceType());
if (existing == null) {
if (request.expectedVersion() != 0) throw concurrent();
LoadingEvidenceEntity entity = new LoadingEvidenceEntity();
entity.setTenantId(tenantId); entity.setInboundId(inboundId);
entity.setEvidenceType(request.evidenceType()); entity.setFileId(request.fileId());
entity.setFileName(fileName); entity.setSealNo(sealNo); entity.setRemark(request.remark());
entity.setVersion(0); entity.setCreatedBy(operatorId); entity.setCreatedByName(operatorName);
entity.setUpdatedBy(operatorId); entity.setUpdatedByName(operatorName);
repository.createEvidence(entity);
} else if (repository.updateEvidence(tenantId, inboundId, request.evidenceType(), request.fileId(), fileName,
sealNo, request.remark(), operatorId, operatorName, request.expectedVersion()) != 1) {
throw concurrent();
}
} catch (DuplicateKeyException ex) {
throw new BizException(ResultCode.CONFLICT, LoadingEvidenceType.SEAL.equals(request.evidenceType())
? "锁码已用于其他装柜任务" : "数据已被其他人修改,请刷新后重试");
}
LoadingActionResponse result = new LoadingActionResponse(requireInbound(tenantId, inboundId).processStatus(), evidenceResponse(tenantId, inboundId));
return complete(tenantId, inboundId, operatorId, UPSERT_EVIDENCE, key, result);
@@ -84,6 +104,8 @@ public class LoadingDeliveryService {
new LoadingActionResponse(inbound.processStatus(), evidenceResponse(tenantId, inboundId)));
}
if (!"LOADING".equals(inbound.processStatus())) throw new BizException(ResultCode.CONFLICT, "当前订单不在装柜中");
requireCompletedPicking(tenantId, inboundId);
requireCompletionEvidence(tenantId, inboundId);
if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent();
addHistory(tenantId, inboundId, "LOADING", "LOADED", COMPLETE_LOADING, request.remark(), operatorId, operatorName);
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
@@ -120,6 +142,11 @@ public class LoadingDeliveryService {
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, "START_PICKING", key, hash);
if (replay != null) return pickResponse(tenantId, inboundId);
requireLoadingStage(tenantId, inboundId);
if (request.expectedCount() == null || request.expectedCount() <= 0
|| request.pickedCount() == null || request.pickedCount() < 0
|| request.pickedCount() > request.expectedCount()) {
throw new BizException(ResultCode.BAD_REQUEST, "拣货计划数量必须大于0且已拣数量不能超过计划数量");
}
InboundLoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId);
if (task == null) {
if (request.expectedVersion() != 0) throw concurrent();
@@ -128,6 +155,8 @@ public class LoadingDeliveryService {
task.setPickedCount(request.pickedCount());
task.setRemark(request.remark()); task.setUpdatedBy(operatorId); task.setUpdatedByName(operatorName);
repository.createPickTask(task);
} else if (PickingStatus.COMPLETED.equals(task.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "拣货任务已完成,不允许重新开始");
} else if (repository.changePickStatus(tenantId, inboundId, task.getStatus(), PickingStatus.IN_PROGRESS,
operatorId, operatorName, request.remark(), request.expectedCount(), request.pickedCount(), request.expectedVersion()) != 1) throw concurrent();
PickingResponse result = pickResponse(tenantId, inboundId);
@@ -143,14 +172,18 @@ public class LoadingDeliveryService {
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, "COMPLETE_PICKING", key, hash);
if (replay != null) return pickResponse(tenantId, inboundId);
requireLoadingStage(tenantId, inboundId);
if (request.expectedCount() == null || request.expectedCount() <= 0
|| request.pickedCount() == null || !request.expectedCount().equals(request.pickedCount())) {
throw new BizException(ResultCode.CONFLICT, "拣货复核数量必须与计划数量一致");
}
InboundLoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId);
if (task == null) {
task = new InboundLoadingPickTaskEntity(); task.setTenantId(tenantId); task.setInboundId(inboundId);
task.setStatus(PickingStatus.COMPLETED); task.setVersion(0); task.setExpectedCount(request.expectedCount());
task.setPickedCount(request.pickedCount());
task.setRemark(request.remark()); task.setUpdatedBy(operatorId); task.setUpdatedByName(operatorName);
repository.createPickTask(task);
} else if (repository.changePickStatus(tenantId, inboundId,
if (task == null || !PickingStatus.IN_PROGRESS.equals(task.getStatus())) {
throw new BizException(ResultCode.CONFLICT, task == null ? "拣货任务尚未开始" : "拣货任务不在作业中");
}
if (!request.expectedCount().equals(task.getExpectedCount())) {
throw new BizException(ResultCode.CONFLICT, "拣货复核数量与任务计划数量不一致");
}
if (repository.changePickStatus(tenantId, inboundId,
task.getStatus(), PickingStatus.COMPLETED, operatorId, operatorName, request.remark(), request.expectedCount(), request.pickedCount(), request.expectedVersion()) != 1) throw concurrent();
PickingResponse result = pickResponse(tenantId, inboundId);
complete(tenantId, inboundId, operatorId, "COMPLETE_PICKING", key,
@@ -170,6 +203,30 @@ public class LoadingDeliveryService {
task.getExpectedCount(), task.getPickedCount(), task.getRemark());
}
private void requireCompletedPicking(long tenantId, long inboundId) {
InboundLoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId);
if (task == null || !PickingStatus.COMPLETED.equals(task.getStatus())
|| task.getExpectedCount() == null || task.getExpectedCount() <= 0
|| !task.getExpectedCount().equals(task.getPickedCount())) {
throw new BizException(ResultCode.CONFLICT, "PDA拣货复核尚未完成");
}
}
private void requireCompletionEvidence(long tenantId, long inboundId) {
Map<String, LoadingEvidenceEntity> values = repository.listEvidence(tenantId, inboundId).stream()
.collect(Collectors.toMap(LoadingEvidenceEntity::getEvidenceType, Function.identity(), (left, right) -> right));
boolean filesReady = LoadingEvidenceType.VALUES.stream()
.allMatch(type -> values.get(type) != null && values.get(type).getFileId() != null);
LoadingEvidenceEntity seal = values.get(LoadingEvidenceType.SEAL);
if (!filesReady || seal == null || !StringUtils.hasText(seal.getSealNo())) {
throw new BizException(ResultCode.CONFLICT, "空仓、半仓、满仓、锁仓、带锁码照及锁码必须齐全");
}
LoadingEvidenceType.VALUES.forEach(type -> {
LoadingEvidenceEntity evidence = values.get(type);
validateFile(tenantId, inboundId, evidence.getFileId(), type);
});
}
private LoadingEvidenceFileClient.FileMetadata validateFile(long tenantId, long inboundId, long fileId, String evidenceType) {
try {
var response = files.validate(internalToken, tenantId, inboundId, fileId);
@@ -0,0 +1,60 @@
package com.baiyue.firstleg.outbound.dispatch.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.*;
import com.baiyue.firstleg.outbound.dispatch.service.OutboundDispatchService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "三期国内出仓聚合")
@Validated @RestController
@RequestMapping("/api/platform/v1/first-leg/outbound-dispatches")
public class PlatformOutboundDispatchController {
private final OutboundDispatchService service;
public PlatformOutboundDispatchController(OutboundDispatchService service) { this.service = service; }
@GetMapping
@PreAuthorize("hasAuthority('platform:firstleg:outbound-dispatch:list')")
public ApiResponse<List<Aggregate>> list(@AuthenticationPrincipal AuthenticatedUser user, @Valid Query query) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.list(query));
}
@GetMapping("/detail")
@PreAuthorize("hasAuthority('platform:firstleg:outbound-dispatch:view')")
public ApiResponse<Detail> detail(@AuthenticationPrincipal AuthenticatedUser user, @Valid Query query) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(query));
}
@GetMapping("/check-gate")
@PreAuthorize("hasAuthority('platform:firstleg:outbound-dispatch:check')")
public ApiResponse<GateResult> checkGate(@AuthenticationPrincipal AuthenticatedUser user, @Valid Query query) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.checkGate(query));
}
@PostMapping("/confirm-dispatch")
@PreAuthorize("hasAuthority('platform:firstleg:outbound-dispatch:confirm')")
@BusinessAudit(event="OUTBOUND_CONFIRM_DISPATCH",resourceType="INBOUND",resourceId="#request.inboundId",description="确认国内出仓")
public ApiResponse<Detail> confirm(@AuthenticationPrincipal AuthenticatedUser user,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ActionRequest request) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.confirm(user.userId(), user.username(), key, request));
}
@PostMapping("/reverse-dispatch")
@PreAuthorize("hasAuthority('platform:firstleg:outbound-dispatch:reverse')")
@BusinessAudit(event="OUTBOUND_REVERSE_DISPATCH",resourceType="INBOUND",resourceId="#request.inboundId",description="撤销国内出仓")
public ApiResponse<Detail> reverse(@AuthenticationPrincipal AuthenticatedUser user,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ActionRequest request) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.reverse(user.userId(), user.username(), key, request));
}
}
@@ -0,0 +1,6 @@
package com.baiyue.firstleg.outbound.dispatch.domain;
public enum OutboundDispatchStatus {
READY_FOR_DISPATCH,
WAITING_FOR_LOADING
}
@@ -0,0 +1,32 @@
package com.baiyue.firstleg.outbound.dispatch.dto;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
public final class OutboundDispatchDtos {
private OutboundDispatchDtos() {}
public record Query(@Positive Long orderId, @Positive Long inboundId, @Positive Long containerBookingId) {}
public record ActionRequest(@Positive Long inboundId, @Positive Long containerBookingId,
@PositiveOrZero int expectedVersion, @Size(max = 500) String remark) {}
public record GateItem(String code, String name, boolean passed, String actual, String requirement) {}
public record GateResult(boolean passed, List<GateItem> items, LocalDateTime checkedAt) {}
public record Aggregate(Long orderId, String orderNo, Long inboundId, String inboundCode, Long tenantId,
Long customerId, String customerName, String inboundStatus,
Long containerBookingId, String containerNo, Long loadingPlanId, String loadingPlanStatus,
Long pickTaskId, String pickTaskStatus, Long oceanBookingId, String oceanBookingStatus,
Long truckAppointmentId, String truckAppointmentStatus,
String dispatchStatus, Integer version, GateResult gate) {}
public record TimelineItem(String module, String action, String fromStatus, String toStatus,
Long operatorId, String operatorName, String remark, LocalDateTime occurredAt) {}
public record Detail(Aggregate aggregate, List<TimelineItem> timeline) {}
}
@@ -0,0 +1,29 @@
package com.baiyue.firstleg.outbound.dispatch.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter @Setter @TableName("fl_outbound_dispatch")
public class OutboundDispatchEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long tenantId;
private Long orderId;
private Long inboundId;
private Long containerBookingId;
private String status;
private Integer version;
private Long confirmedBy;
private String confirmedByName;
private LocalDateTime confirmedAt;
private Long reversedBy;
private String reversedByName;
private LocalDateTime reversedAt;
private String remark;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,25 @@
package com.baiyue.firstleg.outbound.dispatch.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter @Setter @TableName("fl_outbound_dispatch_history")
public class OutboundDispatchHistoryEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long dispatchId;
private Long tenantId;
private Long inboundId;
private Long containerBookingId;
private String action;
private String fromStatus;
private String toStatus;
private String remark;
private Long operatorId;
private String operatorName;
private LocalDateTime createdAt;
}
@@ -0,0 +1,22 @@
package com.baiyue.firstleg.outbound.dispatch.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter @Setter @TableName("fl_outbound_dispatch_operation")
public class OutboundDispatchOperationEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long dispatchId;
private Long operatorId;
private String operation;
private String idempotencyKey;
private String requestHash;
private String status;
private LocalDateTime createdAt;
private LocalDateTime completedAt;
}
@@ -0,0 +1,12 @@
package com.baiyue.firstleg.outbound.dispatch.gate;
import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.GateItem;
/** External Phase 3 modules contribute a fail-closed gate result without coupling their entities here. */
public interface OutboundDispatchGateContributor {
String code();
GateItem assess(Context context);
record Context(long tenantId, long customerId, long orderId, long inboundId,
long containerBookingId, long loadingPlanId, Long oceanBookingId) {}
}
@@ -0,0 +1,8 @@
package com.baiyue.firstleg.outbound.dispatch.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchHistoryEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface OutboundDispatchHistoryMapper extends BaseMapper<OutboundDispatchHistoryEntity> {}
@@ -0,0 +1,30 @@
package com.baiyue.firstleg.outbound.dispatch.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface OutboundDispatchMapper extends BaseMapper<OutboundDispatchEntity> {
@Select("SELECT * FROM fl_outbound_dispatch WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} "
+ "AND container_booking_id=#{containerBookingId}")
OutboundDispatchEntity find(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("containerBookingId") long containerBookingId);
@Update("UPDATE fl_outbound_dispatch SET status='WAITING_FOR_LOADING',confirmed_by=#{operatorId},"
+ "confirmed_by_name=#{operatorName},confirmed_at=CURRENT_TIMESTAMP(3),reversed_by=NULL,reversed_by_name=NULL,"
+ "reversed_at=NULL,remark=#{remark},version=version+1,updated_at=CURRENT_TIMESTAMP(3) "
+ "WHERE id=#{id} AND status='READY_FOR_DISPATCH' AND version=#{version}")
int confirm(@Param("id") long id, @Param("version") int version, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName, @Param("remark") String remark);
@Update("UPDATE fl_outbound_dispatch SET status='READY_FOR_DISPATCH',reversed_by=#{operatorId},"
+ "reversed_by_name=#{operatorName},reversed_at=CURRENT_TIMESTAMP(3),remark=#{remark},"
+ "version=version+1,updated_at=CURRENT_TIMESTAMP(3) "
+ "WHERE id=#{id} AND status='WAITING_FOR_LOADING' AND version=#{version}")
int reverse(@Param("id") long id, @Param("version") int version, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName, @Param("remark") String remark);
}
@@ -0,0 +1,28 @@
package com.baiyue.firstleg.outbound.dispatch.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchOperationEntity;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface OutboundDispatchOperationMapper extends BaseMapper<OutboundDispatchOperationEntity> {
@Insert("INSERT IGNORE INTO fl_outbound_dispatch_operation(id,operator_id,operation,idempotency_key,request_hash,status) "
+ "VALUES(#{id},#{operatorId},#{operation},#{key},#{hash},'PROCESSING')")
int claim(@Param("id") long id, @Param("operatorId") long operatorId, @Param("operation") String operation,
@Param("key") String key, @Param("hash") String hash);
@Select("SELECT * FROM fl_outbound_dispatch_operation WHERE operator_id=#{operatorId} "
+ "AND operation=#{operation} AND idempotency_key=#{key}")
OutboundDispatchOperationEntity find(@Param("operatorId") long operatorId,
@Param("operation") String operation, @Param("key") String key);
@Update("UPDATE fl_outbound_dispatch_operation SET dispatch_id=#{dispatchId},status='SUCCESS',"
+ "completed_at=CURRENT_TIMESTAMP(3) WHERE operator_id=#{operatorId} AND operation=#{operation} "
+ "AND idempotency_key=#{key} AND status='PROCESSING'")
int complete(@Param("dispatchId") long dispatchId, @Param("operatorId") long operatorId,
@Param("operation") String operation, @Param("key") String key);
}
@@ -0,0 +1,119 @@
package com.baiyue.firstleg.outbound.dispatch.mapper;
import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.TimelineItem;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface OutboundDispatchReadMapper {
@Select("""
<script>
SELECT DISTINCT inbound.order_id orderId,inbound.order_no orderNo,inbound.id inboundId,
inbound.inbound_code inboundCode,inbound.tenant_id tenantId,inbound.customer_id customerId,
inbound.customer_name customerName,inbound.process_status inboundStatus,
booking.id containerBookingId,booking.container_no containerNo,
plan.id loadingPlanId,plan.plan_status loadingPlanStatus
FROM fl_loading_plan_item item
JOIN fl_loading_plan plan ON plan.id=item.loading_plan_id
JOIN fl_container_booking booking ON booking.id=plan.container_booking_id
JOIN fl_inbound_order inbound ON inbound.id=item.inbound_id AND inbound.tenant_id=item.account_id AND inbound.deleted=0
WHERE item.deleted=0
<if test="orderId != null">AND inbound.order_id=#{orderId}</if>
<if test="inboundId != null">AND inbound.id=#{inboundId}</if>
<if test="containerBookingId != null">AND booking.id=#{containerBookingId}</if>
ORDER BY inbound.id,booking.id
</script>
""")
List<Binding> bindings(@Param("orderId") Long orderId, @Param("inboundId") Long inboundId,
@Param("containerBookingId") Long containerBookingId);
@Select("SELECT id,task_status status FROM fl_loading_pick_task WHERE loading_plan_id=#{planId} "
+ "ORDER BY created_at DESC,id DESC LIMIT 1")
IdStatus pick(@Param("planId") long planId);
@Select("""
SELECT booking.id,booking.status
FROM fl_ocean_booking_container_allocation allocation
JOIN fl_ocean_booking booking ON booking.id=allocation.ocean_booking_id
WHERE allocation.container_booking_id=#{containerBookingId} AND booking.customer_id=#{customerId}
ORDER BY booking.updated_at DESC,booking.id DESC LIMIT 1
""")
IdStatus ocean(@Param("containerBookingId") long containerBookingId, @Param("customerId") long customerId);
@Select("""
SELECT id,status FROM fl_truck_appointment
WHERE container_booking_id=#{containerBookingId} AND loading_plan_id=#{loadingPlanId}
AND customer_id=#{customerId}
ORDER BY updated_at DESC,id DESC LIMIT 1
""")
IdStatus truck(@Param("containerBookingId") long containerBookingId, @Param("loadingPlanId") long loadingPlanId,
@Param("customerId") long customerId);
@Select("""
SELECT COUNT(DISTINCT CASE WHEN file_id IS NOT NULL AND evidence_type IN
('PHOTO_STEP_1','PHOTO_STEP_2','PHOTO_STEP_3','PHOTO_STEP_4','SEAL') THEN evidence_type END) evidenceCount,
MAX(CASE WHEN evidence_type='SEAL' AND file_id IS NOT NULL AND seal_no IS NOT NULL
AND seal_no&lt;&gt;'' THEN 1 ELSE 0 END) validSeal
FROM fl_loading_evidence WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId}
""")
Evidence evidence(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
@Update("UPDATE fl_inbound_order SET process_status='WAITING_FOR_LOADING',updated_by=#{operatorId},"
+ "version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE tenant_id=#{tenantId} AND id=#{inboundId} "
+ "AND deleted=0 AND process_status='LOADED'")
int confirmInbound(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId);
@Update("UPDATE fl_inbound_order SET process_status='LOADED',updated_by=#{operatorId},"
+ "version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE tenant_id=#{tenantId} AND id=#{inboundId} "
+ "AND deleted=0 AND process_status='WAITING_FOR_LOADING'")
int reverseInbound(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId);
@Select("""
SELECT module,action,fromStatus,toStatus,operatorId,operatorName,remark,occurredAt FROM (
SELECT 'INBOUND' module,h.action action,h.from_status fromStatus,h.to_status toStatus,
h.operator_id operatorId,h.operator_name operatorName,h.reason remark,h.occurred_at occurredAt
FROM fl_inbound_status_history h WHERE h.tenant_id=#{tenantId} AND h.inbound_id=#{inboundId}
UNION ALL
SELECT 'LOADING_PLAN',h.action_type,h.from_status,h.to_status,h.operator_id,h.operator_name,
h.operation_reason,h.created_at FROM fl_loading_plan_history h WHERE h.loading_plan_id=#{loadingPlanId}
UNION ALL
SELECT 'PDA_PICK',h.action_type,NULL,NULL,h.operator_id,h.operator_name,h.operation_reason,h.created_at
FROM fl_loading_pick_history h JOIN fl_loading_pick_task task ON task.id=h.pick_task_id
WHERE task.loading_plan_id=#{loadingPlanId}
UNION ALL
SELECT 'LOADING_EVIDENCE','UPSERT_EVIDENCE',NULL,NULL,
COALESCE(e.updated_by,e.created_by),COALESCE(e.updated_by_name,e.created_by_name),
CONCAT(e.evidence_type,CASE WHEN e.seal_no IS NULL OR e.seal_no='' THEN '' ELSE CONCAT(':',e.seal_no) END),
COALESCE(e.updated_at,e.created_at)
FROM fl_loading_evidence e WHERE e.tenant_id=#{tenantId} AND e.inbound_id=#{inboundId}
UNION ALL
SELECT 'OCEAN_BOOKING',h.action,h.from_status,h.to_status,h.operator_id,h.operator_name,h.reason,h.created_at
FROM fl_ocean_booking_status_history h WHERE h.ocean_booking_id=#{oceanBookingId}
UNION ALL
SELECT 'TRUCK_APPOINTMENT',h.action,h.from_status,h.to_status,h.operator_id,h.operator_name,h.reason,h.created_at
FROM fl_truck_appointment_history h WHERE h.appointment_id=#{truckAppointmentId}
UNION ALL
SELECT 'OUTBOUND_DISPATCH',h.action,h.from_status,h.to_status,h.operator_id,h.operator_name,h.remark,h.created_at
FROM fl_outbound_dispatch_history h WHERE h.tenant_id=#{tenantId} AND h.inbound_id=#{inboundId}
AND h.container_booking_id=#{containerBookingId}
) timeline ORDER BY occurredAt,module
""")
List<TimelineItem> timeline(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("containerBookingId") long containerBookingId,
@Param("loadingPlanId") long loadingPlanId,
@Param("oceanBookingId") Long oceanBookingId,
@Param("truckAppointmentId") Long truckAppointmentId);
record Binding(Long orderId, String orderNo, Long inboundId, String inboundCode, Long tenantId,
Long customerId, String customerName, String inboundStatus, Long containerBookingId,
String containerNo, Long loadingPlanId, String loadingPlanStatus) {}
record IdStatus(Long id, String status) {}
record Evidence(Integer evidenceCount, Integer validSeal) {}
}
@@ -0,0 +1,68 @@
package com.baiyue.firstleg.outbound.dispatch.repository;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.Query;
import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.TimelineItem;
import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchEntity;
import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchHistoryEntity;
import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchOperationEntity;
import com.baiyue.firstleg.outbound.dispatch.mapper.OutboundDispatchHistoryMapper;
import com.baiyue.firstleg.outbound.dispatch.mapper.OutboundDispatchMapper;
import com.baiyue.firstleg.outbound.dispatch.mapper.OutboundDispatchOperationMapper;
import com.baiyue.firstleg.outbound.dispatch.mapper.OutboundDispatchReadMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public class OutboundDispatchRepository {
@Resource private OutboundDispatchMapper dispatches;
@Resource private OutboundDispatchHistoryMapper histories;
@Resource private OutboundDispatchOperationMapper operations;
@Resource private OutboundDispatchReadMapper reads;
public List<OutboundDispatchReadMapper.Binding> bindings(Query query) {
return reads.bindings(query.orderId(), query.inboundId(), query.containerBookingId());
}
public OutboundDispatchReadMapper.IdStatus pick(long planId) { return reads.pick(planId); }
public OutboundDispatchReadMapper.IdStatus ocean(long containerId, long customerId) {
return reads.ocean(containerId, customerId);
}
public OutboundDispatchReadMapper.IdStatus truck(long containerId, long planId, long customerId) {
return reads.truck(containerId, planId, customerId);
}
public OutboundDispatchReadMapper.Evidence evidence(long tenantId, long inboundId) {
return reads.evidence(tenantId, inboundId);
}
public OutboundDispatchEntity dispatch(long tenantId, long inboundId, long containerId) {
return dispatches.find(tenantId, inboundId, containerId);
}
public void insert(OutboundDispatchEntity value) { dispatches.insert(value); }
public int confirm(long id, int version, long operatorId, String operatorName, String remark) {
return dispatches.confirm(id, version, operatorId, operatorName, remark);
}
public int reverse(long id, int version, long operatorId, String operatorName, String remark) {
return dispatches.reverse(id, version, operatorId, operatorName, remark);
}
public int confirmInbound(long tenantId, long inboundId, long operatorId) {
return reads.confirmInbound(tenantId, inboundId, operatorId);
}
public int reverseInbound(long tenantId, long inboundId, long operatorId) {
return reads.reverseInbound(tenantId, inboundId, operatorId);
}
public void history(OutboundDispatchHistoryEntity value) { histories.insert(value); }
public boolean claim(long operatorId, String operation, String key, String hash) {
return operations.claim(IdWorker.getId(), operatorId, operation, key, hash) == 1;
}
public OutboundDispatchOperationEntity operation(long operatorId, String operation, String key) {
return operations.find(operatorId, operation, key);
}
public int completeOperation(long dispatchId, long operatorId, String operation, String key) {
return operations.complete(dispatchId, operatorId, operation, key);
}
public List<TimelineItem> timeline(OutboundDispatchReadMapper.Binding binding, Long oceanId, Long truckId) {
return reads.timeline(binding.tenantId(), binding.inboundId(), binding.containerBookingId(),
binding.loadingPlanId(), oceanId, truckId);
}
}
@@ -0,0 +1,228 @@
package com.baiyue.firstleg.outbound.dispatch.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.outbound.dispatch.domain.OutboundDispatchStatus;
import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.*;
import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchEntity;
import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchHistoryEntity;
import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor;
import com.baiyue.firstleg.outbound.dispatch.mapper.OutboundDispatchReadMapper;
import com.baiyue.firstleg.outbound.dispatch.repository.OutboundDispatchRepository;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.*;
@Service
public class OutboundDispatchService {
private static final Set<String> REQUIRED_CONTRIBUTORS = Set.of("CUSTOMS_CLEARANCE", "SAILING_SCHEDULE");
private static final Set<String> TRUCK_READY = Set.of("DEPARTED", "COMPLETED");
private final OutboundDispatchRepository repository;
private final Map<String, OutboundDispatchGateContributor> contributors;
public OutboundDispatchService(OutboundDispatchRepository repository,
List<OutboundDispatchGateContributor> contributors) {
this.repository = repository;
Map<String, OutboundDispatchGateContributor> indexed = new HashMap<>();
for (OutboundDispatchGateContributor contributor : contributors) {
if (indexed.put(contributor.code(), contributor) != null) {
throw new IllegalStateException("重复出仓门禁贡献器: " + contributor.code());
}
}
this.contributors = Map.copyOf(indexed);
}
public List<Aggregate> list(Query query) {
requireSelector(query);
return repository.bindings(query).stream().map(this::aggregate).toList();
}
public Detail detail(Query query) {
OutboundDispatchReadMapper.Binding binding = single(query);
Aggregate aggregate = aggregate(binding);
return new Detail(aggregate, repository.timeline(binding, aggregate.oceanBookingId(), aggregate.truckAppointmentId()));
}
public GateResult checkGate(Query query) { return aggregate(single(query)).gate(); }
@Transactional
public Detail confirm(long operatorId, String operatorName, String key, ActionRequest request) {
validateKey(key);
Query query = new Query(null, request.inboundId(), request.containerBookingId());
OutboundDispatchReadMapper.Binding binding = single(query);
String hash = hash("CONFIRM", request);
Detail replay = replay(operatorId, "CONFIRM_DISPATCH", key, hash, query);
if (replay != null) return replay;
Aggregate before = aggregate(binding);
if (!before.gate().passed()) throw conflict("国内出仓门禁未通过");
OutboundDispatchEntity state = state(binding);
if (!Objects.equals(state.getVersion(), request.expectedVersion())) throw concurrent();
if (OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(state.getStatus())) {
throw conflict("当前出仓单已确认派车");
}
if (repository.confirm(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1
|| repository.confirmInbound(binding.tenantId(), binding.inboundId(), operatorId) != 1) {
throw concurrent();
}
history(state, "CONFIRM_DISPATCH", OutboundDispatchStatus.READY_FOR_DISPATCH.name(),
OutboundDispatchStatus.WAITING_FOR_LOADING.name(), request.remark(), operatorId, operatorName);
if (repository.completeOperation(state.getId(), operatorId, "CONFIRM_DISPATCH", key) != 1) throw concurrent();
return detail(query);
}
@Transactional
public Detail reverse(long operatorId, String operatorName, String key, ActionRequest request) {
validateKey(key);
Query query = new Query(null, request.inboundId(), request.containerBookingId());
OutboundDispatchReadMapper.Binding binding = single(query);
String hash = hash("REVERSE", request);
Detail replay = replay(operatorId, "REVERSE_DISPATCH", key, hash, query);
if (replay != null) return replay;
OutboundDispatchEntity state = repository.dispatch(binding.tenantId(), binding.inboundId(), binding.containerBookingId());
if (state == null || !OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(state.getStatus())) {
throw conflict("当前出仓单不在等待装船状态");
}
if (!Objects.equals(state.getVersion(), request.expectedVersion())) throw concurrent();
if (repository.reverse(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1
|| repository.reverseInbound(binding.tenantId(), binding.inboundId(), operatorId) != 1) {
throw concurrent();
}
history(state, "REVERSE_DISPATCH", OutboundDispatchStatus.WAITING_FOR_LOADING.name(),
OutboundDispatchStatus.READY_FOR_DISPATCH.name(), request.remark(), operatorId, operatorName);
if (repository.completeOperation(state.getId(), operatorId, "REVERSE_DISPATCH", key) != 1) throw concurrent();
return detail(query);
}
private Aggregate aggregate(OutboundDispatchReadMapper.Binding binding) {
var pick = repository.pick(binding.loadingPlanId());
var ocean = repository.ocean(binding.containerBookingId(), binding.customerId());
var truck = repository.truck(binding.containerBookingId(), binding.loadingPlanId(), binding.customerId());
var evidence = repository.evidence(binding.tenantId(), binding.inboundId());
var dispatch = repository.dispatch(binding.tenantId(), binding.inboundId(), binding.containerBookingId());
GateResult gate = gate(binding, pick, ocean, truck, evidence);
return new Aggregate(binding.orderId(), binding.orderNo(), binding.inboundId(), binding.inboundCode(),
binding.tenantId(), binding.customerId(), binding.customerName(), binding.inboundStatus(),
binding.containerBookingId(), binding.containerNo(), binding.loadingPlanId(), binding.loadingPlanStatus(),
id(pick), status(pick), id(ocean), status(ocean), id(truck), status(truck),
dispatch == null ? OutboundDispatchStatus.READY_FOR_DISPATCH.name() : dispatch.getStatus(),
dispatch == null ? 0 : dispatch.getVersion(), gate);
}
private GateResult gate(OutboundDispatchReadMapper.Binding binding, OutboundDispatchReadMapper.IdStatus pick,
OutboundDispatchReadMapper.IdStatus ocean, OutboundDispatchReadMapper.IdStatus truck,
OutboundDispatchReadMapper.Evidence evidence) {
List<GateItem> items = new ArrayList<>();
items.add(item("INBOUND_LOADED", "入库单装柜完成", "LOADED".equals(binding.inboundStatus()),
binding.inboundStatus(), "LOADED"));
items.add(item("LOADING_PLAN_COMPLETED", "柜次装柜计划完成", "COMPLETED".equals(binding.loadingPlanStatus()),
binding.loadingPlanStatus(), "COMPLETED"));
items.add(item("PDA_PICK_COMPLETED", "PDA柜次拣货完成", pick != null && "COMPLETED".equals(pick.status()),
status(pick), "COMPLETED"));
items.add(item("OCEAN_BOOKING_RELEASED", "订舱已放舱且分配当前柜次",
ocean != null && "RELEASED".equals(ocean.status()), status(ocean), "RELEASED"));
items.add(item("TRUCK_DEPARTED", "关联拖车已离仓", truck != null && TRUCK_READY.contains(truck.status()),
status(truck), "DEPARTED或COMPLETED"));
boolean evidenceReady = evidence != null && evidence.evidenceCount() != null
&& evidence.evidenceCount() >= 5 && Objects.equals(evidence.validSeal(), 1);
items.add(item("LOADING_EVIDENCE_COMPLETE", "四步照片与铅封完整", evidenceReady,
evidence == null ? null : evidence.evidenceCount() + "个证据/有效铅封=" + evidence.validSeal(),
"PHOTO_STEP_1..4和含铅封号的SEAL"));
var context = new OutboundDispatchGateContributor.Context(binding.tenantId(), binding.customerId(),
binding.orderId(), binding.inboundId(), binding.containerBookingId(), binding.loadingPlanId(), id(ocean));
for (String code : REQUIRED_CONTRIBUTORS.stream().sorted().toList()) {
OutboundDispatchGateContributor contributor = contributors.get(code);
if (contributor == null) {
items.add(item(code, contributorName(code), false, "MODULE_UNAVAILABLE", "模块返回可放行状态"));
continue;
}
try {
GateItem result = contributor.assess(context);
items.add(result == null ? item(code, contributorName(code), false, "NO_RESULT", "模块返回可放行状态") : result);
} catch (RuntimeException ex) {
items.add(item(code, contributorName(code), false, "ASSESSMENT_ERROR", "模块返回可放行状态"));
}
}
return new GateResult(items.stream().allMatch(GateItem::passed), List.copyOf(items), LocalDateTime.now());
}
private Detail replay(long operatorId, String operation, String key, String hash, Query query) {
if (repository.claim(operatorId, operation, key, hash)) return null;
var existing = repository.operation(operatorId, operation, key);
if (existing == null || !Objects.equals(existing.getRequestHash(), hash)) {
throw conflict("Idempotency-Key已用于不同请求");
}
if (!"SUCCESS".equals(existing.getStatus())) throw conflict("相同请求正在处理中");
return detail(query);
}
private OutboundDispatchEntity state(OutboundDispatchReadMapper.Binding binding) {
OutboundDispatchEntity state = repository.dispatch(binding.tenantId(), binding.inboundId(), binding.containerBookingId());
if (state != null) return state;
state = new OutboundDispatchEntity();
state.setId(IdWorker.getId()); state.setTenantId(binding.tenantId()); state.setOrderId(binding.orderId());
state.setInboundId(binding.inboundId()); state.setContainerBookingId(binding.containerBookingId());
state.setStatus(OutboundDispatchStatus.READY_FOR_DISPATCH.name()); state.setVersion(0);
try { repository.insert(state); }
catch (DuplicateKeyException ex) {
state = repository.dispatch(binding.tenantId(), binding.inboundId(), binding.containerBookingId());
if (state == null) throw ex;
}
return state;
}
private void history(OutboundDispatchEntity state, String action, String from, String to, String remark,
long operatorId, String operatorName) {
OutboundDispatchHistoryEntity history = new OutboundDispatchHistoryEntity();
history.setId(IdWorker.getId()); history.setDispatchId(state.getId()); history.setTenantId(state.getTenantId());
history.setInboundId(state.getInboundId()); history.setContainerBookingId(state.getContainerBookingId());
history.setAction(action); history.setFromStatus(from); history.setToStatus(to); history.setRemark(remark);
history.setOperatorId(operatorId); history.setOperatorName(operatorName); repository.history(history);
}
private OutboundDispatchReadMapper.Binding single(Query query) {
requireSelector(query);
List<OutboundDispatchReadMapper.Binding> bindings = repository.bindings(query);
if (bindings.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "未找到匹配的入库单与柜次关联");
if (bindings.size() != 1) throw new BizException(ResultCode.BAD_REQUEST, "查询条件匹配多个出仓聚合,请同时指定inboundId和containerBookingId");
return bindings.get(0);
}
private static void requireSelector(Query query) {
if (query == null || query.orderId() == null && query.inboundId() == null && query.containerBookingId() == null) {
throw new BizException(ResultCode.BAD_REQUEST, "orderId、inboundId、containerBookingId至少提供一个");
}
}
private static void validateKey(String key) {
if (key == null || key.isBlank() || key.length() > 128) {
throw new BizException(ResultCode.BAD_REQUEST, "Idempotency-Key不能为空且长度不能超过128");
}
}
private static String hash(String action, ActionRequest request) {
String source = action + "|" + request.inboundId() + "|" + request.containerBookingId() + "|"
+ request.expectedVersion() + "|" + Objects.toString(request.remark(), "");
try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(source.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest);
} catch (Exception ex) { throw new IllegalStateException(ex); }
}
private static Long id(OutboundDispatchReadMapper.IdStatus value) { return value == null ? null : value.id(); }
private static String status(OutboundDispatchReadMapper.IdStatus value) { return value == null ? null : value.status(); }
private static GateItem item(String code, String name, boolean pass, String actual, String requirement) {
return new GateItem(code, name, pass, actual, requirement);
}
private static String contributorName(String code) {
return "CUSTOMS_CLEARANCE".equals(code) ? "报关协同完成" : "船期有效且未过期";
}
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
private static BizException concurrent() { return conflict("数据已被其他人修改,请刷新后重试"); }
}
@@ -0,0 +1,87 @@
package com.baiyue.firstleg.portschedule.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.CreateRequest;
import com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.Detail;
import com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.Query;
import com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.ScheduleView;
import com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.UpdateRequest;
import com.baiyue.firstleg.portschedule.entity.PortScheduleHistoryEntity;
import com.baiyue.firstleg.portschedule.service.PortScheduleService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Positive;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Validated
@RestController
@Tag(name = "港务船期")
@RequestMapping("/api/platform/v1/port-schedules")
public class PlatformPortScheduleController {
@Resource private PortScheduleService service;
@GetMapping
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule:list')")
public ApiResponse<PageResult<ScheduleView>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid Query query) {
FirstlegScopeGuard.platform(user);
if (query.customerId() != null) FirstlegScopeGuard.platform(user, query.customerId());
return ApiResponse.success(service.page(query));
}
@GetMapping("/{portScheduleId}")
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule:view')")
public ApiResponse<Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long portScheduleId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.detail(portScheduleId));
}
@GetMapping("/{portScheduleId}/history")
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule:view')")
public ApiResponse<List<PortScheduleHistoryEntity>> history(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long portScheduleId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.history(portScheduleId));
}
@PostMapping
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule:create')")
@BusinessAudit(event = "PORT_SCHEDULE_CREATE", resourceType = "PORT_SCHEDULE",
description = "新增港务船期", captureBefore = false)
public ApiResponse<Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody CreateRequest request) {
FirstlegScopeGuard.platform(user, request.data().customerId());
return ApiResponse.success(service.create(user.userId(), user.username(), key, request));
}
@PutMapping("/{portScheduleId}")
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule:edit')")
@BusinessAudit(event = "PORT_SCHEDULE_UPDATE", resourceType = "PORT_SCHEDULE",
resourceId = "#portScheduleId", description = "更新港务船期")
public ApiResponse<Detail> update(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long portScheduleId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody UpdateRequest request) {
FirstlegScopeGuard.platform(user, request.data().customerId());
return ApiResponse.success(service.update(portScheduleId, user.userId(), user.username(), key, request));
}
}
@@ -0,0 +1,58 @@
package com.baiyue.firstleg.portschedule.dto;
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
import com.baiyue.firstleg.portschedule.entity.PortScheduleHistoryEntity;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
public final class PortScheduleDtos {
private PortScheduleDtos() {}
public record Query(@Positive Long customerId, @Positive Long oceanBookingId,
@Positive Long containerBookingId, Boolean expired,
@Size(max = 128) String keyword, @Min(1) long page,
@Min(1) @Max(200) long size) {
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
}
public record ScheduleData(@NotNull @Positive Long customerId,
@NotNull @Positive Long oceanBookingId,
@Positive Long containerBookingId,
@NotBlank @Size(max = 24) String sourceType,
@NotBlank @Size(max = 128) String sourceName,
@NotBlank @Size(max = 255) String sourceRef,
@NotBlank @Size(max = 128) String vesselName,
@NotBlank @Size(max = 64) String voyageNo,
@NotBlank @Size(max = 32) String departurePortCode,
@Size(max = 128) String departurePortName,
@NotBlank @Size(max = 32) String destinationPortCode,
@Size(max = 128) String destinationPortName,
LocalDateTime etdAt,
LocalDateTime etaAt,
LocalDateTime bookingCutoffAt,
LocalDateTime customsCutoffAt,
LocalDateTime portCutoffAt,
@NotNull LocalDateTime observedAt,
@NotNull LocalDateTime expiresAt,
@Size(max = 1000) String remark) {}
public record CreateRequest(@NotNull @Min(0) Integer expectedVersion,
@Valid @NotNull ScheduleData data,
@Size(max = 1000) String reason) {}
public record UpdateRequest(@NotNull @Min(0) Integer expectedVersion,
@Valid @NotNull ScheduleData data,
@Size(max = 1000) String reason) {}
public record ScheduleView(PortScheduleEntity schedule, boolean expired) {}
public record Detail(ScheduleView portSchedule, List<PortScheduleHistoryEntity> histories) {}
}
@@ -0,0 +1,47 @@
package com.baiyue.firstleg.portschedule.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_port_schedule")
public class PortScheduleEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String scheduleNo;
private Long customerId;
private Long oceanBookingId;
private Long containerBookingId;
private String sourceType;
private String sourceName;
private String sourceRef;
private String vesselName;
private String voyageNo;
private String departurePortCode;
private String departurePortName;
private String destinationPortCode;
private String destinationPortName;
private LocalDateTime etdAt;
private LocalDateTime etaAt;
private LocalDateTime bookingCutoffAt;
private LocalDateTime customsCutoffAt;
private LocalDateTime portCutoffAt;
private LocalDateTime observedAt;
private LocalDateTime expiresAt;
private String remark;
private Long createdBy;
private String createdByName;
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
private Long updatedBy;
private String updatedByName;
@TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updatedAt;
@Version @TableField(fill = FieldFill.INSERT) private Integer version;
}
@@ -0,0 +1,28 @@
package com.baiyue.firstleg.portschedule.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_port_schedule_history")
public class PortScheduleHistoryEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long portScheduleId;
private String action;
private Integer fromVersion;
private Integer toVersion;
private String reason;
private String beforeSnapshot;
private String afterSnapshot;
private Long operatorId;
private String operatorName;
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
}
@@ -0,0 +1,27 @@
package com.baiyue.firstleg.portschedule.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_port_schedule_operation")
public class PortScheduleOperationEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long portScheduleId;
private Long operatorId;
private String operation;
private String idempotencyKey;
private String requestHash;
private String status;
private String resultJson;
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
private LocalDateTime completedAt;
}
@@ -0,0 +1,8 @@
package com.baiyue.firstleg.portschedule.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.portschedule.entity.PortScheduleHistoryEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PortScheduleHistoryMapper extends BaseMapper<PortScheduleHistoryEntity> {}
@@ -0,0 +1,61 @@
package com.baiyue.firstleg.portschedule.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.Query;
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface PortScheduleMapper extends BaseMapper<PortScheduleEntity> {
@Select("""
<script>
SELECT * FROM fl_port_schedule
WHERE (#{query.customerId} IS NULL OR customer_id=#{query.customerId})
AND (#{query.oceanBookingId} IS NULL OR ocean_booking_id=#{query.oceanBookingId})
AND (#{query.containerBookingId} IS NULL OR container_booking_id=#{query.containerBookingId})
AND (#{query.expired} IS NULL
OR (#{query.expired}=TRUE AND expires_at&lt;=CURRENT_TIMESTAMP(3))
OR (#{query.expired}=FALSE AND expires_at&gt;CURRENT_TIMESTAMP(3)))
AND (#{query.keyword} IS NULL OR schedule_no LIKE CONCAT('%',#{query.keyword},'%')
OR source_name LIKE CONCAT('%',#{query.keyword},'%')
OR source_ref LIKE CONCAT('%',#{query.keyword},'%')
OR vessel_name LIKE CONCAT('%',#{query.keyword},'%')
OR voyage_no LIKE CONCAT('%',#{query.keyword},'%')
OR departure_port_code LIKE CONCAT('%',#{query.keyword},'%')
OR destination_port_code LIKE CONCAT('%',#{query.keyword},'%'))
ORDER BY observed_at DESC,id DESC
</script>
""")
Page<PortScheduleEntity> selectPage(Page<PortScheduleEntity> page, @Param("query") Query query);
@Select("""
SELECT * FROM fl_port_schedule
WHERE customer_id=#{customerId} AND ocean_booking_id=#{oceanBookingId}
AND container_booking_id=#{containerBookingId}
ORDER BY observed_at DESC,updated_at DESC,id DESC LIMIT 1
""")
PortScheduleEntity latestForDispatch(@Param("customerId") long customerId,
@Param("oceanBookingId") long oceanBookingId,
@Param("containerBookingId") long containerBookingId);
@Update("""
UPDATE fl_port_schedule SET source_type=#{value.sourceType},source_name=#{value.sourceName},
source_ref=#{value.sourceRef},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},
departure_port_code=#{value.departurePortCode},departure_port_name=#{value.departurePortName},
destination_port_code=#{value.destinationPortCode},destination_port_name=#{value.destinationPortName},
etd_at=#{value.etdAt},eta_at=#{value.etaAt},booking_cutoff_at=#{value.bookingCutoffAt},
customs_cutoff_at=#{value.customsCutoffAt},port_cutoff_at=#{value.portCutoffAt},
observed_at=#{value.observedAt},expires_at=#{value.expiresAt},remark=#{value.remark},
updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{value.id} AND customer_id=#{value.customerId} AND ocean_booking_id=#{value.oceanBookingId}
AND (container_booking_id&lt;=&gt;#{value.containerBookingId}) AND version=#{expectedVersion}
""")
int updateSchedule(@Param("value") PortScheduleEntity value,
@Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
}
@@ -0,0 +1,24 @@
package com.baiyue.firstleg.portschedule.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.portschedule.entity.PortScheduleOperationEntity;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface PortScheduleOperationMapper extends BaseMapper<PortScheduleOperationEntity> {
@Insert("INSERT IGNORE INTO fl_port_schedule_operation "
+ "(id,port_schedule_id,operator_id,operation,idempotency_key,request_hash,status,created_at) "
+ "VALUES(#{id},#{portScheduleId},#{operatorId},#{operation},#{key},#{hash},'PROCESSING',CURRENT_TIMESTAMP(3))")
int claim(@Param("id") long id, @Param("portScheduleId") Long portScheduleId,
@Param("operatorId") long operatorId, @Param("operation") String operation,
@Param("key") String key, @Param("hash") String hash);
@Update("UPDATE fl_port_schedule_operation SET port_schedule_id=#{portScheduleId},status='SUCCESS',"
+ "result_json=#{resultJson},completed_at=CURRENT_TIMESTAMP(3) WHERE operator_id=#{operatorId} "
+ "AND idempotency_key=#{key} AND status='PROCESSING'")
int complete(@Param("portScheduleId") long portScheduleId, @Param("operatorId") long operatorId,
@Param("key") String key, @Param("resultJson") String resultJson);
}
@@ -0,0 +1,69 @@
package com.baiyue.firstleg.portschedule.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.mapper.ContainerBookingMapper;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.mapper.OceanBookingContainerAllocationMapper;
import com.baiyue.firstleg.oceanbooking.mapper.OceanBookingMapper;
import com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.Query;
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
import com.baiyue.firstleg.portschedule.entity.PortScheduleHistoryEntity;
import com.baiyue.firstleg.portschedule.entity.PortScheduleOperationEntity;
import com.baiyue.firstleg.portschedule.mapper.PortScheduleHistoryMapper;
import com.baiyue.firstleg.portschedule.mapper.PortScheduleMapper;
import com.baiyue.firstleg.portschedule.mapper.PortScheduleOperationMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public class PortScheduleRepository {
@Resource private PortScheduleMapper schedules;
@Resource private PortScheduleHistoryMapper histories;
@Resource private PortScheduleOperationMapper operations;
@Resource private OceanBookingMapper oceanBookings;
@Resource private OceanBookingContainerAllocationMapper allocations;
@Resource private ContainerBookingMapper containerBookings;
public PageResult<PortScheduleEntity> page(Query query) {
return PageResult.from(schedules.selectPage(new Page<>(query.page(), query.size()), query));
}
public PortScheduleEntity find(long id) { return schedules.selectById(id); }
public PortScheduleEntity latestForDispatch(long customerId, long oceanBookingId, long containerBookingId) {
return schedules.latestForDispatch(customerId, oceanBookingId, containerBookingId);
}
public void insert(PortScheduleEntity value) { schedules.insert(value); }
public int update(PortScheduleEntity value, int expectedVersion, long operatorId, String operatorName) {
return schedules.updateSchedule(value, expectedVersion, operatorId, operatorName);
}
public List<PortScheduleHistoryEntity> histories(long id) {
return histories.selectList(new LambdaQueryWrapper<PortScheduleHistoryEntity>()
.eq(PortScheduleHistoryEntity::getPortScheduleId, id)
.orderByAsc(PortScheduleHistoryEntity::getCreatedAt).orderByAsc(PortScheduleHistoryEntity::getId));
}
public void insert(PortScheduleHistoryEntity value) { histories.insert(value); }
public OceanBookingEntity oceanBooking(long id) { return oceanBookings.selectById(id); }
public ContainerBookingEntity containerBooking(long id) { return containerBookings.selectById(id); }
public OceanBookingContainerAllocationEntity allocation(long oceanBookingId, long containerBookingId) {
return allocations.selectOne(new LambdaQueryWrapper<OceanBookingContainerAllocationEntity>()
.eq(OceanBookingContainerAllocationEntity::getOceanBookingId, oceanBookingId)
.eq(OceanBookingContainerAllocationEntity::getContainerBookingId, containerBookingId));
}
public boolean claimOperation(Long id, long operatorId, String operation, String key, String hash) {
return operations.claim(IdWorker.getId(), id, operatorId, operation, key, hash) == 1;
}
public PortScheduleOperationEntity operation(long operatorId, String key) {
return operations.selectOne(new LambdaQueryWrapper<PortScheduleOperationEntity>()
.eq(PortScheduleOperationEntity::getOperatorId, operatorId)
.eq(PortScheduleOperationEntity::getIdempotencyKey, key));
}
public int completeOperation(long id, long operatorId, String key, String resultJson) {
return operations.complete(id, operatorId, key, resultJson);
}
}
@@ -0,0 +1,59 @@
package com.baiyue.firstleg.portschedule.service;
import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.GateItem;
import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor;
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
@Component
public class PortScheduleDispatchGateContributor implements OutboundDispatchGateContributor {
private static final String CODE = "SAILING_SCHEDULE";
private static final String REQUIREMENT = "最新独立船期来源有效,且未超过expiresAt、截港时间和ETD";
@Resource private PortScheduleRepository repository;
@Override public String code() { return CODE; }
@Override
public GateItem assess(Context context) {
if (context.tenantId() <= 0 || context.customerId() <= 0 || context.tenantId() != context.customerId()) {
return gate(false, "INVALID_CUSTOMER_SCOPE");
}
if (context.oceanBookingId() == null || context.oceanBookingId() <= 0) {
return gate(false, "MISSING_OCEAN_BOOKING");
}
if (context.containerBookingId() <= 0) return gate(false, "MISSING_CONTAINER_BOOKING");
PortScheduleEntity schedule = repository.latestForDispatch(context.tenantId(), context.oceanBookingId(),
context.containerBookingId());
if (schedule == null) return gate(false, "MISSING");
if (!java.util.Objects.equals(schedule.getCustomerId(), context.tenantId())
|| !java.util.Objects.equals(schedule.getOceanBookingId(), context.oceanBookingId())
|| !java.util.Objects.equals(schedule.getContainerBookingId(), context.containerBookingId())) {
return gate(false, "ASSOCIATION_MISMATCH");
}
return assess(schedule, LocalDateTime.now());
}
private static GateItem assess(PortScheduleEntity schedule, LocalDateTime now) {
if (!StringUtils.hasText(schedule.getSourceType()) || !StringUtils.hasText(schedule.getSourceName())
|| !StringUtils.hasText(schedule.getSourceRef()) || schedule.getObservedAt() == null
|| schedule.getExpiresAt() == null || schedule.getPortCutoffAt() == null || schedule.getEtdAt() == null) {
return gate(false, "MISSING_REQUIRED_SCHEDULE_DATA");
}
if (schedule.getObservedAt().isAfter(now)) return gate(false, "OBSERVATION_IN_FUTURE");
if (!schedule.getExpiresAt().isAfter(now)) return gate(false, "EXPIRED");
if (!schedule.getPortCutoffAt().isAfter(now)) return gate(false, "PORT_CUTOFF_PASSED");
if (!schedule.getEtdAt().isAfter(now)) return gate(false, "ETD_PASSED");
return gate(true, "READY:scheduleId=" + schedule.getId() + ",expiresAt=" + schedule.getExpiresAt()
+ ",portCutoffAt=" + schedule.getPortCutoffAt() + ",etdAt=" + schedule.getEtdAt());
}
private static GateItem gate(boolean passed, String actual) {
return new GateItem(CODE, "有效港务船期", passed, actual, REQUIREMENT);
}
}
@@ -0,0 +1,223 @@
package com.baiyue.firstleg.portschedule.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
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.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.CreateRequest;
import com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.Detail;
import com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.Query;
import com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.ScheduleData;
import com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.ScheduleView;
import com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.UpdateRequest;
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
import com.baiyue.firstleg.portschedule.entity.PortScheduleHistoryEntity;
import com.baiyue.firstleg.portschedule.entity.PortScheduleOperationEntity;
import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.HexFormat;
import java.util.Objects;
import java.util.Set;
@Service
public class PortScheduleService {
private static final Set<String> SOURCE_TYPES = Set.of("MANUAL", "CARRIER", "TERMINAL", "API");
@Resource private PortScheduleRepository repository;
@Resource private ObjectMapper objectMapper;
public PageResult<ScheduleView> page(Query query) {
PageResult<PortScheduleEntity> source = repository.page(query);
LocalDateTime now = LocalDateTime.now();
return new PageResult<>(source.records().stream().map(value -> view(value, now)).toList(),
source.page(), source.size(), source.total(), source.pages());
}
public Detail detail(long id) {
return detail(required(id));
}
public java.util.List<PortScheduleHistoryEntity> history(long id) {
required(id);
return repository.histories(id);
}
@Transactional
public Detail create(long operatorId, String operatorName, String key, CreateRequest request) {
String operation = "CREATE";
Detail replay = claimOrReplay(null, operatorId, operation, key, hash(operation, null, request));
if (replay != null) return replay;
if (request.expectedVersion() != 0) throw badRequest("新建船期expectedVersion必须为0");
validate(request.data());
validateLinks(request.data());
PortScheduleEntity value = new PortScheduleEntity();
value.setId(IdWorker.getId());
value.setScheduleNo("PS" + value.getId());
copy(value, request.data());
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName);
value.setVersion(0);
try { repository.insert(value); }
catch (DuplicateKeyException ex) { throw conflict("来源引用已存在,请更新已有船期"); }
addHistory(value.getId(), operation, null, 0, request.reason(), null, value, operatorId, operatorName);
return complete(value.getId(), operatorId, key, detail(value));
}
@Transactional
public Detail update(long id, long operatorId, String operatorName, String key, UpdateRequest request) {
String operation = "UPDATE";
Detail replay = claimOrReplay(id, operatorId, operation, key, hash(operation, id, request));
if (replay != null) return replay;
PortScheduleEntity before = requiredVersion(id, request.expectedVersion());
validate(request.data());
validateLinks(request.data());
if (!Objects.equals(before.getCustomerId(), request.data().customerId())
|| !Objects.equals(before.getOceanBookingId(), request.data().oceanBookingId())
|| !Objects.equals(before.getContainerBookingId(), request.data().containerBookingId())) {
throw conflict("船期的客户、订舱和柜次归属不可变更");
}
PortScheduleEntity value = new PortScheduleEntity();
value.setId(id);
copy(value, request.data());
try {
if (repository.update(value, request.expectedVersion(), operatorId, operatorName) != 1) throw concurrent();
} catch (DuplicateKeyException ex) { throw conflict("来源引用已存在,请更新已有船期"); }
PortScheduleEntity after = required(id);
addHistory(id, operation, before.getVersion(), after.getVersion(), request.reason(), before, after,
operatorId, operatorName);
return complete(id, operatorId, key, detail(after));
}
private void validateLinks(ScheduleData data) {
OceanBookingEntity booking = repository.oceanBooking(data.oceanBookingId());
if (booking == null) throw new BizException(ResultCode.NOT_FOUND, "关联订舱不存在");
if (!Objects.equals(booking.getCustomerId(), data.customerId())) throw badRequest("customerId与订舱客户不一致");
if (data.containerBookingId() != null) {
if (repository.containerBooking(data.containerBookingId()) == null) {
throw new BizException(ResultCode.NOT_FOUND, "关联柜次不存在");
}
if (repository.allocation(data.oceanBookingId(), data.containerBookingId()) == null) {
throw badRequest("containerBookingId未分配至该订舱");
}
}
}
private static void validate(ScheduleData data) {
String sourceType = data.sourceType().trim().toUpperCase();
if (!SOURCE_TYPES.contains(sourceType)) throw badRequest("sourceType仅支持MANUAL、CARRIER、TERMINAL或API");
if (data.etdAt() != null && data.etaAt() != null && data.etaAt().isBefore(data.etdAt())) {
throw badRequest("ETA不能早于ETD");
}
if (!data.expiresAt().isAfter(data.observedAt())) throw badRequest("expiresAt必须晚于observedAt");
validateCutoff("截单时间", data.bookingCutoffAt(), data.etdAt());
validateCutoff("截关时间", data.customsCutoffAt(), data.etdAt());
validateCutoff("截港时间", data.portCutoffAt(), data.etdAt());
}
private static void validateCutoff(String name, LocalDateTime cutoff, LocalDateTime etd) {
if (cutoff != null && etd != null && cutoff.isAfter(etd)) throw badRequest(name + "不能晚于ETD");
}
private Detail claimOrReplay(Long id, long operatorId, String operation, String key, String requestHash) {
validateKey(key);
if (repository.claimOperation(id, operatorId, operation, key, requestHash)) return null;
PortScheduleOperationEntity existing = repository.operation(operatorId, key);
if (existing == null || !requestHash.equals(existing.getRequestHash())) throw conflict("幂等键已用于其他请求");
if (!"SUCCESS".equals(existing.getStatus()) || !StringUtils.hasText(existing.getResultJson())) {
throw conflict("相同请求正在处理中,请稍后重试");
}
try { return objectMapper.readValue(existing.getResultJson(), Detail.class); }
catch (JsonProcessingException ex) { throw new IllegalStateException("无法读取船期幂等结果", ex); }
}
private Detail complete(long id, long operatorId, String key, Detail result) {
try {
if (repository.completeOperation(id, operatorId, key, objectMapper.writeValueAsString(result)) != 1) {
throw conflict("船期幂等请求完成状态冲突");
}
return result;
} catch (JsonProcessingException ex) { throw new IllegalStateException("无法保存船期幂等结果", ex); }
}
private void addHistory(long id, String action, Integer fromVersion, Integer toVersion, String reason,
Object before, Object after, long operatorId, String operatorName) {
PortScheduleHistoryEntity history = new PortScheduleHistoryEntity();
history.setId(IdWorker.getId()); history.setPortScheduleId(id); history.setAction(action);
history.setFromVersion(fromVersion); history.setToVersion(toVersion); history.setReason(trim(reason));
history.setBeforeSnapshot(json(before)); history.setAfterSnapshot(json(after));
history.setOperatorId(operatorId); history.setOperatorName(operatorName);
repository.insert(history);
}
private Detail detail(PortScheduleEntity value) {
return new Detail(view(value, LocalDateTime.now()), repository.histories(value.getId()));
}
private static ScheduleView view(PortScheduleEntity value, LocalDateTime now) {
return new ScheduleView(value, !value.getExpiresAt().isAfter(now));
}
private PortScheduleEntity required(long id) {
PortScheduleEntity value = repository.find(id);
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "港务船期不存在");
return value;
}
private PortScheduleEntity requiredVersion(long id, int version) {
PortScheduleEntity value = required(id);
if (!Integer.valueOf(version).equals(value.getVersion())) throw concurrent();
return value;
}
private static void copy(PortScheduleEntity value, ScheduleData data) {
value.setCustomerId(data.customerId()); value.setOceanBookingId(data.oceanBookingId());
value.setContainerBookingId(data.containerBookingId()); value.setSourceType(data.sourceType().trim().toUpperCase());
value.setSourceName(data.sourceName().trim()); value.setSourceRef(data.sourceRef().trim());
value.setVesselName(data.vesselName().trim()); value.setVoyageNo(data.voyageNo().trim());
value.setDeparturePortCode(data.departurePortCode().trim().toUpperCase());
value.setDeparturePortName(trim(data.departurePortName()));
value.setDestinationPortCode(data.destinationPortCode().trim().toUpperCase());
value.setDestinationPortName(trim(data.destinationPortName()));
value.setEtdAt(data.etdAt()); value.setEtaAt(data.etaAt());
value.setBookingCutoffAt(data.bookingCutoffAt()); value.setCustomsCutoffAt(data.customsCutoffAt());
value.setPortCutoffAt(data.portCutoffAt()); value.setObservedAt(data.observedAt());
value.setExpiresAt(data.expiresAt()); value.setRemark(trim(data.remark()));
}
private String hash(String operation, Long id, Object request) {
try {
byte[] bytes = (operation + '|' + id + '|' + objectMapper.writeValueAsString(request))
.getBytes(StandardCharsets.UTF_8);
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
} catch (Exception ex) { throw new IllegalStateException("无法计算船期请求摘要", ex); }
}
private String json(Object value) {
if (value == null) return null;
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException ex) { throw new IllegalStateException("无法保存船期历史快照", ex); }
}
private static void validateKey(String key) {
if (!StringUtils.hasText(key) || key.length() > 128) throw badRequest("Idempotency-Key不能为空且不能超过128个字符");
}
private static String trim(String value) {
if (value == null) return null;
String result = value.trim();
return result.isEmpty() ? null : result;
}
private static BizException badRequest(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
private static BizException concurrent() { return conflict("数据已被其他人修改,请刷新后重试"); }
}