feat(firstleg): split customs receipt and final document flow
This commit is contained in:
+9
-2
@@ -84,13 +84,20 @@ public class MerchantContainerCustomsController {
|
||||
return response(service.downloadSource(FirstlegScopeGuard.merchant(user),documentId,false));
|
||||
}
|
||||
|
||||
@GetMapping("/documents/{documentId}/completion/download")
|
||||
@GetMapping("/documents/{documentId}/receipt/download")
|
||||
@PreAuthorize("hasAuthority('merchant:order:view')")
|
||||
public ResponseEntity<byte[]> downloadCompletion(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
public ResponseEntity<byte[]> downloadReceipt(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long documentId) {
|
||||
return response(service.downloadCompletion(FirstlegScopeGuard.merchant(user),documentId,false));
|
||||
}
|
||||
|
||||
@GetMapping("/documents/{documentId}/final/download")
|
||||
@PreAuthorize("hasAuthority('merchant:order:view')")
|
||||
public ResponseEntity<byte[]> downloadFinal(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long documentId) {
|
||||
return response(service.downloadFinal(FirstlegScopeGuard.merchant(user),documentId,false));
|
||||
}
|
||||
|
||||
@PostMapping("/documents/{documentId}/receipt/confirm")
|
||||
@PreAuthorize("hasAuthority('merchant:order:edit')")
|
||||
public ApiResponse<ContainerView> confirmReceipt(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
|
||||
+32
-1
@@ -44,7 +44,18 @@ public class PlatformContainerCustomsController {
|
||||
return ApiResponse.success(service.start(planId,user.userId(),user.username(),key,request));
|
||||
}
|
||||
|
||||
@PostMapping(value="/documents/{documentId}/completion/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PostMapping("/{planId}/submit-to-broker")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
||||
@BusinessAudit(event="CUSTOMS_SUBMIT_TO_BROKER",resourceType="CUSTOMS_CONTAINER")
|
||||
public ApiResponse<ContainerView> submitToBroker(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long planId,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody ActionRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.submitToBroker(planId,user.userId(),user.username(),key,request));
|
||||
}
|
||||
|
||||
@PostMapping(value="/documents/{documentId}/receipt/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,
|
||||
@@ -57,6 +68,18 @@ public class PlatformContainerCustomsController {
|
||||
expectedVersion,file));
|
||||
}
|
||||
|
||||
@PostMapping(value="/documents/{documentId}/final/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
||||
@BusinessAudit(event="CUSTOMS_FINAL_DOCUMENT_UPLOAD",resourceType="CUSTOMS_CUSTOMER_DOCUMENT")
|
||||
public ApiResponse<DocumentView> uploadFinal(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long documentId,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@RequestParam @Min(0) int expectedVersion,
|
||||
@RequestPart("file") MultipartFile file) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.uploadFinalDocument(documentId,user.userId(),user.username(),key,expectedVersion,file));
|
||||
}
|
||||
|
||||
@PostMapping("/{planId}/complete-all-buy")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
||||
@BusinessAudit(event="ALL_BUY_CONTAINER_CUSTOMS_COMPLETE",resourceType="CUSTOMS_CONTAINER")
|
||||
@@ -84,6 +107,14 @@ public class PlatformContainerCustomsController {
|
||||
return response(service.downloadCompletion(0,documentId,true));
|
||||
}
|
||||
|
||||
@GetMapping("/documents/{documentId}/final/download")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:list')")
|
||||
public ResponseEntity<byte[]> downloadFinal(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long documentId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return response(service.downloadFinal(0,documentId,true));
|
||||
}
|
||||
|
||||
private static ResponseEntity<byte[]> response(Download value) {
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
|
||||
.contentType(MediaType.parseMediaType(value.contentType()))
|
||||
|
||||
+2
-2
@@ -19,8 +19,8 @@ public final class ContainerCustomsDtos {
|
||||
|
||||
public record DocumentView(long documentId, long tenantId, FileView source,
|
||||
List<Long> inboundIds, List<String> inboundCodes,
|
||||
boolean frozen, FileView completion, String receiptStatus,
|
||||
String receiptRejectReason) {}
|
||||
boolean frozen, FileView receipt, String receiptStatus,
|
||||
String receiptRejectReason, FileView finalDocument) {}
|
||||
|
||||
public record ContainerView(long aggregateId, long loadingPlanId, long containerBookingId,
|
||||
String planNo, String cabinetNo, String containerNo, String status, boolean hasActualCargo,
|
||||
|
||||
+69
@@ -20,6 +20,11 @@ public interface ContainerCustomsMapper {
|
||||
r.uploaded_by_name uploadedByName,r.uploaded_at uploadedAt,r.version version,
|
||||
r.customer_confirm_status customerConfirmStatus,r.customer_reject_reason customerRejectReason
|
||||
""";
|
||||
String FINAL_COLUMNS = """
|
||||
f.id id,f.source_document_id sourceDocumentId,f.file_id fileId,f.file_name fileName,
|
||||
f.file_hash fileHash,f.content_type contentType,f.size_bytes sizeBytes,f.uploaded_by uploadedBy,
|
||||
f.uploaded_by_name uploadedByName,f.uploaded_at uploadedAt,f.version version
|
||||
""";
|
||||
|
||||
@Select("""
|
||||
SELECT DISTINCT p.id loadingPlanId,p.plan_no planNo,p.container_booking_id containerBookingId,
|
||||
@@ -102,6 +107,18 @@ public interface ContainerCustomsMapper {
|
||||
int start(@Param("id") long id,@Param("version") int version,@Param("operatorId") long operatorId,
|
||||
@Param("operatorName") String operatorName);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_customs_container SET broker_submitted_at=CURRENT_TIMESTAMP(3),
|
||||
broker_submitted_by=#{operatorId},broker_submitted_by_name=#{operatorName},
|
||||
updated_at=CURRENT_TIMESTAMP(3),version=version+1
|
||||
WHERE id=#{id} AND version=#{version} AND status='DECLARING' AND broker_submitted_at IS NULL
|
||||
""")
|
||||
int submitToBroker(@Param("id") long id,@Param("version") int version,@Param("operatorId") long operatorId,
|
||||
@Param("operatorName") String operatorName);
|
||||
|
||||
@Select("SELECT broker_submitted_at FROM fl_customs_container WHERE id=#{id}")
|
||||
LocalDateTime brokerSubmittedAt(@Param("id") long id);
|
||||
|
||||
@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
|
||||
@@ -268,6 +285,22 @@ public interface ContainerCustomsMapper {
|
||||
@Param("status") String status,@Param("reason") String reason,
|
||||
@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
|
||||
|
||||
@Select("""
|
||||
SELECT COUNT(*) FROM fl_customs_customer_document d
|
||||
JOIN fl_customs_completion_document r ON r.source_document_id=d.id
|
||||
WHERE d.customs_container_id=#{containerId} AND d.status='ACTIVE'
|
||||
AND r.customer_confirm_status='CONFIRMED'
|
||||
""")
|
||||
int confirmedReceiptCount(@Param("containerId") long containerId);
|
||||
|
||||
@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 (r.id IS NULL OR r.customer_confirm_status<>'CONFIRMED')
|
||||
""")
|
||||
int unconfirmedReceiptCount(@Param("containerId") long containerId);
|
||||
|
||||
@Select("""
|
||||
SELECT COUNT(*) FROM fl_customs_customer_document d
|
||||
JOIN fl_customs_completion_document r ON r.source_document_id=d.id
|
||||
@@ -288,6 +321,40 @@ public interface ContainerCustomsMapper {
|
||||
@Param("contentType") String contentType,@Param("sizeBytes") long sizeBytes,
|
||||
@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
|
||||
|
||||
@Select("SELECT " + FINAL_COLUMNS + " FROM fl_customs_final_document f WHERE f.source_document_id=#{documentId}")
|
||||
FinalRow finalDocument(@Param("documentId") long documentId);
|
||||
|
||||
@Select("SELECT " + FINAL_COLUMNS + " FROM fl_customs_final_document f WHERE f.source_document_id=#{documentId} FOR UPDATE")
|
||||
FinalRow lockFinalDocument(@Param("documentId") long documentId);
|
||||
|
||||
@Insert("""
|
||||
INSERT INTO fl_customs_final_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 insertFinal(@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_final_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),version=version+1,updated_at=CURRENT_TIMESTAMP(3)
|
||||
WHERE id=#{id} AND version=#{version}
|
||||
""")
|
||||
int replaceFinal(@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);
|
||||
|
||||
@Select("""
|
||||
SELECT COUNT(*) FROM fl_customs_customer_document d
|
||||
LEFT JOIN fl_customs_final_document f ON f.source_document_id=d.id
|
||||
WHERE d.customs_container_id=#{containerId} AND d.status='ACTIVE' AND f.id IS NULL
|
||||
""")
|
||||
int missingFinalCount(@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)
|
||||
@@ -382,6 +449,8 @@ public interface ContainerCustomsMapper {
|
||||
record CompletionRow(Long id,Long sourceDocumentId,Long fileId,String fileName,String fileHash,String contentType,
|
||||
Long sizeBytes,Long uploadedBy,String uploadedByName,LocalDateTime uploadedAt,Integer version,
|
||||
String customerConfirmStatus,String customerRejectReason) {}
|
||||
record FinalRow(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,
|
||||
|
||||
+75
-7
@@ -33,6 +33,7 @@ public class ContainerCustomsService {
|
||||
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 RECEIPT_FILE_TYPE = "LOADING_LIST_CUSTOMS_RECEIPT";
|
||||
private static final String RESULT_FILE_TYPE = "LOADING_LIST_CUSTOMS_RESULT";
|
||||
private static final long MAX_FILE_BYTES = 50L * 1024 * 1024;
|
||||
|
||||
@@ -172,6 +173,24 @@ public class ContainerCustomsService {
|
||||
return platformView(planId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ContainerView submitToBroker(long planId, long operatorId, String operatorName, String key, ActionRequest request) {
|
||||
String action = "SUBMIT_CUSTOMS_TO_BROKER_" + 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();
|
||||
if (mapper.unconfirmedReceiptCount(container.id()) != 0) throw conflict("仍有报关行回执未完成客户确认");
|
||||
if (mapper.brokerSubmittedAt(container.id()) != null) throw conflict("当前柜已发送给报关行执行");
|
||||
if (mapper.submitToBroker(container.id(), container.version(), operatorId, operatorName) != 1) throw concurrent();
|
||||
history(container, "SUBMIT_CUSTOMS_TO_BROKER", container.status(), container.status(), request.remark(), operatorId, operatorName);
|
||||
completeCommand(operatorId, action, key, container.id());
|
||||
return platformView(planId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DocumentView uploadCompletion(long documentId, long operatorId, String operatorName, String key,
|
||||
int expectedVersion, MultipartFile file) {
|
||||
@@ -187,7 +206,7 @@ public class ContainerCustomsService {
|
||||
throw conflict("当前客户资料不在报关中冻结版本");
|
||||
}
|
||||
CompletionRow completion = mapper.lockCompletion(documentId);
|
||||
CustomsFileClient.Metadata metadata = upload(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file);
|
||||
CustomsFileClient.Metadata metadata = upload(source.tenantId(), RECEIPT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file);
|
||||
long completionId;
|
||||
int version;
|
||||
if (completion == null) {
|
||||
@@ -227,12 +246,47 @@ public class ContainerCustomsService {
|
||||
if (mapper.confirmReceipt(documentId, tenantId, confirm ? "CONFIRMED" : "REJECTED",
|
||||
confirm ? null : trim(reason), operatorId, operatorName) != 1) throw conflict("回执状态已变化,请刷新后重试");
|
||||
if (confirm && mapper.pendingReceiptConfirmationCount(container.id()) == 0) {
|
||||
completeContainer(container, operatorId, operatorName, "客户已确认全部报关行回执");
|
||||
history(container, "ALL_CUSTOMS_RECEIPTS_CONFIRMED", container.status(), container.status(), "客户已确认全部报关行回执,待发送给报关行执行", operatorId, operatorName);
|
||||
}
|
||||
completeCommand(operatorId, action, key, documentId);
|
||||
return merchantViewForDocument(tenantId, source);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DocumentView uploadFinalDocument(long documentId, long operatorId, String operatorName, String key,
|
||||
int expectedVersion, MultipartFile file) {
|
||||
validateFile(file);
|
||||
DocumentRow readable = requiredDocument(documentId);
|
||||
String action = "UPLOAD_FINAL_CUSTOMS_DOCUMENT_" + documentId;
|
||||
String requestHash = hash(action, expectedVersion, fileHash(file));
|
||||
CommandRow replay = claim(operatorId, action, key, requestHash);
|
||||
if (replay != null) return documentView(requiredDocument(documentId), false);
|
||||
ContainerRow container = mapper.lockContainerById(readable.customsContainerId());
|
||||
DocumentRow source = requiredDocumentLocked(documentId, null);
|
||||
if (container == null || !DECLARING.equals(container.status()) || source.frozenAt() == null) {
|
||||
throw conflict("当前客户资料不在报关中冻结版本");
|
||||
}
|
||||
if (mapper.brokerSubmittedAt(container.id()) == null) throw conflict("客户确认结果尚未发送给报关行");
|
||||
CompletionRow receipt = mapper.lockCompletion(documentId);
|
||||
if (receipt == null || !"CONFIRMED".equals(receipt.customerConfirmStatus())) throw conflict("该客户回执尚未确认");
|
||||
FinalRow existing = mapper.lockFinalDocument(documentId);
|
||||
CustomsFileClient.Metadata metadata = upload(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file);
|
||||
long finalId;
|
||||
int version;
|
||||
if (existing == null) {
|
||||
if (expectedVersion != 0) throw concurrent();
|
||||
finalId = IdWorker.getId(); version = 1;
|
||||
mapper.insertFinal(finalId, documentId, metadata.fileId(), metadata.fileName(), metadata.sha256(), metadata.contentType(), size(metadata), operatorId, operatorName);
|
||||
} else {
|
||||
finalId = existing.id(); version = existing.version() + 1;
|
||||
if (mapper.replaceFinal(existing.id(), expectedVersion, metadata.fileId(), metadata.fileName(), metadata.sha256(), metadata.contentType(), size(metadata), operatorId, operatorName) != 1) throw concurrent();
|
||||
}
|
||||
history(container, "UPLOAD_FINAL_CUSTOMS_DOCUMENT", container.status(), container.status(), "真实报关完税文件已上传", operatorId, operatorName);
|
||||
if (mapper.missingFinalCount(container.id()) == 0) completeContainer(container, operatorId, operatorName, "全部真实报关完税文件已上传");
|
||||
completeCommand(operatorId, action, key, finalId);
|
||||
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;
|
||||
@@ -261,12 +315,23 @@ public class ContainerCustomsService {
|
||||
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("报关完成文件不存在");
|
||||
if (!platform && (source.tenantId() != tenantId || container == null || !Set.of(DECLARING, COMPLETED).contains(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());
|
||||
if (completion == null) throw notFound("报关行回执不存在");
|
||||
return download(source.tenantId(), RECEIPT_FILE_TYPE, Long.toString(container.loadingPlanId()), completion.fileId(), completion.fileName());
|
||||
}
|
||||
|
||||
public Download downloadFinal(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("报关完税文件不存在");
|
||||
}
|
||||
FinalRow finalDocument = mapper.finalDocument(documentId);
|
||||
if (finalDocument == null) throw notFound("报关完税文件不存在");
|
||||
return download(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), finalDocument.fileId(), finalDocument.fileName());
|
||||
}
|
||||
|
||||
private ContainerRow ensure(long planId) {
|
||||
@@ -367,12 +432,14 @@ public class ContainerCustomsService {
|
||||
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());
|
||||
FinalRow finalDocument = mapper.finalDocument(source.id());
|
||||
ContainerRow container = mapper.containerById(source.customsContainerId());
|
||||
FileView result = completion == null ? 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,
|
||||
completion == null ? null : completion.customerConfirmStatus(),
|
||||
completion == null ? null : completion.customerRejectReason());
|
||||
completion == null ? null : completion.customerRejectReason(),
|
||||
finalDocument == null ? null : file(finalDocument));
|
||||
}
|
||||
|
||||
private void completeContainer(ContainerRow container, long operatorId, String operatorName, String remark) {
|
||||
@@ -444,6 +511,7 @@ public class ContainerCustomsService {
|
||||
|
||||
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 FileView file(FinalRow 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("无法读取上传文件"); } }
|
||||
|
||||
@@ -12,6 +12,9 @@ CREATE TABLE IF NOT EXISTS fl_customs_container (
|
||||
completed_by BIGINT UNSIGNED NULL COMMENT '完成报关操作人ID',
|
||||
completed_by_name VARCHAR(64) NULL COMMENT '完成报关操作人名称快照',
|
||||
completed_at DATETIME(3) NULL COMMENT '报关完成时间',
|
||||
broker_submitted_by BIGINT UNSIGNED NULL COMMENT '已将客户确认结果发送给报关行的操作人ID',
|
||||
broker_submitted_by_name VARCHAR(64) NULL COMMENT '发送报关行操作人名称快照',
|
||||
broker_submitted_at DATETIME(3) NULL COMMENT '发送报关行执行时间',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '聚合创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '聚合更新时间',
|
||||
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
@@ -93,10 +96,10 @@ CREATE TABLE IF NOT EXISTS fl_customs_frozen_document_inbound (
|
||||
) ENGINE=InnoDB COMMENT='开始报关时冻结的客户资料覆盖范围';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_customs_completion_document (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '报关完成文件ID',
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '报关行回执文件ID',
|
||||
source_document_id BIGINT UNSIGNED NOT NULL COMMENT '一份冻结客户资料严格对应一份完成文件逻辑根',
|
||||
file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID',
|
||||
file_name VARCHAR(255) NOT NULL COMMENT '完成文件名称快照',
|
||||
file_name VARCHAR(255) NOT NULL COMMENT '回执文件名称快照',
|
||||
file_hash CHAR(64) NOT NULL COMMENT '完成文件SHA-256摘要',
|
||||
content_type VARCHAR(128) NULL COMMENT '完成文件媒体类型',
|
||||
size_bytes BIGINT UNSIGNED NOT NULL COMMENT '完成文件大小(字节)',
|
||||
@@ -114,7 +117,7 @@ CREATE TABLE IF NOT EXISTS fl_customs_completion_document (
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_customs_completion_source(source_document_id),
|
||||
CONSTRAINT fk_customs_completion_source FOREIGN KEY(source_document_id) REFERENCES fl_customs_customer_document(id)
|
||||
) ENGINE=InnoDB COMMENT='报关完成文件当前版本';
|
||||
) ENGINE=InnoDB COMMENT='报关行回执当前版本';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_customs_completion_document_version (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '完成文件版本记录ID',
|
||||
@@ -133,6 +136,25 @@ CREATE TABLE IF NOT EXISTS fl_customs_completion_document_version (
|
||||
CONSTRAINT fk_customs_completion_version FOREIGN KEY(completion_document_id) REFERENCES fl_customs_completion_document(id)
|
||||
) ENGINE=InnoDB COMMENT='报关完成文件不可变覆盖版本';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_customs_final_document (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '真实报关完税文件ID',
|
||||
source_document_id BIGINT UNSIGNED NOT NULL COMMENT '对应冻结客户资料ID',
|
||||
file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID',
|
||||
file_name VARCHAR(255) NOT NULL COMMENT '真实报关完税文件名称快照',
|
||||
file_hash CHAR(64) NOT NULL COMMENT '真实报关完税文件SHA-256摘要',
|
||||
content_type VARCHAR(128) NULL COMMENT '真实报关完税文件媒体类型',
|
||||
size_bytes BIGINT UNSIGNED NOT NULL COMMENT '真实报关完税文件大小(字节)',
|
||||
uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID',
|
||||
uploaded_by_name VARCHAR(64) NULL COMMENT '上传操作人名称快照',
|
||||
uploaded_at DATETIME(3) NOT NULL COMMENT '上传时间',
|
||||
version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '完税文件版本号',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_customs_final_source(source_document_id),
|
||||
CONSTRAINT fk_customs_final_source FOREIGN KEY(source_document_id) REFERENCES fl_customs_customer_document(id)
|
||||
) ENGINE=InnoDB COMMENT='真实报关完税文件当前版本';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_customs_container_history (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '报关历史记录ID',
|
||||
customs_container_id BIGINT UNSIGNED NOT NULL COMMENT '所属整柜报关聚合ID',
|
||||
|
||||
@@ -21,10 +21,17 @@ paths:
|
||||
parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {$ref: '#/components/requestBodies/ActionBody'}
|
||||
responses: {'200': {$ref: '#/components/responses/ContainerResponse'}}
|
||||
/api/platform/v1/customs/loading-plans/documents/{documentId}/completion/upload:
|
||||
/api/platform/v1/customs/loading-plans/{planId}/submit-to-broker:
|
||||
post:
|
||||
summary: 上传或覆盖冻结客户资料的一对一完成文件
|
||||
description: 报关行回执按冻结客户资料一对一保存;上传后等待对应客户确认或打回,不自动完成整柜报关。
|
||||
summary: 将客户确认回执发送给报关行执行
|
||||
x-permission: platform:firstleg:customs:manage
|
||||
parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {$ref: '#/components/requestBodies/ActionBody'}
|
||||
responses: {'200': {$ref: '#/components/responses/ContainerResponse'}}
|
||||
/api/platform/v1/customs/loading-plans/documents/{documentId}/receipt/upload:
|
||||
post:
|
||||
summary: 上传或覆盖冻结客户资料的一对一报关行回执
|
||||
description: 报关行回执按冻结客户资料一对一保存;上传后等待对应客户确认或打回。
|
||||
x-permission: platform:firstleg:customs:manage
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/DocumentId'}
|
||||
@@ -38,9 +45,22 @@ paths:
|
||||
x-permission: platform:firstleg:customs:list
|
||||
parameters: [{$ref: '#/components/parameters/DocumentId'}]
|
||||
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
|
||||
/api/platform/v1/customs/loading-plans/documents/{documentId}/completion/download:
|
||||
/api/platform/v1/customs/loading-plans/documents/{documentId}/receipt/download:
|
||||
get:
|
||||
summary: 平台下载报关完成文件
|
||||
summary: 平台下载报关行回执
|
||||
x-permission: platform:firstleg:customs:list
|
||||
parameters: [{$ref: '#/components/parameters/DocumentId'}]
|
||||
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
|
||||
/api/platform/v1/customs/loading-plans/documents/{documentId}/final/upload:
|
||||
post:
|
||||
summary: 上传真实报关完税文件
|
||||
x-permission: platform:firstleg:customs:manage
|
||||
parameters: [{$ref: '#/components/parameters/DocumentId'}, {$ref: '#/components/parameters/IdempotencyKey'}, {name: expectedVersion, in: query, required: true, schema: {type: integer, minimum: 0}}]
|
||||
requestBody: {$ref: '#/components/requestBodies/FileBody'}
|
||||
responses: {'200': {$ref: '#/components/responses/DocumentResponse'}}
|
||||
/api/platform/v1/customs/loading-plans/documents/{documentId}/final/download:
|
||||
get:
|
||||
summary: 平台下载真实报关完税文件
|
||||
x-permission: platform:firstleg:customs:list
|
||||
parameters: [{$ref: '#/components/parameters/DocumentId'}]
|
||||
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
|
||||
@@ -93,9 +113,15 @@ paths:
|
||||
x-permission: merchant:order:view
|
||||
parameters: [{$ref: '#/components/parameters/DocumentId'}]
|
||||
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
|
||||
/api/merchant/v1/customs/documents/{documentId}/completion/download:
|
||||
/api/merchant/v1/customs/documents/{documentId}/receipt/download:
|
||||
get:
|
||||
summary: 整柜完成后客户下载对应完成文件
|
||||
summary: 客户下载报关行回执
|
||||
x-permission: merchant:order:view
|
||||
parameters: [{$ref: '#/components/parameters/DocumentId'}]
|
||||
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
|
||||
/api/merchant/v1/customs/documents/{documentId}/final/download:
|
||||
get:
|
||||
summary: 整柜完成后客户下载真实报关完税文件
|
||||
x-permission: merchant:order:view
|
||||
parameters: [{$ref: '#/components/parameters/DocumentId'}]
|
||||
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
|
||||
@@ -142,7 +168,7 @@ components:
|
||||
ScopeRequest: {type: object, required: [inboundIds, expectedVersion], additionalProperties: false, properties: {inboundIds: {type: array, minItems: 1, maxItems: 200, items: {$ref: '#/components/schemas/LongId'}}, expectedVersion: {type: integer, minimum: 1}}}
|
||||
FileView: {type: object, required: [fileId, fileName, fileHash, sizeBytes, version, uploadedAt], properties: {fileId: {$ref: '#/components/schemas/LongId'}, fileName: {type: string}, fileHash: {type: string}, contentType: {type: string, nullable: true}, sizeBytes: {type: integer, format: int64}, version: {type: integer}, uploadedAt: {type: string, format: date-time}}}
|
||||
InboundView: {type: object, required: [inboundId, orderId, orderNo, inboundCode, tenantId, customerName, taxMode, covered], properties: {inboundId: {$ref: '#/components/schemas/LongId'}, orderId: {$ref: '#/components/schemas/LongId'}, orderNo: {type: string}, inboundCode: {type: string}, tenantId: {$ref: '#/components/schemas/LongId'}, customerName: {type: string}, taxMode: {type: string, enum: [TAX_REFUND, BUY_DECLARATION]}, covered: {type: boolean}}}
|
||||
DocumentView: {type: object, required: [documentId, tenantId, source, inboundIds, inboundCodes, frozen], properties: {documentId: {$ref: '#/components/schemas/LongId'}, tenantId: {$ref: '#/components/schemas/LongId'}, source: {$ref: '#/components/schemas/FileView'}, inboundIds: {type: array, items: {$ref: '#/components/schemas/LongId'}}, inboundCodes: {type: array, items: {type: string}}, frozen: {type: boolean}, completion: {allOf: [{$ref: '#/components/schemas/FileView'}], nullable: true, description: 退税报关行回执文件}, receiptStatus: {type: string, enum: [PENDING, CONFIRMED, REJECTED], nullable: true}, receiptRejectReason: {type: string, nullable: true}}}
|
||||
DocumentView: {type: object, required: [documentId, tenantId, source, inboundIds, inboundCodes, frozen], properties: {documentId: {$ref: '#/components/schemas/LongId'}, tenantId: {$ref: '#/components/schemas/LongId'}, source: {$ref: '#/components/schemas/FileView'}, inboundIds: {type: array, items: {$ref: '#/components/schemas/LongId'}}, inboundCodes: {type: array, items: {type: string}}, frozen: {type: boolean}, receipt: {allOf: [{$ref: '#/components/schemas/FileView'}], nullable: true, description: 报关行回执文件}, receiptStatus: {type: string, enum: [PENDING, CONFIRMED, REJECTED], nullable: true}, receiptRejectReason: {type: string, nullable: true}, finalDocument: {allOf: [{$ref: '#/components/schemas/FileView'}], nullable: true, description: 真实报关完税文件}}}
|
||||
ContainerView: {type: object, required: [aggregateId, loadingPlanId, containerBookingId, status, hasActualCargo, version, taxRefundCount, coveredTaxRefundCount, actualBoxCount, inbounds, documents], properties: {aggregateId: {$ref: '#/components/schemas/LongId'}, loadingPlanId: {$ref: '#/components/schemas/LongId'}, containerBookingId: {$ref: '#/components/schemas/LongId'}, planNo: {type: string}, cabinetNo: {type: string}, containerNo: {type: string, nullable: true}, status: {type: string, enum: [WAITING_CUSTOMER_DOCUMENTS, READY_TO_START, DECLARING, COMPLETED]}, hasActualCargo: {type: boolean, description: 当前完成装柜计划是否仍有实际装柜货物;false时查询成功返回空明细且无需发起报关}, merchantStatus: {type: string, enum: [WAITING_UPLOAD, DOCUMENT_UPLOADED, DECLARING, CUSTOMS_COMPLETED], nullable: true}, version: {type: integer}, taxRefundCount: {type: integer}, coveredTaxRefundCount: {type: integer}, actualBoxCount: {type: integer, format: int64, minimum: 0, description: 当前视角可见的实际装箱数;部分装柜只展示该数量,线下处理后续}, inbounds: {type: array, items: {$ref: '#/components/schemas/InboundView'}}, documents: {type: array, items: {$ref: '#/components/schemas/DocumentView'}}, startedAt: {type: string, format: date-time, nullable: true}, completedAt: {type: string, format: date-time, nullable: true}}}
|
||||
ExportBatchRequest: {type: object, required: [taskId, tenantId, appCode, requestedBy, dispatchVersion, parameters, limit], properties: {taskId: {type: string}, tenantId: {type: string, enum: ['0']}, appCode: {type: string, enum: [PLATFORM]}, requestedBy: {type: string}, dispatchVersion: {type: integer}, parameters: {type: object, required: [loadingPlanId], additionalProperties: false, properties: {loadingPlanId: {type: string}}}, cursor: {type: string, nullable: true}, snapshotToken: {type: string, nullable: true}, limit: {type: integer, minimum: 1, maximum: 500}}}
|
||||
ExportBatchEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {type: object, properties: {schemaVersion: {type: integer}, schemaHash: {type: string}, snapshotToken: {type: string}, columns: {type: array, items: {type: object}}, rows: {type: array, items: {type: object}}, nextCursor: {type: string}, hasMore: {type: boolean}, totalRows: {type: integer, format: int64, nullable: true}}}}}]}
|
||||
|
||||
+9
-9
@@ -248,9 +248,9 @@ class ContainerCustomsServiceTest {
|
||||
when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("k"),anyString())).thenReturn(1);
|
||||
when(mapper.document(1L)).thenReturn(source);when(mapper.lockContainerById(2L)).thenReturn(container);
|
||||
when(mapper.lockDocument(1L)).thenReturn(source);when(mapper.lockCompletion(1L)).thenReturn(null);
|
||||
when(files.upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RESULT"),eq("9"),any()))
|
||||
when(files.upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RECEIPT"),eq("9"),any()))
|
||||
.thenReturn(com.baiyue.common.core.api.ApiResponse.success(new CustomsFileClient.Metadata(99L,7L,
|
||||
"LOADING_LIST_CUSTOMS_RESULT","9","result.bin","b".repeat(64),"application/octet-stream",1L)));
|
||||
"LOADING_LIST_CUSTOMS_RECEIPT","9","result.bin","b".repeat(64),"application/octet-stream",1L)));
|
||||
when(mapper.insertCompletion(anyLong(),eq(1L),eq(99L),anyString(),anyString(),anyString(),eq(1L),eq(5L),eq("admin"))).thenReturn(1);
|
||||
when(mapper.insertCompletionVersion(anyLong(),anyLong(),eq(1),eq(99L),anyString(),anyString(),anyString(),eq(1L),eq(5L),eq("admin"))).thenReturn(1);
|
||||
when(mapper.frozenScopes(1L)).thenReturn(List.of(new ContainerCustomsMapper.ScopeRow(1L,11L,21L,"I")));
|
||||
@@ -259,7 +259,7 @@ class ContainerCustomsServiceTest {
|
||||
var file=new MockMultipartFile("file","result.bin","application/octet-stream",new byte[]{1});
|
||||
var result=service.uploadCompletion(1L,5L,"admin","k",0,file);
|
||||
assertThat(result).isNotNull();
|
||||
verify(files).upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RESULT"),eq("9"),same(file));
|
||||
verify(files).upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RECEIPT"),eq("9"),same(file));
|
||||
verify(mapper,never()).complete(anyLong(),anyInt(),anyLong(),anyString());
|
||||
}
|
||||
|
||||
@@ -282,7 +282,7 @@ class ContainerCustomsServiceTest {
|
||||
var commandState=new AtomicReference<ContainerCustomsMapper.CommandRow>();
|
||||
var file=new MockMultipartFile("file","result.pdf","application/pdf",new byte[]{1});
|
||||
var changedFile=new MockMultipartFile("file","result.pdf","application/pdf",new byte[]{2});
|
||||
var metadata=new CustomsFileClient.Metadata(99L,7L,"LOADING_LIST_CUSTOMS_RESULT","9","result.pdf",
|
||||
var metadata=new CustomsFileClient.Metadata(99L,7L,"LOADING_LIST_CUSTOMS_RECEIPT","9","result.pdf",
|
||||
"b".repeat(64),"application/pdf",1L);
|
||||
when(mapper.document(1L)).thenReturn(source);
|
||||
when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("completion-key"),anyString()))
|
||||
@@ -296,7 +296,7 @@ class ContainerCustomsServiceTest {
|
||||
when(mapper.lockContainerById(2L)).thenAnswer(invocation->containerState.get());
|
||||
when(mapper.lockDocument(1L)).thenReturn(source);
|
||||
when(mapper.lockCompletion(1L)).thenAnswer(invocation->completionState.get());
|
||||
when(files.upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RESULT"),eq("9"),same(file)))
|
||||
when(files.upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RECEIPT"),eq("9"),same(file)))
|
||||
.thenReturn(com.baiyue.common.core.api.ApiResponse.success(metadata));
|
||||
when(mapper.insertCompletion(anyLong(),eq(1L),eq(99L),eq("result.pdf"),eq("b".repeat(64)),
|
||||
eq("application/pdf"),eq(1L),eq(5L),eq("admin"))).thenAnswer(invocation->{
|
||||
@@ -319,12 +319,12 @@ class ContainerCustomsServiceTest {
|
||||
var first=service.uploadCompletion(1L,5L,"admin","completion-key",0,file);
|
||||
var replay=service.uploadCompletion(1L,5L,"admin","completion-key",0,file);
|
||||
|
||||
assertThat(first.completion().fileId()).isEqualTo(99L);
|
||||
assertThat(replay.completion().fileId()).isEqualTo(99L);
|
||||
assertThat(first.receipt().fileId()).isEqualTo(99L);
|
||||
assertThat(replay.receipt().fileId()).isEqualTo(99L);
|
||||
assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","completion-key",0,changedFile))
|
||||
.isInstanceOfSatisfying(BizException.class,
|
||||
error->assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT));
|
||||
verify(files,times(1)).upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RESULT"),eq("9"),same(file));
|
||||
verify(files,times(1)).upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RECEIPT"),eq("9"),same(file));
|
||||
verify(mapper,times(1)).lockContainerById(2L);
|
||||
verify(mapper,times(1)).insertCompletion(anyLong(),eq(1L),eq(99L),anyString(),anyString(),anyString(),
|
||||
eq(1L),eq(5L),eq("admin"));
|
||||
@@ -335,7 +335,7 @@ class ContainerCustomsServiceTest {
|
||||
var source=document(1L);when(mapper.document(1L)).thenReturn(source);
|
||||
when(mapper.containerById(2L)).thenReturn(container(ContainerCustomsService.DECLARING,3));
|
||||
assertThatThrownBy(()->service.downloadCompletion(7L,1L,false)).isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("完成文件不存在");
|
||||
.hasMessageContaining("报关行回执不存在");
|
||||
verifyNoInteractions(files);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user