feat(firstleg): implement container customs workflow

This commit is contained in:
2026-08-28 16:48:50 +08:00
parent bb9673358d
commit 0ea4aeecc9
92 changed files with 3165 additions and 2754 deletions
@@ -12,23 +12,13 @@ import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmPreloadRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadConfirmationResponse;
import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.BusinessDocument;
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentCompleteRequest;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.LoadingListService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import org.springframework.http.MediaType;
import org.springframework.http.CacheControl;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
@@ -41,10 +31,6 @@ import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
import java.nio.charset.StandardCharsets;
/** 基于同一装柜计划聚合提供平台预装清单和已确认装柜清单。 */
@Tag(name = "平台预装与装柜清单", description = "预装单向推进和已确认装柜清单只读查询")
@@ -120,67 +106,4 @@ public class PlatformLoadingListController {
return ApiResponse.success(service.confirmedDetail(planId, warehouseId));
}
@Operation(summary = "查询装柜清单出口报关文档")
@GetMapping("/loading-lists/{planId}/customs")
@PreAuthorize("hasAuthority('platform:firstleg:loading-list:list')")
public ApiResponse<BusinessDocument> declaration(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
@RequestParam @Positive long warehouseId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.loadingDeclaration(planId, warehouseId));
}
@Operation(summary = "下载装柜清单出口报关附件", description = "完成态仍可下载;服务端校验装柜计划、仓库、当前附件及 File 业务归属。")
@GetMapping("/loading-lists/{planId}/customs/document/{fileId}/download")
@PreAuthorize("hasAuthority('platform:firstleg:loading-list:list')")
public ResponseEntity<byte[]> downloadDeclaration(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
@PathVariable @Positive long fileId, @RequestParam @Positive long warehouseId) {
FirstlegScopeGuard.platform(user);
var download = service.downloadLoadingDeclaration(planId, warehouseId, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
.filename(download.fileName(), StandardCharsets.UTF_8).build().toString())
.contentType(download.contentType()).contentLength(download.content().length)
.body(download.content());
}
@Operation(summary = "上传或替换装柜清单出口报关文档")
@PostMapping("/loading-lists/{planId}/customs/document")
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
public ApiResponse<BusinessDocument> attachDeclaration(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
@RequestParam @Positive long warehouseId, @RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody DocumentAttachRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.attachLoadingDeclaration(
planId, warehouseId, user.userId(), user.username(), key, request));
}
@Operation(summary = "直接上传或替换装柜清单出口报关文档")
@PostMapping(value = "/loading-lists/{planId}/customs/document/upload",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
public ApiResponse<BusinessDocument> uploadDeclaration(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
@RequestParam @Positive long warehouseId, @RequestHeader("Idempotency-Key") String key,
@RequestPart("file") MultipartFile file,
@RequestParam("expectedVersion") @Min(0) int expectedVersion,
@RequestParam(value = "operationReason", required = false) @Size(max = 500) String operationReason) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.uploadLoadingDeclaration(planId, warehouseId,
user.userId(), user.username(), key, expectedVersion, operationReason, file));
}
@Operation(summary = "确认装柜清单出口报关完成")
@PostMapping("/loading-lists/{planId}/customs/complete")
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
public ApiResponse<BusinessDocument> completeDeclaration(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
@RequestParam @Positive long warehouseId, @RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody DocumentCompleteRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.completeLoadingDeclaration(
planId, warehouseId, user.userId(), user.username(), key, request));
}
}
@@ -0,0 +1,29 @@
package com.baiyue.firstleg.customs.container.controller;
import com.baiyue.common.core.api.*;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.export.ExportContracts.*;
import com.baiyue.firstleg.customs.container.service.LoadingListBuyDeclarationExportService;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@RestController
@RequestMapping("/internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS")
public class LoadingListBuyDeclarationExportController {
@Resource private LoadingListBuyDeclarationExportService service;
@Value("${baiyue.security.internal-service-token}") private String configuredToken;
private byte[] token;
@PostConstruct void init(){token=configuredToken==null?new byte[0]:configuredToken.getBytes(StandardCharsets.UTF_8);if(token.length<32)throw new IllegalStateException("INTERNAL_SERVICE_TOKEN至少需要32字节");}
@PostMapping("/batches")
public ApiResponse<ExportBatchResponse> batch(@RequestHeader(value="X-Internal-Service-Token",required=false)String supplied,
@RequestBody ExportBatchRequest request){
byte[] actual=supplied==null?new byte[0]:supplied.getBytes(StandardCharsets.UTF_8);
if(!MessageDigest.isEqual(token,actual))throw new BizException(ResultCode.FORBIDDEN,"Invalid internal service token");
return ApiResponse.success(service.batch(request));
}
}
@@ -0,0 +1,101 @@
package com.baiyue.firstleg.customs.container.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.*;
import com.baiyue.firstleg.customs.container.service.ContainerCustomsService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
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;
import java.nio.charset.StandardCharsets;
import java.util.List;
@Validated
@RestController
@RequestMapping("/api/merchant/v1/customs")
public class MerchantContainerCustomsController {
@Resource private ContainerCustomsService service;
@GetMapping("/orders/{orderId}")
@PreAuthorize("hasAuthority('merchant:order:view')")
public ApiResponse<ContainerView> order(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long orderId) {
return ApiResponse.success(service.merchantOrderView(FirstlegScopeGuard.merchant(user), orderId));
}
@PostMapping(value="/loading-plans/{planId}/documents/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('merchant:order:edit')")
public ApiResponse<DocumentView> upload(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long planId,
@RequestHeader("Idempotency-Key") String key,
@RequestParam @Size(min=1,max=200) List<@Positive Long> inboundIds,
@RequestPart("file") MultipartFile file) {
long tenantId=FirstlegScopeGuard.merchant(user);
return ApiResponse.success(service.uploadSource(tenantId,planId,user.userId(),key,inboundIds,file));
}
@PostMapping(value="/documents/{documentId}/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('merchant:order:edit')")
public ApiResponse<DocumentView> replace(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long documentId,
@RequestHeader("Idempotency-Key") String key,
@RequestParam @Size(min=1,max=200) List<@Positive Long> inboundIds,
@RequestParam @Min(1) int expectedVersion,
@RequestPart("file") MultipartFile file) {
long tenantId=FirstlegScopeGuard.merchant(user);
return ApiResponse.success(service.replaceSource(tenantId,documentId,user.userId(),key,
new ScopeRequest(inboundIds,expectedVersion),file));
}
@PutMapping("/documents/{documentId}/inbounds")
@PreAuthorize("hasAuthority('merchant:order:edit')")
public ApiResponse<DocumentView> scope(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long documentId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ScopeRequest request) {
long tenantId=FirstlegScopeGuard.merchant(user);
return ApiResponse.success(service.updateScope(tenantId,documentId,user.userId(),key,request));
}
@DeleteMapping("/documents/{documentId}")
@PreAuthorize("hasAuthority('merchant:order:edit')")
public ApiResponse<ContainerView> delete(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long documentId,
@RequestHeader("Idempotency-Key") String key,
@RequestParam @Min(1) int expectedVersion) {
long tenantId=FirstlegScopeGuard.merchant(user);
return ApiResponse.success(service.deleteSource(tenantId,documentId,user.userId(),key,expectedVersion));
}
@GetMapping("/documents/{documentId}/download")
@PreAuthorize("hasAuthority('merchant:order:view')")
public ResponseEntity<byte[]> downloadSource(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long documentId) {
return response(service.downloadSource(FirstlegScopeGuard.merchant(user),documentId,false));
}
@GetMapping("/documents/{documentId}/completion/download")
@PreAuthorize("hasAuthority('merchant:order:view')")
public ResponseEntity<byte[]> downloadCompletion(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long documentId) {
return response(service.downloadCompletion(FirstlegScopeGuard.merchant(user),documentId,false));
}
private static ResponseEntity<byte[]> response(Download value) {
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(value.contentType()))
.header(HttpHeaders.CONTENT_DISPOSITION,ContentDisposition.attachment()
.filename(value.fileName(),StandardCharsets.UTF_8).build().toString())
.contentLength(value.content().length).body(value.content());
}
}
@@ -0,0 +1,95 @@
package com.baiyue.firstleg.customs.container.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.*;
import com.baiyue.firstleg.customs.container.service.ContainerCustomsService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Positive;
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;
import java.nio.charset.StandardCharsets;
@Validated
@RestController
@RequestMapping("/api/platform/v1/customs/loading-plans")
public class PlatformContainerCustomsController {
@Resource private ContainerCustomsService service;
@GetMapping("/{planId}")
@PreAuthorize("hasAuthority('platform:firstleg:customs:list')")
public ApiResponse<ContainerView> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long planId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.platformView(planId));
}
@PostMapping("/{planId}/start")
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
@BusinessAudit(event="CONTAINER_CUSTOMS_START",resourceType="CUSTOMS_CONTAINER")
public ApiResponse<ContainerView> start(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long planId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ActionRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.start(planId,user.userId(),user.username(),key,request));
}
@PostMapping(value="/documents/{documentId}/completion/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
@BusinessAudit(event="CUSTOMS_COMPLETION_UPLOAD",resourceType="CUSTOMS_CUSTOMER_DOCUMENT")
public ApiResponse<DocumentView> uploadCompletion(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long documentId,
@RequestHeader("Idempotency-Key") String key,
@RequestParam @Min(0) int expectedVersion,
@RequestParam(defaultValue="false") boolean finalConfirmation,
@RequestPart("file") MultipartFile file) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.uploadCompletion(documentId,user.userId(),user.username(),key,
expectedVersion,finalConfirmation,file));
}
@PostMapping("/{planId}/complete-all-buy")
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
@BusinessAudit(event="ALL_BUY_CONTAINER_CUSTOMS_COMPLETE",resourceType="CUSTOMS_CONTAINER")
public ApiResponse<ContainerView> completeAllBuy(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long planId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ActionRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.completeAllBuy(planId,user.userId(),user.username(),key,request));
}
@GetMapping("/documents/{documentId}/download")
@PreAuthorize("hasAuthority('platform:firstleg:customs:list')")
public ResponseEntity<byte[]> downloadSource(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long documentId) {
FirstlegScopeGuard.platform(user);
return response(service.downloadSource(0,documentId,true));
}
@GetMapping("/documents/{documentId}/completion/download")
@PreAuthorize("hasAuthority('platform:firstleg:customs:list')")
public ResponseEntity<byte[]> downloadCompletion(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long documentId) {
FirstlegScopeGuard.platform(user);
return response(service.downloadCompletion(0,documentId,true));
}
private static ResponseEntity<byte[]> response(Download value) {
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(value.contentType()))
.header(HttpHeaders.CONTENT_DISPOSITION,ContentDisposition.attachment()
.filename(value.fileName(),StandardCharsets.UTF_8).build().toString())
.contentLength(value.content().length).body(value.content());
}
}
@@ -0,0 +1,37 @@
package com.baiyue.firstleg.customs.container.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
public final class ContainerCustomsDtos {
private ContainerCustomsDtos() {}
public record InboundView(long inboundId, long orderId, String orderNo, String inboundCode,
long tenantId, String customerName, String taxMode, boolean covered) {}
public record FileView(long fileId, String fileName, String fileHash, String contentType,
long sizeBytes, int version, LocalDateTime uploadedAt) {}
public record DocumentView(long documentId, long tenantId, FileView source,
List<Long> inboundIds, List<String> inboundCodes,
boolean frozen, FileView completion) {}
public record ContainerView(long aggregateId, long loadingPlanId, long containerBookingId,
String planNo, String cabinetNo, String containerNo, String status, String merchantStatus,
int version, int taxRefundCount, int coveredTaxRefundCount,
List<InboundView> inbounds, List<DocumentView> documents,
LocalDateTime startedAt, LocalDateTime completedAt) {}
public record ScopeRequest(@NotEmpty @Size(max = 200) List<@Positive Long> inboundIds,
@Min(0) int expectedVersion) {}
public record ActionRequest(@Min(0) int expectedVersion, boolean confirm,
@Size(max = 500) String remark) {}
public record Download(String fileName, String contentType, byte[] content) {}
}
@@ -0,0 +1,337 @@
package com.baiyue.firstleg.customs.container.mapper;
import org.apache.ibatis.annotations.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface ContainerCustomsMapper {
@Select("""
SELECT DISTINCT p.id loadingPlanId,p.plan_no planNo,p.container_booking_id containerBookingId,
p.plan_status loadingPlanStatus,b.cabinet_no cabinetNo,b.container_no containerNo,
i.id inboundId,i.order_id orderId,i.order_no orderNo,i.inbound_code inboundCode,
i.tenant_id tenantId,i.customer_name customerName,i.tax_mode taxMode
FROM fl_loading_plan p
JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE'
JOIN fl_loading_plan_item item ON item.loading_plan_id=p.id AND item.deleted=0
JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0
WHERE p.id=#{planId}
ORDER BY i.tenant_id,i.id
""")
List<InboundRow> planInbounds(@Param("planId") long planId);
@Select("""
SELECT p.id FROM fl_loading_plan p
JOIN fl_loading_plan_item item ON item.loading_plan_id=p.id AND item.deleted=0
JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0
WHERE p.plan_status='COMPLETED' AND i.tenant_id=#{tenantId} AND i.order_id=#{orderId} AND i.tax_mode='TAX_REFUND'
ORDER BY p.completed_at DESC,p.id DESC LIMIT 1
""")
Long completedPlanForOrder(@Param("tenantId") long tenantId, @Param("orderId") long orderId);
@Insert("""
INSERT IGNORE INTO fl_customs_container
(id,loading_plan_id,container_booking_id,status,created_at,updated_at,version)
VALUES (#{id},#{planId},#{containerBookingId},#{status},CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0)
""")
int insertContainer(@Param("id") long id,@Param("planId") long planId,
@Param("containerBookingId") long containerBookingId,@Param("status") String status);
@Select("SELECT * FROM fl_customs_container WHERE loading_plan_id=#{planId}")
ContainerRow container(@Param("planId") long planId);
@Select("SELECT * FROM fl_customs_container WHERE id=#{id}")
ContainerRow containerById(@Param("id") long id);
@Select("SELECT * FROM fl_customs_container WHERE container_booking_id=#{containerBookingId}")
ContainerRow containerByBooking(@Param("containerBookingId") long containerBookingId);
@Select("SELECT * FROM fl_customs_container WHERE loading_plan_id=#{planId} FOR UPDATE")
ContainerRow lockContainer(@Param("planId") long planId);
@Select("SELECT * FROM fl_customs_container WHERE id=#{id} FOR UPDATE")
ContainerRow lockContainerById(@Param("id") long id);
@Update("""
UPDATE fl_customs_container SET status=#{status},updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{id} AND version=#{version} AND status IN ('WAITING_CUSTOMER_DOCUMENTS','READY_TO_START')
""")
int refreshStatus(@Param("id") long id,@Param("version") int version,@Param("status") String status);
@Update("""
UPDATE fl_customs_container SET status='DECLARING',started_by=#{operatorId},started_by_name=#{operatorName},
started_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{id} AND version=#{version} AND status='READY_TO_START'
""")
int start(@Param("id") long id,@Param("version") int version,@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_customs_container SET status='COMPLETED',completed_by=#{operatorId},completed_by_name=#{operatorName},
completed_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{id} AND version=#{version} AND status='DECLARING'
""")
int complete(@Param("id") long id,@Param("version") int version,@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Insert("""
INSERT INTO fl_customs_customer_document
(id,customs_container_id,tenant_id,file_id,file_name,file_hash,content_type,size_bytes,status,
uploaded_by,uploaded_at,version,created_at,updated_at)
VALUES (#{id},#{containerId},#{tenantId},#{fileId},#{fileName},#{fileHash},#{contentType},#{sizeBytes},'ACTIVE',
#{operatorId},CURRENT_TIMESTAMP(3),1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))
""")
int insertDocument(@Param("id") long id,@Param("containerId") long containerId,@Param("tenantId") long tenantId,
@Param("fileId") long fileId,@Param("fileName") String fileName,@Param("fileHash") String fileHash,
@Param("contentType") String contentType,@Param("sizeBytes") long sizeBytes,@Param("operatorId") long operatorId);
@Select("SELECT * FROM fl_customs_customer_document WHERE id=#{id}")
DocumentRow document(@Param("id") long id);
@Select("SELECT * FROM fl_customs_customer_document WHERE id=#{id} FOR UPDATE")
DocumentRow lockDocument(@Param("id") long id);
@Select("""
<script>SELECT * FROM fl_customs_customer_document
WHERE customs_container_id=#{containerId} AND status='ACTIVE'
<if test='tenantId != null'>AND tenant_id=#{tenantId}</if>
ORDER BY created_at,id</script>
""")
List<DocumentRow> documents(@Param("containerId") long containerId,@Param("tenantId") Long tenantId);
@Select("SELECT * FROM fl_customs_customer_document WHERE customs_container_id=#{containerId} AND status='ACTIVE' ORDER BY id FOR UPDATE")
List<DocumentRow> lockActiveDocuments(@Param("containerId") long containerId);
@Update("""
UPDATE fl_customs_customer_document SET file_id=#{fileId},file_name=#{fileName},file_hash=#{fileHash},
content_type=#{contentType},size_bytes=#{sizeBytes},uploaded_by=#{operatorId},uploaded_at=CURRENT_TIMESTAMP(3),
updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{id} AND tenant_id=#{tenantId} AND status='ACTIVE' AND frozen_at IS NULL AND version=#{version}
""")
int replaceDocument(@Param("id") long id,@Param("tenantId") long tenantId,@Param("version") int version,
@Param("fileId") long fileId,@Param("fileName") String fileName,@Param("fileHash") String fileHash,
@Param("contentType") String contentType,@Param("sizeBytes") long sizeBytes,@Param("operatorId") long operatorId);
@Update("""
UPDATE fl_customs_customer_document SET updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{id} AND tenant_id=#{tenantId} AND status='ACTIVE' AND frozen_at IS NULL AND version=#{version}
""")
int touchScope(@Param("id") long id,@Param("tenantId") long tenantId,@Param("version") int version);
@Update("""
UPDATE fl_customs_customer_document SET status='DELETED',deleted_by=#{operatorId},deleted_at=CURRENT_TIMESTAMP(3),
updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{id} AND tenant_id=#{tenantId} AND status='ACTIVE' AND frozen_at IS NULL AND version=#{version}
""")
int deleteDocument(@Param("id") long id,@Param("tenantId") long tenantId,@Param("version") int version,
@Param("operatorId") long operatorId);
@Select("""
SELECT s.document_id documentId,s.inbound_id inboundId,s.order_id orderId,s.inbound_code inboundCode
FROM fl_customs_customer_document_inbound s WHERE s.document_id=#{documentId} ORDER BY s.inbound_id
""")
List<ScopeRow> scopes(@Param("documentId") long documentId);
@Delete("DELETE FROM fl_customs_customer_document_inbound WHERE document_id=#{documentId}")
int deleteScopes(@Param("documentId") long documentId);
@Insert("""
INSERT INTO fl_customs_customer_document_inbound(document_id,inbound_id,tenant_id,order_id,inbound_code,created_at)
VALUES(#{documentId},#{inboundId},#{tenantId},#{orderId},#{inboundCode},CURRENT_TIMESTAMP(3))
""")
int insertScope(@Param("documentId") long documentId,@Param("inboundId") long inboundId,
@Param("tenantId") long tenantId,@Param("orderId") long orderId,@Param("inboundCode") String inboundCode);
@Insert("""
INSERT INTO fl_customs_customer_document_version
(id,document_id,document_version,file_id,file_name,file_hash,content_type,size_bytes,scope_snapshot_json,
action,operator_id,created_at)
VALUES(#{id},#{documentId},#{version},#{fileId},#{fileName},#{fileHash},#{contentType},#{sizeBytes},
CAST(#{scopeJson} AS JSON),#{action},#{operatorId},CURRENT_TIMESTAMP(3))
""")
int insertDocumentVersion(@Param("id") long id,@Param("documentId") long documentId,@Param("version") int version,
@Param("fileId") long fileId,@Param("fileName") String fileName,@Param("fileHash") String fileHash,
@Param("contentType") String contentType,@Param("sizeBytes") long sizeBytes,
@Param("scopeJson") String scopeJson,@Param("action") String action,@Param("operatorId") long operatorId);
@Select("""
SELECT COUNT(*) FROM (
SELECT DISTINCT i.id FROM fl_loading_plan_item item
JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0
WHERE item.loading_plan_id=#{planId} AND item.deleted=0 AND i.tax_mode='TAX_REFUND'
) target LEFT JOIN fl_customs_customer_document_inbound s ON s.inbound_id=target.id
LEFT JOIN fl_customs_customer_document d ON d.id=s.document_id AND d.customs_container_id=#{containerId} AND d.status='ACTIVE'
WHERE d.id IS NULL
""")
int missingRefundCount(@Param("planId") long planId,@Param("containerId") long containerId);
@Update("UPDATE fl_customs_customer_document SET frozen_version=version,frozen_at=CURRENT_TIMESTAMP(3) " +
"WHERE customs_container_id=#{containerId} AND status='ACTIVE' AND frozen_at IS NULL")
int freezeDocuments(@Param("containerId") long containerId);
@Insert("""
INSERT INTO fl_customs_frozen_document_inbound(document_id,inbound_id,tenant_id,order_id,inbound_code,created_at)
SELECT s.document_id,s.inbound_id,s.tenant_id,s.order_id,s.inbound_code,CURRENT_TIMESTAMP(3)
FROM fl_customs_customer_document_inbound s
JOIN fl_customs_customer_document d ON d.id=s.document_id
WHERE d.customs_container_id=#{containerId} AND d.status='ACTIVE'
""")
int freezeScopes(@Param("containerId") long containerId);
@Select("""
SELECT s.document_id documentId,s.inbound_id inboundId,s.order_id orderId,s.inbound_code inboundCode
FROM fl_customs_frozen_document_inbound s WHERE s.document_id=#{documentId} ORDER BY s.inbound_id
""")
List<ScopeRow> frozenScopes(@Param("documentId") long documentId);
@Select("SELECT * FROM fl_customs_completion_document WHERE source_document_id=#{documentId}")
CompletionRow completion(@Param("documentId") long documentId);
@Select("SELECT * FROM fl_customs_completion_document WHERE source_document_id=#{documentId} FOR UPDATE")
CompletionRow lockCompletion(@Param("documentId") long documentId);
@Insert("""
INSERT INTO fl_customs_completion_document
(id,source_document_id,file_id,file_name,file_hash,content_type,size_bytes,uploaded_by,uploaded_by_name,
uploaded_at,version,created_at,updated_at)
VALUES(#{id},#{documentId},#{fileId},#{fileName},#{fileHash},#{contentType},#{sizeBytes},#{operatorId},#{operatorName},
CURRENT_TIMESTAMP(3),1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))
""")
int insertCompletion(@Param("id") long id,@Param("documentId") long documentId,@Param("fileId") long fileId,
@Param("fileName") String fileName,@Param("fileHash") String fileHash,
@Param("contentType") String contentType,@Param("sizeBytes") long sizeBytes,
@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_customs_completion_document SET file_id=#{fileId},file_name=#{fileName},file_hash=#{fileHash},
content_type=#{contentType},size_bytes=#{sizeBytes},uploaded_by=#{operatorId},uploaded_by_name=#{operatorName},
uploaded_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{id} AND version=#{version}
AND EXISTS(SELECT 1 FROM fl_customs_customer_document d JOIN fl_customs_container c ON c.id=d.customs_container_id
WHERE d.id=source_document_id AND c.status='DECLARING')
""")
int replaceCompletion(@Param("id") long id,@Param("version") int version,@Param("fileId") long fileId,
@Param("fileName") String fileName,@Param("fileHash") String fileHash,
@Param("contentType") String contentType,@Param("sizeBytes") long sizeBytes,
@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Insert("""
INSERT INTO fl_customs_completion_document_version
(id,completion_document_id,document_version,file_id,file_name,file_hash,content_type,size_bytes,
operator_id,operator_name,created_at)
VALUES(#{id},#{completionId},#{version},#{fileId},#{fileName},#{fileHash},#{contentType},#{sizeBytes},
#{operatorId},#{operatorName},CURRENT_TIMESTAMP(3))
""")
int insertCompletionVersion(@Param("id") long id,@Param("completionId") long completionId,@Param("version") int version,
@Param("fileId") long fileId,@Param("fileName") String fileName,@Param("fileHash") String fileHash,
@Param("contentType") String contentType,@Param("sizeBytes") long sizeBytes,
@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Select("""
SELECT COUNT(*) FROM fl_customs_customer_document d
LEFT JOIN fl_customs_completion_document r ON r.source_document_id=d.id
WHERE d.customs_container_id=#{containerId} AND d.status='ACTIVE' AND d.frozen_at IS NOT NULL AND r.id IS NULL
""")
int missingCompletionCount(@Param("containerId") long containerId);
@Insert("""
INSERT INTO fl_customs_container_history
(id,customs_container_id,action,from_status,to_status,remark,operator_id,operator_name,created_at)
VALUES(#{id},#{containerId},#{action},#{fromStatus},#{toStatus},#{remark},#{operatorId},#{operatorName},CURRENT_TIMESTAMP(3))
""")
int insertHistory(@Param("id") long id,@Param("containerId") long containerId,@Param("action") String action,
@Param("fromStatus") String fromStatus,@Param("toStatus") String toStatus,@Param("remark") String remark,
@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Insert("""
INSERT IGNORE INTO fl_customs_command
(id,actor_id,action,idempotency_key,request_hash,status,created_at)
VALUES(#{id},#{actorId},#{action},#{key},#{hash},'PROCESSING',CURRENT_TIMESTAMP(3))
""")
int claimCommand(@Param("id") long id,@Param("actorId") long actorId,@Param("action") String action,
@Param("key") String key,@Param("hash") String hash);
@Select("SELECT * FROM fl_customs_command WHERE actor_id=#{actorId} AND action=#{action} AND idempotency_key=#{key}")
CommandRow command(@Param("actorId") long actorId,@Param("action") String action,@Param("key") String key);
@Update("""
UPDATE fl_customs_command SET status='SUCCESS',result_id=#{resultId},completed_at=CURRENT_TIMESTAMP(3)
WHERE actor_id=#{actorId} AND action=#{action} AND idempotency_key=#{key} AND status='PROCESSING'
""")
int completeCommand(@Param("actorId") long actorId,@Param("action") String action,@Param("key") String key,
@Param("resultId") long resultId);
@Select("""
SELECT c.container_booking_id FROM fl_customs_container c
WHERE c.status='COMPLETED' AND NOT EXISTS(
SELECT 1 FROM fl_loading_plan_item item
LEFT JOIN fl_outbound_dispatch d ON d.inbound_id=item.inbound_id
AND d.tenant_id=item.account_id AND d.container_booking_id=c.container_booking_id
WHERE item.loading_plan_id=c.loading_plan_id AND item.deleted=0
GROUP BY item.loading_plan_id HAVING COUNT(*)=SUM(CASE WHEN d.status='WAITING_FOR_LOADING' THEN 1 ELSE 0 END)
) ORDER BY c.completed_at,c.id LIMIT #{limit}
""")
List<Long> pendingAutoDispatch(@Param("limit") int limit);
@Select("""
SELECT item.id itemId,p.plan_no planNo,b.cabinet_no cabinetNo,b.container_no containerNo,
item.customer_name customerName,item.order_no orderNo,item.inbound_code inboundCode,item.sku_code skuCode,
item.product_name_zh productNameZh,item.product_name_en productNameEn,item.package_count packageCount,
item.product_quantity productQuantity,item.gross_weight_kg grossWeightKg,item.net_weight_kg netWeightKg,
item.volume_cbm volumeCbm,item.shipping_mark shippingMark,item.customer_remark customerRemark
FROM fl_loading_plan_item item
JOIN fl_loading_plan p ON p.id=item.loading_plan_id AND p.plan_status='COMPLETED'
JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE'
JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0
WHERE p.id=#{planId} AND item.deleted=0 AND i.tax_mode='BUY_DECLARATION'
AND item.id>#{cursor} AND item.id<=#{maximumId}
ORDER BY item.id LIMIT #{limit}
""")
List<BuyExportRow> buyRows(@Param("planId") long planId,@Param("cursor") long cursor,
@Param("maximumId") long maximumId,@Param("limit") int limit);
@Select("""
SELECT p.id planId,p.plan_status planStatus,b.warehouse_id warehouseId
FROM fl_loading_plan p JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE'
WHERE p.id=#{planId}
""")
PlanExportScope planExportScope(@Param("planId") long planId);
@Select("""
SELECT COALESCE(MAX(item.id),0) FROM fl_loading_plan_item item
JOIN fl_loading_plan p ON p.id=item.loading_plan_id AND p.plan_status='COMPLETED'
JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0
WHERE p.id=#{planId} AND item.deleted=0 AND i.tax_mode='BUY_DECLARATION'
""")
long maximumBuyItemId(@Param("planId") long planId);
@Select("""
SELECT COUNT(*) FROM fl_loading_plan_item item
JOIN fl_loading_plan p ON p.id=item.loading_plan_id AND p.plan_status='COMPLETED'
JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0
WHERE p.id=#{planId} AND item.deleted=0 AND i.tax_mode='BUY_DECLARATION' AND item.id<=#{maximumId}
""")
long countBuyRows(@Param("planId") long planId,@Param("maximumId") long maximumId);
record InboundRow(Long loadingPlanId,String planNo,Long containerBookingId,String loadingPlanStatus,
String cabinetNo,String containerNo,Long inboundId,Long orderId,String orderNo,String inboundCode,
Long tenantId,String customerName,String taxMode) {}
record ContainerRow(Long id,Long loadingPlanId,Long containerBookingId,String status,Long startedBy,
String startedByName,LocalDateTime startedAt,Long completedBy,String completedByName,
LocalDateTime completedAt,LocalDateTime createdAt,LocalDateTime updatedAt,Integer version) {}
record DocumentRow(Long id,Long customsContainerId,Long tenantId,Long fileId,String fileName,String fileHash,
String contentType,Long sizeBytes,String status,Long uploadedBy,LocalDateTime uploadedAt,
Integer frozenVersion,LocalDateTime frozenAt,Integer version) {}
record ScopeRow(Long documentId,Long inboundId,Long orderId,String inboundCode) {}
record CompletionRow(Long id,Long sourceDocumentId,Long fileId,String fileName,String fileHash,String contentType,
Long sizeBytes,Long uploadedBy,String uploadedByName,LocalDateTime uploadedAt,Integer version) {}
record CommandRow(Long id,Long actorId,String action,String idempotencyKey,String requestHash,String status,
Long resultId,LocalDateTime createdAt,LocalDateTime completedAt) {}
record BuyExportRow(Long itemId,String planNo,String cabinetNo,String containerNo,String customerName,String orderNo,
String inboundCode,String skuCode,String productNameZh,String productNameEn,Integer packageCount,
Integer productQuantity,BigDecimal grossWeightKg,BigDecimal netWeightKg,BigDecimal volumeCbm,
String shippingMark,String customerRemark) {}
record PlanExportScope(Long planId,String planStatus,Long warehouseId) {}
}
@@ -0,0 +1,409 @@
package com.baiyue.firstleg.customs.container.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.customs.client.CustomsFileClient;
import com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.*;
import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper;
import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper.*;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
public class ContainerCustomsService {
public static final String WAITING = "WAITING_CUSTOMER_DOCUMENTS";
public static final String READY = "READY_TO_START";
public static final String DECLARING = "DECLARING";
public static final String COMPLETED = "COMPLETED";
private static final String SOURCE_FILE_TYPE = "LOADING_LIST_CUSTOMS_SOURCE";
private static final String RESULT_FILE_TYPE = "LOADING_LIST_CUSTOMS_RESULT";
private static final long MAX_FILE_BYTES = 50L * 1024 * 1024;
@Resource private ContainerCustomsMapper mapper;
@Resource private CustomsFileClient files;
@Resource private ObjectMapper objectMapper;
@Resource private ApplicationEventPublisher events;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
@Transactional
public ContainerView platformView(long planId) {
ContainerRow container = ensure(planId);
return view(container, null, false);
}
@Transactional
public ContainerView merchantOrderView(long tenantId, long orderId) {
Long planId = mapper.completedPlanForOrder(tenantId, orderId);
if (planId == null) throw notFound("订单尚未完成装柜");
ContainerRow container = ensure(planId);
return view(container, tenantId, true);
}
@Transactional
public DocumentView uploadSource(long tenantId, long planId, long operatorId, String key,
List<Long> inboundIds, MultipartFile file) {
ensureMutable(planId);
ContainerRow container = lockEditableContainerByPlan(planId);
List<InboundRow> scope = validateScope(planId, tenantId, inboundIds);
validateFile(file);
long documentId = IdWorker.getId();
String action = "UPLOAD_SOURCE";
String requestHash = hash(action, planId, scope.stream().map(InboundRow::inboundId).toList(), fileHash(file));
CommandRow replay = claim(operatorId, action, key, requestHash);
if (replay != null) return documentView(requiredDocument(replay.resultId()), false);
CustomsFileClient.Metadata metadata = upload(tenantId, SOURCE_FILE_TYPE, Long.toString(planId), operatorId, file);
mapper.insertDocument(documentId, container.id(), tenantId, metadata.fileId(), metadata.fileName(),
metadata.sha256(), metadata.contentType(), size(metadata), operatorId);
replaceScopes(documentId, tenantId, scope);
mapper.insertDocumentVersion(IdWorker.getId(), documentId, 1, metadata.fileId(), metadata.fileName(),
metadata.sha256(), metadata.contentType(), size(metadata), scopeJson(scope), "UPLOAD", operatorId);
refresh(container.loadingPlanId());
completeCommand(operatorId, action, key, documentId);
return documentView(requiredDocument(documentId), false);
}
@Transactional
public DocumentView replaceSource(long tenantId, long documentId, long operatorId, String key,
ScopeRequest request, MultipartFile file) {
validateFile(file);
DocumentRow readable = requiredDocument(documentId);
if (readable.tenantId()!=tenantId) throw notFound("客户报送资料不存在");
ContainerRow container = lockEditableContainer(readable.customsContainerId());
DocumentRow current = requiredDocumentLocked(documentId, tenantId);
List<InboundRow> scope = validateScope(container.loadingPlanId(), tenantId, request.inboundIds());
String action = "REPLACE_SOURCE_" + documentId;
String requestHash = hash(action, request.expectedVersion(), scope.stream().map(InboundRow::inboundId).toList(), fileHash(file));
CommandRow replay = claim(operatorId, action, key, requestHash);
if (replay != null) return documentView(requiredDocument(documentId), false);
CustomsFileClient.Metadata metadata = upload(tenantId, SOURCE_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file);
if (mapper.replaceDocument(documentId, tenantId, request.expectedVersion(), metadata.fileId(), metadata.fileName(),
metadata.sha256(), metadata.contentType(), size(metadata), operatorId) != 1) throw concurrent();
replaceScopes(documentId, tenantId, scope);
int version = request.expectedVersion() + 1;
mapper.insertDocumentVersion(IdWorker.getId(), documentId, version, metadata.fileId(), metadata.fileName(),
metadata.sha256(), metadata.contentType(), size(metadata), scopeJson(scope), "REPLACE", operatorId);
refresh(container.loadingPlanId());
completeCommand(operatorId, action, key, documentId);
return documentView(requiredDocument(documentId), false);
}
@Transactional
public DocumentView updateScope(long tenantId, long documentId, long operatorId, String key, ScopeRequest request) {
DocumentRow readable = requiredDocument(documentId);
if (readable.tenantId()!=tenantId) throw notFound("客户报送资料不存在");
ContainerRow container = lockEditableContainer(readable.customsContainerId());
DocumentRow current = requiredDocumentLocked(documentId, tenantId);
List<InboundRow> scope = validateScope(container.loadingPlanId(), tenantId, request.inboundIds());
String action = "UPDATE_SOURCE_SCOPE_" + documentId;
String requestHash = hash(action, request.expectedVersion(), scope.stream().map(InboundRow::inboundId).toList());
CommandRow replay = claim(operatorId, action, key, requestHash);
if (replay != null) return documentView(requiredDocument(documentId), false);
if (mapper.touchScope(documentId, tenantId, request.expectedVersion()) != 1) throw concurrent();
replaceScopes(documentId, tenantId, scope);
int version = request.expectedVersion() + 1;
mapper.insertDocumentVersion(IdWorker.getId(), documentId, version, current.fileId(), current.fileName(),
current.fileHash(), current.contentType(), current.sizeBytes(), scopeJson(scope), "SCOPE_CHANGE", operatorId);
refresh(container.loadingPlanId());
completeCommand(operatorId, action, key, documentId);
return documentView(requiredDocument(documentId), false);
}
@Transactional
public ContainerView deleteSource(long tenantId, long documentId, long operatorId, String key, int expectedVersion) {
DocumentRow readable = requiredDocument(documentId);
if (readable.tenantId()!=tenantId) throw notFound("客户报送资料不存在");
ContainerRow container = lockEditableContainer(readable.customsContainerId());
DocumentRow current = requiredDocumentLocked(documentId, tenantId);
String action = "DELETE_SOURCE_" + documentId;
String requestHash = hash(action, expectedVersion);
CommandRow replay = claim(operatorId, action, key, requestHash);
if (replay != null) return view(mapper.container(container.loadingPlanId()), tenantId, true);
List<InboundRow> deletedScope=validateScope(container.loadingPlanId(),tenantId,mapper.scopes(documentId).stream().map(ScopeRow::inboundId).toList());
if (mapper.deleteDocument(documentId, tenantId, expectedVersion, operatorId) != 1) throw concurrent();
mapper.insertDocumentVersion(IdWorker.getId(),documentId,expectedVersion+1,current.fileId(),current.fileName(),
current.fileHash(),current.contentType(),current.sizeBytes(),scopeJson(deletedScope),"DELETE",operatorId);
mapper.deleteScopes(documentId);
history(container, "DELETE_CUSTOMER_DOCUMENT", container.status(), container.status(),
"documentId=" + documentId, operatorId, null);
refresh(container.loadingPlanId());
completeCommand(operatorId, action, key, container.id());
return view(mapper.container(container.loadingPlanId()), tenantId, true);
}
@Transactional
public ContainerView start(long planId, long operatorId, String operatorName, String key, ActionRequest request) {
ContainerRow current = ensure(planId);
String action = "START_CONTAINER_CUSTOMS_" + planId;
if(!request.confirm())throw bad("请确认开始整柜报关");
String requestHash = hash(action, request.expectedVersion(), request.confirm(), request.remark());
CommandRow replay = claim(operatorId, action, key, requestHash);
if (replay != null) return platformView(planId);
ContainerRow locked = mapper.lockContainer(planId);
if (locked == null || !READY.equals(locked.status())) throw conflict("当前柜报关资料尚未齐全或已开始报关");
if (!Objects.equals(locked.version(), request.expectedVersion())) throw concurrent();
mapper.lockActiveDocuments(locked.id());
if (mapper.missingRefundCount(planId, locked.id()) != 0) throw conflict("仍有退税入仓码未上传报送资料");
mapper.freezeScopes(locked.id());
mapper.freezeDocuments(locked.id());
if (mapper.start(locked.id(), locked.version(), operatorId, operatorName) != 1) throw concurrent();
history(locked, "START_CUSTOMS", locked.status(), DECLARING, request.remark(), operatorId, operatorName);
completeCommand(operatorId, action, key, locked.id());
return platformView(planId);
}
@Transactional
public DocumentView uploadCompletion(long documentId, long operatorId, String operatorName, String key,
int expectedVersion, boolean finalConfirmation, MultipartFile file) {
validateFile(file);
DocumentRow readable=requiredDocument(documentId);
ContainerRow container = mapper.lockContainerById(readable.customsContainerId());
DocumentRow source = requiredDocumentLocked(documentId, null);
if (container == null || !DECLARING.equals(container.status()) || source.frozenAt() == null) {
throw conflict("当前客户资料不在报关中冻结版本");
}
CompletionRow completion = mapper.lockCompletion(documentId);
int missingBefore=mapper.missingCompletionCount(container.id());
boolean lastNewCompletion=completion==null&&missingBefore==1;
if(lastNewCompletion&&!finalConfirmation)throw conflict("这是最后一份完成文件,请二次确认后上传");
if(finalConfirmation&&!lastNewCompletion)throw conflict("只有最后一份尚未上传的完成文件才能确认整柜完成");
String action = "UPLOAD_COMPLETION_" + documentId;
String requestHash = hash(action, expectedVersion, finalConfirmation, fileHash(file));
CommandRow replay = claim(operatorId, action, key, requestHash);
if (replay != null) return documentView(requiredDocument(documentId), false);
CustomsFileClient.Metadata metadata = upload(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file);
long completionId;
int version;
if (completion == null) {
if (expectedVersion != 0) throw concurrent();
completionId = IdWorker.getId(); version = 1;
mapper.insertCompletion(completionId, documentId, metadata.fileId(), metadata.fileName(), metadata.sha256(),
metadata.contentType(), size(metadata), operatorId, operatorName);
} else {
completionId = completion.id(); version = completion.version() + 1;
if (mapper.replaceCompletion(completion.id(), expectedVersion, metadata.fileId(), metadata.fileName(), metadata.sha256(),
metadata.contentType(), size(metadata), operatorId, operatorName) != 1) throw concurrent();
}
mapper.insertCompletionVersion(IdWorker.getId(), completionId, version, metadata.fileId(), metadata.fileName(),
metadata.sha256(), metadata.contentType(), size(metadata), operatorId, operatorName);
if (lastNewCompletion) {
completeContainer(container, operatorId, operatorName, "全部冻结客户资料已回传完成文件");
}
completeCommand(operatorId, action, key, completionId);
return documentView(requiredDocument(documentId), false);
}
@Transactional
public ContainerView completeAllBuy(long planId, long operatorId, String operatorName, String key, ActionRequest request) {
String action = "COMPLETE_ALL_BUY_CUSTOMS_" + planId;
if(!request.confirm())throw bad("请二次确认全买单柜报关完成");
String requestHash = hash(action, request.expectedVersion(), request.confirm(), request.remark());
CommandRow replay = claim(operatorId, action, key, requestHash);
if (replay != null) return platformView(planId);
ContainerRow container = mapper.lockContainer(planId);
if (container == null || !DECLARING.equals(container.status())) throw conflict("当前柜不在报关中");
if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent();
List<InboundRow> actual=planRows(planId);
if(actual.stream().anyMatch(row->!"BUY_DECLARATION".equals(row.taxMode())))throw conflict("全买单柜完成只允许全部实际行均为BUY_DECLARATION");
completeContainer(container, operatorId, operatorName, request.remark());
completeCommand(operatorId, action, key, container.id());
return platformView(planId);
}
public Download downloadSource(long tenantId, long documentId, boolean platform) {
DocumentRow source = requiredDocument(documentId);
if (!platform && source.tenantId() != tenantId) throw notFound("客户报送资料不存在");
ContainerRow container=mapper.containerById(source.customsContainerId());
return download(source.tenantId(), SOURCE_FILE_TYPE, Long.toString(container.loadingPlanId()), source.fileId(), source.fileName());
}
public Download downloadCompletion(long tenantId, long documentId, boolean platform) {
DocumentRow source = requiredDocument(documentId);
ContainerRow container = mapper.containerById(source.customsContainerId());
if (!platform && (source.tenantId() != tenantId || container == null || !COMPLETED.equals(container.status()))) {
throw notFound("报关完成文件不存在");
}
CompletionRow completion = mapper.completion(documentId);
if (completion == null) throw notFound("报关完成文件不存在");
return download(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), completion.fileId(), completion.fileName());
}
private ContainerRow ensure(long planId) {
List<InboundRow> rows = planRows(planId);
InboundRow first = rows.get(0);
if (!"COMPLETED".equals(first.loadingPlanStatus())) throw conflict("装柜完成后才能处理报关资料");
boolean refund = rows.stream().anyMatch(row -> "TAX_REFUND".equals(row.taxMode()));
mapper.insertContainer(IdWorker.getId(), planId, first.containerBookingId(), refund ? WAITING : READY);
return refresh(planId);
}
private ContainerRow ensureMutable(long planId) {
ContainerRow value = ensure(planId);
if (!Set.of(WAITING, READY).contains(value.status())) throw conflict("整柜已开始报关,客户资料已锁定");
return value;
}
private ContainerRow refresh(long planId) {
ContainerRow value = mapper.container(planId);
if (value == null || !Set.of(WAITING, READY).contains(value.status())) return value;
String status = mapper.missingRefundCount(planId, value.id()) == 0 ? READY : WAITING;
if (!status.equals(value.status())) {
if (mapper.refreshStatus(value.id(), value.version(), status) != 1) throw concurrent();
value = mapper.container(planId);
}
return value;
}
private ContainerRow lockEditableContainer(long containerId) {
ContainerRow value = mapper.lockContainerById(containerId);
if (value == null || !Set.of(WAITING, READY).contains(value.status())) throw conflict("整柜已开始报关,客户资料已锁定");
return value;
}
private ContainerRow lockEditableContainerByPlan(long planId) {
ContainerRow value=mapper.lockContainer(planId);
if(value==null||!Set.of(WAITING,READY).contains(value.status()))throw conflict("整柜已开始报关,客户资料已锁定");
return value;
}
private List<InboundRow> planRows(long planId) {
List<InboundRow> rows = mapper.planInbounds(planId);
if (rows == null || rows.isEmpty()) throw notFound("未找到当前装柜计划的实际货物明细");
return rows;
}
private List<InboundRow> validateScope(long planId, long tenantId, List<Long> inboundIds) {
if (inboundIds == null || inboundIds.isEmpty() || inboundIds.size() > 200) throw bad("至少选择一个且最多200个入仓码");
Set<Long> requested = new LinkedHashSet<>(inboundIds);
List<InboundRow> scope = planRows(planId).stream()
.filter(row -> requested.contains(row.inboundId()) && row.tenantId() == tenantId && "TAX_REFUND".equals(row.taxMode()))
.toList();
if (scope.size() != requested.size()) throw bad("只能选择当前柜内属于本客户的退税入仓码");
return scope;
}
private void replaceScopes(long documentId, long tenantId, List<InboundRow> scope) {
mapper.deleteScopes(documentId);
for (InboundRow row : scope) mapper.insertScope(documentId, row.inboundId(), tenantId, row.orderId(), row.inboundCode());
}
private ContainerView view(ContainerRow container, Long tenantId, boolean merchant) {
List<InboundRow> rows = planRows(container.loadingPlanId());
List<DocumentRow> documents = mapper.documents(container.id(), tenantId);
Set<Long> covered = documents.stream().flatMap(doc -> mapper.scopes(doc.id()).stream()).map(ScopeRow::inboundId).collect(Collectors.toSet());
List<InboundRow> visibleRows=rows.stream().filter(row -> tenantId == null || (row.tenantId().equals(tenantId)&&"TAX_REFUND".equals(row.taxMode()))).toList();
List<InboundView> inbounds = visibleRows.stream()
.map(row -> new InboundView(row.inboundId(), row.orderId(), row.orderNo(), row.inboundCode(), row.tenantId(),
row.customerName(), row.taxMode(), covered.contains(row.inboundId()))).toList();
List<DocumentView> documentViews = documents.stream().map(doc -> documentView(doc, merchant)).toList();
int refund = (int) visibleRows.stream().filter(row -> "TAX_REFUND".equals(row.taxMode())).count();
int coveredRefund = (int) visibleRows.stream().filter(row -> covered.contains(row.inboundId())).count();
String merchantStatus=tenantId==null?null:merchantStatus(container.status(),refund,coveredRefund);
InboundRow first = rows.get(0);
return new ContainerView(container.id(), container.loadingPlanId(), container.containerBookingId(), first.planNo(),
first.cabinetNo(), first.containerNo(), container.status(), merchantStatus, container.version(), refund, coveredRefund,
inbounds, documentViews, container.startedAt(), container.completedAt());
}
private DocumentView documentView(DocumentRow source, boolean merchant) {
List<ScopeRow> scopes = source.frozenAt() == null ? mapper.scopes(source.id()) : mapper.frozenScopes(source.id());
CompletionRow completion = mapper.completion(source.id());
ContainerRow container = mapper.containerById(source.customsContainerId());
FileView result = completion == null || (merchant && !COMPLETED.equals(container.status())) ? null : file(completion);
return new DocumentView(source.id(), source.tenantId(), file(source), scopes.stream().map(ScopeRow::inboundId).toList(),
scopes.stream().map(ScopeRow::inboundCode).toList(), source.frozenAt() != null, result);
}
private void completeContainer(ContainerRow container, long operatorId, String operatorName, String remark) {
if (mapper.complete(container.id(), container.version(), operatorId, operatorName) != 1) throw concurrent();
history(container, "COMPLETE_CUSTOMS", container.status(), COMPLETED, remark, operatorId, operatorName);
events.publishEvent(new CustomsCompletedEvent(container.containerBookingId()));
}
private void history(ContainerRow container,String action,String from,String to,String remark,long operatorId,String operatorName) {
mapper.insertHistory(IdWorker.getId(), container.id(), action, from, to, trim(remark), operatorId, trim(operatorName));
}
private CommandRow claim(long actorId, String action, String key, String requestHash) {
validateKey(key);
if (mapper.claimCommand(IdWorker.getId(), actorId, action, key, requestHash) == 1) return null;
CommandRow existing = mapper.command(actorId, action, key);
if (existing == null || !Objects.equals(existing.requestHash(), requestHash)) throw conflict("Idempotency-Key已用于不同请求");
if (!"SUCCESS".equals(existing.status())) throw conflict("相同请求正在处理中");
return existing;
}
private void completeCommand(long actorId,String action,String key,long resultId) {
if (mapper.completeCommand(actorId, action, key, resultId) != 1) throw concurrent();
}
private CustomsFileClient.Metadata upload(long tenantId,String type,String businessId,long operatorId,MultipartFile file) {
var response = files.upload(internalToken, operatorId, tenantId, type, businessId, file);
if (response == null || !response.successful() || response.data() == null || response.data().fileId() == null) {
throw conflict("File服务上传失败");
}
CustomsFileClient.Metadata metadata = response.data();
if (metadata.tenantId() == null || metadata.tenantId() != tenantId || !type.equals(metadata.businessType())
|| !businessId.equals(metadata.businessId())) throw conflict("File服务返回的文件归属不一致");
return metadata;
}
private Download download(long tenantId,String type,String businessId,long fileId,String fileName) {
ResponseEntity<byte[]> response = files.download(internalToken, tenantId, type, businessId, fileId);
if (response == null || response.getBody() == null) throw notFound("文件不存在");
MediaType contentType = response.getHeaders().getContentType();
return new Download(fileName, contentType == null ? MediaType.APPLICATION_OCTET_STREAM_VALUE : contentType.toString(), response.getBody());
}
private DocumentRow requiredDocument(long id) {
DocumentRow value = mapper.document(id);
if (value == null || !"ACTIVE".equals(value.status())) throw notFound("客户报送资料不存在");
return value;
}
private DocumentRow requiredDocumentLocked(long id, Long tenantId) {
DocumentRow value = mapper.lockDocument(id);
if (value == null || !"ACTIVE".equals(value.status()) || (tenantId != null && !tenantId.equals(value.tenantId()))) {
throw notFound("客户报送资料不存在");
}
return value;
}
private String scopeJson(List<InboundRow> scope) {
try { return objectMapper.writeValueAsString(scope.stream().map(row -> Map.of("inboundId", row.inboundId(),
"orderId", row.orderId(), "inboundCode", row.inboundCode())).toList()); }
catch (JsonProcessingException ex) { throw new IllegalStateException(ex); }
}
private static FileView file(DocumentRow row) { return new FileView(row.fileId(),row.fileName(),row.fileHash(),row.contentType(),row.sizeBytes(),row.version(),row.uploadedAt()); }
private static FileView file(CompletionRow row) { return new FileView(row.fileId(),row.fileName(),row.fileHash(),row.contentType(),row.sizeBytes(),row.version(),row.uploadedAt()); }
private static long size(CustomsFileClient.Metadata value) { return value.sizeBytes() == null ? 0 : value.sizeBytes(); }
private static void validateFile(MultipartFile file) { if (file == null || file.isEmpty()) throw bad("请选择文件"); if (file.getSize() > MAX_FILE_BYTES) throw bad("单文件不能超过50MB"); }
private static String fileHash(MultipartFile file) { try (InputStream input=file.getInputStream()) { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(input.readAllBytes())); } catch(Exception ex){ throw bad("无法读取上传文件"); } }
private static String hash(Object... values) { try { MessageDigest digest=MessageDigest.getInstance("SHA-256"); for(Object value:values) digest.update((Objects.toString(value,"")+"\u001f").getBytes(StandardCharsets.UTF_8)); return HexFormat.of().formatHex(digest.digest()); } catch(Exception ex){ throw new IllegalStateException(ex); } }
private static void validateKey(String key) { if (key == null || key.isBlank() || key.length() > 128) throw bad("Idempotency-Key不能为空且长度不能超过128"); }
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static String merchantStatus(String containerStatus,int refund,int covered){if(COMPLETED.equals(containerStatus))return "CUSTOMS_COMPLETED";if(DECLARING.equals(containerStatus))return "DECLARING";return refund>0&&refund==covered?"DOCUMENT_UPLOADED":"WAITING_UPLOAD";}
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); }
public record CustomsCompletedEvent(long containerBookingId) {}
}
@@ -0,0 +1,38 @@
package com.baiyue.firstleg.customs.container.service;
import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper;
import com.baiyue.firstleg.customs.container.service.ContainerCustomsService.CustomsCompletedEvent;
import com.baiyue.firstleg.outbound.dispatch.service.OutboundDispatchAutoConfirmService;
import jakarta.annotation.Resource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
@Component
public class CustomsAutoDispatchCoordinator {
private static final Logger log=LoggerFactory.getLogger(CustomsAutoDispatchCoordinator.class);
private static final int BATCH_SIZE=50;
@Resource private ContainerCustomsMapper mapper;
@Resource private OutboundDispatchAutoConfirmService outbound;
@TransactionalEventListener(phase=TransactionPhase.AFTER_COMMIT)
public void afterCustomsCompleted(CustomsCompletedEvent event) {
tryConfirm(event.containerBookingId());
}
@Scheduled(fixedDelayString="${baiyue.customs.auto-dispatch.fixed-delay-ms:60000}")
public void retryPending() {
for(Long containerBookingId:mapper.pendingAutoDispatch(BATCH_SIZE)) tryConfirm(containerBookingId);
}
private void tryConfirm(long containerBookingId) {
try {
outbound.confirmIfReady(containerBookingId);
} catch (RuntimeException ex) {
log.warn("整柜报关完成后的自动离仓暂未收敛,稍后重试: containerBookingId={}",containerBookingId,ex);
}
}
}
@@ -0,0 +1,73 @@
package com.baiyue.firstleg.customs.container.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.export.ExportContracts.*;
import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper;
import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper.BuyExportRow;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*;
@Service
public class LoadingListBuyDeclarationExportService {
public static final String TYPE="LOADING_LIST_BUY_DECLARATION_ITEMS";
private static final int MAX_BATCH=500;
private static final List<ExportColumn> COLUMNS=List.of(
c("customerName","客户名称",ExportColumnType.STRING,24),c("orderNo","订单号",ExportColumnType.STRING,20),
c("inboundCode","入仓码",ExportColumnType.STRING,22),c("sku","SKU",ExportColumnType.STRING,18),
c("chineseName","中文品名",ExportColumnType.STRING,24),c("englishName","英文品名",ExportColumnType.STRING,24),
c("cartonCount","箱数",ExportColumnType.INTEGER,12),c("productQuantity","产品数量",ExportColumnType.INTEGER,14),
c("grossWeight","毛重",ExportColumnType.DECIMAL,14),c("netWeight","净重",ExportColumnType.DECIMAL,14),
c("volume","体积",ExportColumnType.DECIMAL,14),c("mark","唛头",ExportColumnType.STRING,20),
c("customerRemark","客户备注",ExportColumnType.STRING,30));
private static final String SCHEMA_HASH=schemaHash();
@Resource private ContainerCustomsMapper mapper;
@Value("${baiyue.security.internal-service-token}") private String token;
private byte[] signingKey;
@PostConstruct public void init(){signingKey=token.getBytes(StandardCharsets.UTF_8);}
public ExportBatchResponse batch(ExportBatchRequest request){
if(request==null||!"0".equals(request.tenantId())||!"PLATFORM".equals(request.appCode())) throw invalid("仅支持平台导出");
if(request.limit()<1||request.limit()>MAX_BATCH) throw invalid("limit必须为1到500");
long planId=positive(parameter(request,"loadingPlanId"),"loadingPlanId");
var scope=mapper.planExportScope(planId);
if(scope==null||!"COMPLETED".equals(scope.planStatus())) throw invalid("只能导出已完成装柜的装柜清单");
boolean first=request.snapshotToken()==null||request.snapshotToken().isBlank();
long maximumId=first?mapper.maximumBuyItemId(planId):verify(request,planId);
String snapshot=first?sign(request,planId,maximumId):request.snapshotToken();
long cursor=request.cursor()==null||request.cursor().isBlank()?0:positiveOrZero(request.cursor(),"cursor");
List<BuyExportRow> source=mapper.buyRows(planId,cursor,maximumId,request.limit()+1);
boolean more=source.size()>request.limit();
List<BuyExportRow> page=more?source.subList(0,request.limit()):source;
String next=page.isEmpty()?String.valueOf(cursor):String.valueOf(page.get(page.size()-1).itemId());
return new ExportBatchResponse(1,SCHEMA_HASH,snapshot,first?COLUMNS:List.of(),page.stream().map(this::row).toList(),
next,more,first?mapper.countBuyRows(planId,maximumId):null);
}
private Map<String,Object> row(BuyExportRow value){Map<String,Object> row=new LinkedHashMap<>();
row.put("customerName",value.customerName());row.put("orderNo",value.orderNo());row.put("inboundCode",value.inboundCode());
row.put("sku",value.skuCode());row.put("chineseName",value.productNameZh());row.put("englishName",value.productNameEn());
row.put("cartonCount",value.packageCount());row.put("productQuantity",value.productQuantity());row.put("grossWeight",value.grossWeightKg());
row.put("netWeight",value.netWeightKg());row.put("volume",value.volumeCbm());row.put("mark",value.shippingMark());
row.put("customerRemark",value.customerRemark());return row;}
private String sign(ExportBatchRequest r,long p,long m){return "v1."+m+"."+Base64.getUrlEncoder().withoutPadding().encodeToString(hmac(subject(r,p,m)));}
private long verify(ExportBatchRequest r,long p){String[] a=r.snapshotToken().split("\\.",-1);if(a.length!=3||!"v1".equals(a[0]))throw invalid("snapshotToken无效");
try{long m=Long.parseLong(a[1]);if(!MessageDigest.isEqual(hmac(subject(r,p,m)),Base64.getUrlDecoder().decode(a[2])))throw invalid("snapshotToken不匹配");return m;}
catch(IllegalArgumentException ex){throw invalid("snapshotToken无效");}}
private String subject(ExportBatchRequest r,long p,long m){return String.join("\n",TYPE,r.taskId(),r.tenantId(),r.appCode(),r.requestedBy(),String.valueOf(r.dispatchVersion()),String.valueOf(p),String.valueOf(m));}
private byte[] hmac(String s){try{Mac mac=Mac.getInstance("HmacSHA256");mac.init(new SecretKeySpec(signingKey,"HmacSHA256"));return mac.doFinal(s.getBytes(StandardCharsets.UTF_8));}catch(Exception ex){throw new IllegalStateException(ex);}}
private static Object parameter(ExportBatchRequest r,String k){if(r.parameters()==null||!r.parameters().keySet().equals(Set.of("loadingPlanId")))throw invalid("导出参数必须且只能包含loadingPlanId");return r.parameters().get(k);}
private static long positive(Object v,String f){long n=v instanceof Number x?x.longValue():positiveOrZero(Objects.toString(v,""),f);if(n<=0)throw invalid(f+"必须为正整数");return n;}
private static long positiveOrZero(String v,String f){try{long n=Long.parseLong(v);if(n<0)throw invalid(f+"不能为负数");return n;}catch(NumberFormatException ex){throw invalid(f+"必须为整数");}}
private static ExportColumn c(String k,String t,ExportColumnType y,int w){return new ExportColumn(k,t,y,w);}
private static String schemaHash(){try{MessageDigest d=MessageDigest.getInstance("SHA-256");for(ExportColumn c:COLUMNS)d.update((c.key()+"|"+c.title()+"|"+c.type()+"|"+c.width()+"\n").getBytes(StandardCharsets.UTF_8));return "sha256:"+HexFormat.of().formatHex(d.digest());}catch(Exception ex){throw new ExceptionInInitializerError(ex);}}
private static BizException invalid(String m){return new BizException(ResultCode.VALIDATION_FAILED,m);}
}
@@ -1,88 +0,0 @@
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("\"", ""); }
}
@@ -1,161 +0,0 @@
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("\"", ""); }
}
@@ -1,80 +0,0 @@
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(@NotNull @jakarta.validation.constraints.Positive Long customsAgentId,
@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) {}
}
@@ -1,28 +0,0 @@
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;
}
@@ -1,21 +0,0 @@
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;
}
@@ -1,27 +0,0 @@
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;
}
@@ -1,22 +0,0 @@
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;
}
@@ -1,20 +0,0 @@
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;
}
@@ -1,24 +0,0 @@
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;
}
@@ -1,23 +0,0 @@
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 Long customsAgentId;
private String customsAgentName;
private String externalReference;
private String fileSnapshotJson;
private String status;
private String note;
private Long submittedBy;
private String submittedByName;
private LocalDateTime submittedAt;
}
@@ -1,23 +0,0 @@
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;
}
@@ -1,11 +0,0 @@
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;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper public interface CustomsCaseMapper extends BaseMapper<CustomsCaseEntity> {
@Select("SELECT COUNT(*) FROM fl_container_document WHERE container_booking_id=#{containerBookingId} "
+ "AND document_type='CUSTOMS_DECLARATION' AND completed_at IS NOT NULL")
int completedLoadingDeclaration(@Param("containerBookingId") long containerBookingId);
}
@@ -1,5 +0,0 @@
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> {}
@@ -1,5 +0,0 @@
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> {}
@@ -1,5 +0,0 @@
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> {}
@@ -1,5 +0,0 @@
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> {}
@@ -1,5 +0,0 @@
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> {}
@@ -1,5 +0,0 @@
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> {}
@@ -1,5 +0,0 @@
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> {}
@@ -1,191 +0,0 @@
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 loadingDeclarationCompleted(long containerBookingId) {
return cases.completedLoadingDeclaration(containerBookingId) == 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()));
}
}
@@ -1,6 +1,6 @@
package com.baiyue.firstleg.customs.service;
import com.baiyue.firstleg.customs.repository.CustomsRepository;
import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper;
import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.GateItem;
import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor;
import jakarta.annotation.Resource;
@@ -8,7 +8,7 @@ import org.springframework.stereotype.Component;
@Component
public class CustomsDispatchGateContributor implements OutboundDispatchGateContributor {
@Resource private CustomsRepository repository;
@Resource private ContainerCustomsMapper mapper;
@Override public String code() { return "CUSTOMS_CLEARANCE"; }
@@ -17,8 +17,9 @@ public class CustomsDispatchGateContributor implements OutboundDispatchGateContr
if (context.tenantId() <= 0 || context.customerId() <= 0) {
return gate(false, "INVALID_CUSTOMER_SCOPE");
}
boolean completed = repository.loadingDeclarationCompleted(context.containerBookingId());
return gate(completed, completed ? "COMPLETED" : "MISSING");
var completed = mapper.containerByBooking(context.containerBookingId());
boolean passed = completed != null && "COMPLETED".equals(completed.status());
return gate(passed, passed ? "COMPLETED" : completed == null ? "MISSING" : completed.status());
}
private static GateItem gate(boolean passed, String actual) {
@@ -1,482 +0,0 @@
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.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
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;
@Resource private WarehouseSupplierSnapshotService suppliers;
@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());
var customsAgent = suppliers.customs(request.customsAgentId(), request.customsAgentName());
batch.setCustomsAgentId(customsAgent == null ? null : customsAgent.supplierId());
batch.setCustomsAgentName(customsAgent == null ? null : customsAgent.name());
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); }
}
@@ -23,9 +23,7 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
COALESCE((SELECT CASE WHEN d.completed_at IS NOT NULL THEN 'COMPLETED' ELSE 'UPLOADED' END
FROM fl_container_document d WHERE d.container_booking_id=b.id
AND d.document_type='DESTINATION_CLEARANCE' LIMIT 1),'MISSING') AS destination_clearance_document_status,
COALESCE((SELECT CASE WHEN d.completed_at IS NOT NULL THEN 'COMPLETED' ELSE 'UPLOADED' END
FROM fl_container_document d WHERE d.container_booking_id=b.id
AND d.document_type='CUSTOMS_DECLARATION' LIMIT 1),'MISSING') AS customs_declaration_document_status,
COALESCE((SELECT c.status FROM fl_customs_container c WHERE c.container_booking_id=b.id LIMIT 1),'MISSING') AS customs_declaration_document_status,
COALESCE((SELECT 'UPLOADED' FROM fl_container_document d WHERE d.container_booking_id=b.id
AND d.document_type='DELIVERY_ORDER' LIMIT 1),'MISSING') AS delivery_order_document_status,
COALESCE((SELECT 'UPLOADED' FROM fl_container_document d WHERE d.container_booking_id=b.id
@@ -85,9 +83,7 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
COALESCE((SELECT CASE WHEN d.completed_at IS NOT NULL THEN 'COMPLETED' ELSE 'UPLOADED' END
FROM fl_container_document d WHERE d.container_booking_id=b.id
AND d.document_type='DESTINATION_CLEARANCE' LIMIT 1),'MISSING') AS destination_clearance_document_status,
COALESCE((SELECT CASE WHEN d.completed_at IS NOT NULL THEN 'COMPLETED' ELSE 'UPLOADED' END
FROM fl_container_document d WHERE d.container_booking_id=b.id
AND d.document_type='CUSTOMS_DECLARATION' LIMIT 1),'MISSING') AS customs_declaration_document_status,
COALESCE((SELECT c.status FROM fl_customs_container c WHERE c.container_booking_id=b.id LIMIT 1),'MISSING') AS customs_declaration_document_status,
COALESCE((SELECT 'UPLOADED' FROM fl_container_document d WHERE d.container_booking_id=b.id
AND d.document_type='DELIVERY_ORDER' LIMIT 1),'MISSING') AS delivery_order_document_status,
COALESCE((SELECT 'UPLOADED' FROM fl_container_document d WHERE d.container_booking_id=b.id
@@ -4,7 +4,7 @@ import com.baiyue.firstleg.entity.ContainerTransportHistoryEntity;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper public interface ContainerOverviewMapper {
@Select("SELECT * FROM fl_container_document WHERE container_booking_id=#{id} AND document_type IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION','DELIVERY_ORDER','PICKUP_ORDER') ORDER BY document_type") List<ContainerDocumentEntity> documents(@Param("id") long id);
@Select("SELECT * FROM fl_container_document WHERE container_booking_id=#{id} AND document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER','PICKUP_ORDER') ORDER BY document_type") List<ContainerDocumentEntity> documents(@Param("id") long id);
@Select("SELECT * FROM fl_container_transport_history WHERE container_booking_id=#{id} ORDER BY created_at,id") List<ContainerTransportHistoryEntity> transport(@Param("id") long id);
@Select("SELECT id,action,reason,operator_id operatorId,operator_name operatorName,occurred_at occurredAt,request_hash requestHash FROM fl_container_exception_history WHERE container_booking_id=#{id} ORDER BY occurred_at,id") List<ExceptionRow> exceptions(@Param("id") long id);
@Insert("INSERT INTO fl_container_exception_history(id,container_booking_id,action,reason,operator_id,operator_name,operation_id,request_hash,occurred_at) VALUES(#{id},#{bookingId},#{action},#{reason},#{operatorId},#{operatorName},#{operationId},#{requestHash},CURRENT_TIMESTAMP(3))") int insertException(@Param("id") long id,@Param("bookingId") long bookingId,@Param("action") String action,@Param("reason") String reason,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName,@Param("operationId") String operationId,@Param("requestHash") String requestHash);
@@ -103,10 +103,9 @@ public interface OutboundDispatchReadMapper {
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 'CUSTOMS',h.action,h.from_status,h.to_status,h.operator_id,h.operator_name,h.note,h.created_at
FROM fl_customs_history h JOIN fl_customs_case customs_case ON customs_case.id=h.customs_case_id
WHERE customs_case.tenant_id=#{tenantId} AND customs_case.order_id=#{orderId}
AND customs_case.inbound_id=#{inboundId} AND customs_case.container_booking_id=#{containerBookingId}
SELECT 'CUSTOMS',h.action,h.from_status,h.to_status,h.operator_id,h.operator_name,h.remark,h.created_at
FROM fl_customs_container_history h JOIN fl_customs_container customs_container ON customs_container.id=h.customs_container_id
WHERE customs_container.container_booking_id=#{containerBookingId}
UNION ALL
SELECT 'SAILING_SCHEDULE',h.action,NULL,NULL,h.operator_id,h.operator_name,h.reason,h.created_at
FROM fl_port_schedule_history h JOIN fl_port_schedule port_schedule ON port_schedule.id=h.port_schedule_id
@@ -0,0 +1,30 @@
package com.baiyue.firstleg.outbound.dispatch.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.outbound.dispatch.domain.OutboundDispatchStatus;
import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.ActionRequest;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/** Real proxy-owned transaction boundary for repeated system convergence after customs completion. */
@Service
public class OutboundDispatchAutoConfirmService {
@Resource private OutboundDispatchService dispatch;
@Transactional
public boolean confirmIfReady(long containerBookingId) {
var current=dispatch.detail(containerBookingId);
if(OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(current.aggregate().dispatchStatus()))return true;
if(!current.aggregate().gate().passed())return false;
try{
dispatch.confirm(containerBookingId,0L,"SYSTEM","CUSTOMS-AUTO-DISPATCH-"+containerBookingId,
new ActionRequest(current.aggregate().version(),"整柜报关完成且全部离仓门禁满足,系统自动确认离仓"));
return true;
}catch(BizException ex){
var after=dispatch.detail(containerBookingId);
if(OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(after.aggregate().dispatchStatus()))return true;
throw ex;
}
}
}
@@ -52,11 +52,9 @@ import java.util.Set;
public class LoadingListService {
private static final Set<String> PRELOAD_STATUSES = Set.of("EDITING", "LOADING");
public static final String DESTINATION_CLEARANCE = "DESTINATION_CLEARANCE";
public static final String CUSTOMS_DECLARATION = "CUSTOMS_DECLARATION";
public static final String DELIVERY_ORDER = "DELIVERY_ORDER";
public static final String PICKUP_ORDER = "PICKUP_ORDER";
private static final String CLEARANCE_FILE_TYPE = "CONTAINER_CLEARANCE_DOCUMENT";
private static final String DECLARATION_FILE_TYPE = "LOADING_CUSTOMS_DECLARATION";
private static final String DO_FILE_TYPE = "CONTAINER_DELIVERY_ORDER";
private static final String PU_FILE_TYPE = "CONTAINER_PICKUP_ORDER";
@Resource
@@ -198,89 +196,6 @@ public class LoadingListService {
operatorId, operatorName, key, request);
}
public BusinessDocument loadingDeclaration(long planId, long warehouseId) {
AggregateRow plan = requirePlan(planId);
requireWarehouse(plan, warehouseId);
requireCompletedPlan(plan);
return document(repository.document(plan.getContainerBookingId(), CUSTOMS_DECLARATION));
}
public DocumentDownload downloadLoadingDeclaration(long planId, long warehouseId, long fileId) {
AggregateRow plan = requirePlan(planId);
requireWarehouse(plan, warehouseId);
requireCompletedPlan(plan);
ContainerDocumentEntity document = repository.document(plan.getContainerBookingId(), CUSTOMS_DECLARATION);
if (document == null || !Long.valueOf(fileId).equals(document.getFileId())) {
throw notFound("出口报关附件不存在或不属于当前装柜计划");
}
String businessId = Long.toString(planId);
try {
var metadata = documentFiles.metadata(internalToken, 0, DECLARATION_FILE_TYPE, businessId, fileId);
if (metadata == null || !metadata.successful() || metadata.data() == null
|| !Long.valueOf(fileId).equals(metadata.data().fileId())
|| !Long.valueOf(0).equals(metadata.data().tenantId())
|| !DECLARATION_FILE_TYPE.equals(metadata.data().businessType())
|| !businessId.equals(metadata.data().businessId())) {
throw notFound("出口报关附件不存在或归属不匹配");
}
var response = documentFiles.download(internalToken, 0, DECLARATION_FILE_TYPE, businessId, fileId);
if (response == null || !response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
throw notFound("出口报关附件不存在");
}
MediaType contentType;
try {
contentType = MediaType.parseMediaType(metadata.data().contentType());
} catch (RuntimeException exception) {
contentType = MediaType.APPLICATION_OCTET_STREAM;
}
String fileName = StringUtils.hasText(document.getFileName())
? document.getFileName() : metadata.data().fileName();
return new DocumentDownload(fileName, contentType, response.getBody());
} catch (FeignException.NotFound exception) {
throw notFound("出口报关附件不存在或归属不匹配");
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用");
}
}
@Transactional
@BusinessAudit(event = "LOADING_CUSTOMS_DOCUMENT_ATTACH", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "上传出口报关文档")
public BusinessDocument attachLoadingDeclaration(long planId, long warehouseId, long operatorId,
String operatorName, String key,
DocumentAttachRequest request) {
AggregateRow plan = requirePlan(planId);
requireWarehouse(plan, warehouseId);
requireCompletedPlan(plan);
return attachDocument(plan, CUSTOMS_DECLARATION, DECLARATION_FILE_TYPE,
planId, Long.toString(planId), operatorId, operatorName, key, request);
}
@Transactional
@BusinessAudit(event = "LOADING_CUSTOMS_DOCUMENT_UPLOAD", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "直接上传出口报关文档")
public BusinessDocument uploadLoadingDeclaration(long planId, long warehouseId, long operatorId,
String operatorName, String key, int expectedVersion,
String operationReason, MultipartFile file) {
AggregateRow plan = requirePlan(planId);
requireWarehouse(plan, warehouseId);
requireCompletedPlan(plan);
return uploadDocument(plan, CUSTOMS_DECLARATION, DECLARATION_FILE_TYPE, planId,
Long.toString(planId), operatorId, operatorName, key, expectedVersion, operationReason, file);
}
@Transactional
@BusinessAudit(event = "LOADING_CUSTOMS_COMPLETE", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "确认出口报关完成")
public BusinessDocument completeLoadingDeclaration(long planId, long warehouseId, long operatorId,
String operatorName, String key,
DocumentCompleteRequest request) {
AggregateRow plan = requirePlan(planId);
requireWarehouse(plan, warehouseId);
requireCompletedPlan(plan);
return completeDocument(plan, CUSTOMS_DECLARATION, planId, operatorId, operatorName, key, request);
}
private BusinessDocument attachDocument(AggregateRow plan, String documentType, String businessType,
long identityId, String businessId, long operatorId, String operatorName,
String key, DocumentAttachRequest request) {
@@ -450,7 +365,7 @@ public class LoadingListService {
}
private static void requireCompletedPlan(AggregateRow plan) {
if (!"COMPLETED".equals(plan.getPlanStatus())) throw conflict("仅完成装柜清单可办理报关");
if (!"COMPLETED".equals(plan.getPlanStatus())) throw conflict("仅完成装柜清单可维护后续单证");
}
private ContainerDocumentEntity requireDocumentEntity(long bookingId, String documentType) {