diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/MerchantContainerCustomsController.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/MerchantContainerCustomsController.java index 93f3035fa..2b448a216 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/MerchantContainerCustomsController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/MerchantContainerCustomsController.java @@ -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 downloadCompletion(@AuthenticationPrincipal AuthenticatedUser user, + public ResponseEntity 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 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 confirmReceipt(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/PlatformContainerCustomsController.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/PlatformContainerCustomsController.java index 8ce46a403..f25e831be 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/PlatformContainerCustomsController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/controller/PlatformContainerCustomsController.java @@ -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 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 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 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 downloadFinal(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long documentId) { + FirstlegScopeGuard.platform(user); + return response(service.downloadFinal(0,documentId,true)); + } + private static ResponseEntity response(Download value) { return ResponseEntity.ok().cacheControl(CacheControl.noStore()) .contentType(MediaType.parseMediaType(value.contentType())) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java index 509b9096e..1f388c41e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java @@ -19,8 +19,8 @@ public final class ContainerCustomsDtos { public record DocumentView(long documentId, long tenantId, FileView source, List inboundIds, List 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, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java index 9d6cab745..5839894b1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java @@ -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, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java index 5a40f339f..a334eaaac 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java @@ -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 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("无法读取上传文件"); } } diff --git a/firstleg/src/main/resources/db/phase3-container-customs-schema.sql b/firstleg/src/main/resources/db/phase3-container-customs-schema.sql index 3d2982d05..92ea78873 100644 --- a/firstleg/src/main/resources/db/phase3-container-customs-schema.sql +++ b/firstleg/src/main/resources/db/phase3-container-customs-schema.sql @@ -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', diff --git a/firstleg/src/main/resources/openapi/customs-v1.yaml b/firstleg/src/main/resources/openapi/customs-v1.yaml index f1c36d611..7175f95d3 100644 --- a/firstleg/src/main/resources/openapi/customs-v1.yaml +++ b/firstleg/src/main/resources/openapi/customs-v1.yaml @@ -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}}}}}]} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java index 84c73bbbb..6df5f6cba 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java @@ -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(); 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); } diff --git a/scripts/README.md b/scripts/README.md index 07dcb4b5b..7e44caf20 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -4,6 +4,8 @@ `db/recent-schema-incremental-20260923.sql`:汇总 2026-09-21 至 2026-09-22 的近期结构变更,依次显式指向 `baiyue_iam`、`baiyue_firstleg`、`baiyue_lastleg`、`baiyue_lastlag_warehouse`;补齐国内 PDA 作业仓字段及索引、人工 FBA 报价可空字段、墨家 SKU 测量字段、换标申请普通索引和 A0 共享溢出位字段。所有字段和索引变更均经 `information_schema` 判断,可重复执行;不创建数据库、不含凭据、权限种子或业务状态修正,末尾输出逐库 `READY/CHECK_REQUIRED`。由操作者选择并确认目标连接后执行,任一 DDL 报错立即停止。 +`db/customs-two-file-incremental-20260926.sql`:报关两文件流程增量结构,保留报关行回执与新增真实报关完税文件,补充“已发送给报关行执行”的操作人和时间;仅新增结构,可重复执行,不修改业务数据。 + `local-return-relabel-acceptance.mjs` 的 `LOCAL_RETURN_VERIFY_REMAINING=true` 专项新建3件退货,先换1件并客户确认,再对同一退货单剩余2件中的1件新建第二申请,验证同键重放、第二轮完工后剩余1件;不执行出库,报告 scope 为 `REMAINING_RETURN_RELABEL`,不代表出库验收通过。仍保留单据、使用正式API和合成照片,默认单票模式不变。 `local-return-relabel-outbound.mjs`:仅本机网关,读取显式 `LOCAL_RETURN_SOURCE` 单票报告,为其中同客户、同仓、已完成换标的唯一一件可用库存执行平台创建出库、原键重放、PDA拣货及完成。沿用平台账号环境参数,报告默认 `reports/local-return-relabel-outbound-.json` 或 `--output`;创建临时PDA并finally停用,不改客户权限。平台库存核验不替代源报告的客户读权限失败。会推进库存与出库状态,保留单据及报告;失败后按已保存单号核查,不自动清理,不可对已出库库存盲目重跑。 diff --git a/scripts/admin-test-env-modules/container-customs-acceptance-matrix.mjs b/scripts/admin-test-env-modules/container-customs-acceptance-matrix.mjs index 7d49e220d..d44cff54d 100644 --- a/scripts/admin-test-env-modules/container-customs-acceptance-matrix.mjs +++ b/scripts/admin-test-env-modules/container-customs-acceptance-matrix.mjs @@ -17,10 +17,14 @@ export const REQUIRED_ROUTE_OPERATIONS = Object.freeze([ 'merchantDeleteDocument', 'merchantConfirmReceipt', 'platformStart', - 'platformUploadCompletion', + 'platformUploadReceipt', + 'platformSubmitToBroker', + 'platformUploadFinal', 'platformComplete', - 'platformDownload', - 'merchantDownload', + 'platformDownloadReceipt', + 'platformDownloadFinal', + 'merchantDownloadReceipt', + 'merchantDownloadFinal', 'outboundState', 'satisfyOutboundPrerequisites', ]) @@ -29,7 +33,7 @@ export const CONTAINER_CUSTOMS_MATRIX = Object.freeze([ { priority: 'P0', fixture: 'ALL_BUY_DECLARATION', scenario: 'LOADED详情与买单XLSX', expected: 'XLSX只含柜内买单实装行,下载窗口可用' }, { priority: 'P0', fixture: 'ALL_BUY_DECLARATION', scenario: '开始与完成', expected: 'READY_TO_START→DECLARING→COMPLETED,重放返回同一结果' }, { priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '客户多对多资料', expected: '上传、覆盖、改关联、删除均保留版本与客户范围' }, - { priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '冻结、回执确认与逐份完成文件', expected: '开始冻结来源文件和关联;最后一份平台回执上传后由客户确认整份回执,才允许整柜完成' }, + { priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '冻结、回执确认、报关行执行与完税文件', expected: '开始冻结来源文件和关联;上传回执、客户确认、发送报关行执行,最后上传真实报关完税文件才允许整柜完成' }, { priority: 'P0', fixture: 'MIXED', scenario: '买单与退税混合', expected: '买单行进入XLSX,退税票逐票被资料覆盖,整柜统一开始/完成' }, { priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '缺资料禁止开始', expected: 'A0409且聚合、文件、关联、历史零漂移' }, { priority: 'P0', fixture: 'MIXED', scenario: '自动离仓失败恢复', expected: '报关COMPLETED不回滚;待条件满足后后台重试并单次离仓' }, @@ -91,6 +95,7 @@ export const READ_ONLY_DB_EVIDENCE = Object.freeze([ 'fl_customs_frozen_document_inbound', 'fl_customs_completion_document', 'fl_customs_completion_document_version', + 'fl_customs_final_document', 'fl_customs_command', 'fl_customs_container_history', 'fl_outbound_dispatch', diff --git a/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs b/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs index 3df5cc060..8b3bda756 100644 --- a/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs +++ b/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs @@ -88,16 +88,27 @@ function routes(context, merchantToken) { platformStart: (ctx, fixture, request, idempotencyKey) => ctx.request('POST', `${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/start`, request, ctx.token, { 'Idempotency-Key': idempotencyKey }), - platformUploadCompletion: (ctx, fixture, request, idempotencyKey) => multipart( - `${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/completion/upload?expectedVersion=${Number(request.expectedVersion)}&finalConfirmation=${request.finalConfirmation === true}`, + platformUploadReceipt: (ctx, fixture, request, idempotencyKey) => multipart( + `${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/receipt/upload?expectedVersion=${Number(request.expectedVersion)}`, {}, request, ctx.token, idempotencyKey), + platformSubmitToBroker: (ctx, fixture, request, idempotencyKey) => ctx.request('POST', + `${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/submit-to-broker`, request, ctx.token, + { 'Idempotency-Key': idempotencyKey }), platformComplete: (ctx, fixture, request, idempotencyKey) => ctx.request('POST', `${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/complete-all-buy`, request, ctx.token, { 'Idempotency-Key': idempotencyKey }), - platformDownload: (ctx, fixture, request) => binary('GET', - `${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/${request.completion ? 'completion/' : ''}download`), - merchantDownload: (ctx, fixture, customer, request) => binary('GET', - `${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}/${request.completion ? 'completion/' : ''}download`, + platformUploadFinal: (ctx, fixture, request, idempotencyKey) => multipart( + `${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/final/upload?expectedVersion=${Number(request.expectedVersion)}`, + {}, request, ctx.token, idempotencyKey), + platformDownloadReceipt: (ctx, fixture, request) => binary('GET', + `${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/receipt/download`), + platformDownloadFinal: (ctx, fixture, request) => binary('GET', + `${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/final/download`), + merchantDownloadReceipt: (ctx, fixture, customer, request) => binary('GET', + `${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}/receipt/download`, + customer.customerToken || merchantToken), + merchantDownloadFinal: (ctx, fixture, customer, request) => binary('GET', + `${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}/final/download`, customer.customerToken || merchantToken), buyDeclarationXlsx: async (ctx, request) => { const created = await ctx.request('POST', '/api/platform/v1/files/export-tasks', { diff --git a/scripts/admin-test-env-modules/phase3-container-customs.mjs b/scripts/admin-test-env-modules/phase3-container-customs.mjs index ecfed8107..1b8f1339b 100644 --- a/scripts/admin-test-env-modules/phase3-container-customs.mjs +++ b/scripts/admin-test-env-modules/phase3-container-customs.mjs @@ -183,56 +183,71 @@ async function runRefund(context, routes, fixture) { const documents = frozenSnapshot?.documents || [] for (const [index, document] of documents.entries()) { - const completion = await routes.platformUploadCompletion(context, fixture, { + const receipt = await routes.platformUploadReceipt(context, fixture, { documentId: id(document.documentId), expectedVersion: Number(document.source?.version), - fileName: `${context.marker}-completion-${index + 1}.done`, - contentType: 'application/x-customs-completion', content: Uint8Array.from([0x43, index + 1]), - }, key(context, `completion-${index + 1}`)) - context.assert(MODULE, `逐份上传完成文件${index + 1}`, completion, - success(completion) - && zeroDrift(completion.body?.data?.inboundIds, document.inboundIds), - evidence(completion, `sourceDocumentId=${id(document.documentId)}`), OPTIONS) - if (index + 1 < documents.length) { - const beforeEarlyComplete = await platformDetail(context, routes, fixture) - const early = await routes.platformComplete(context, fixture, { - expectedVersion: Number(beforeEarlyComplete.body?.data?.version), remark: 'not-last-completion', - }, key(context, `early-complete-${index + 1}`)) - await assertZeroDrift(context, '最后一份完成文件前禁止整柜完成', routes, fixture, beforeEarlyComplete, early) - } + fileName: `${context.marker}-broker-receipt-${index + 1}.pdf`, + contentType: 'application/pdf', content: Uint8Array.from([0x52, index + 1]), + }, key(context, `receipt-${index + 1}`)) + context.assert(MODULE, `逐份上传报关行回执${index + 1}`, receipt, + success(receipt) && zeroDrift(receipt.body?.data?.inboundIds, document.inboundIds), + evidence(receipt, `sourceDocumentId=${id(document.documentId)}`), OPTIONS) } - const completedReady = await platformDetail(context, routes, fixture) + const beforeConfirm = await platformDetail(context, routes, fixture) + const earlyFinal = await routes.platformUploadFinal(context, fixture, { + documentId: id(documents[0]?.documentId), expectedVersion: 0, + fileName: `${context.marker}-early-final.pdf`, contentType: 'application/pdf', content: Uint8Array.from([0x45]), + }, key(context, 'early-final')) + await assertZeroDrift(context, '回执未确认且未发送执行时禁止上传完税文件', routes, fixture, beforeConfirm, earlyFinal) const confirmed = [] for (const customer of customers) { const document = documents.find((item) => (item.inboundIds || []).some((inboundId) => customer.inboundIds.map(id).includes(id(inboundId)))) if (!document) continue - const version = Number(document.source?.version || completedReady.body?.data?.documents - ?.find((item) => id(item.documentId) === id(document.documentId))?.source?.version || 0) + const current = await platformDetail(context, routes, fixture) + const live = current.body?.data?.documents?.find((item) => id(item.documentId) === id(document.documentId)) + const version = Number(live?.receipt?.version || 0) const response = await routes.merchantConfirmReceipt(context, fixture, customer, { documentId: id(document.documentId), expectedVersion: version, confirm: true, remark: context.marker, }, key(context, `refund-receipt-confirm-${customer.customerId}`)) confirmed.push(response) } + const afterConfirm = await platformDetail(context, routes, fixture) + context.assert(MODULE, '客户确认整份回执后仍处于报关中', afterConfirm, + success(afterConfirm) && afterConfirm.body?.data?.status === 'DECLARING' && confirmed.every(success), + evidence(afterConfirm), OPTIONS) + const submitted = await routes.platformSubmitToBroker(context, fixture, + { expectedVersion: Number(afterConfirm.body?.data?.version), remark: context.marker }, key(context, 'submit-to-broker')) + context.assert(MODULE, '平台发送客户确认回执给报关行执行', submitted, + success(submitted) && submitted.body?.data?.status === 'DECLARING', evidence(submitted), OPTIONS) + const submitReplay = await routes.platformSubmitToBroker(context, fixture, + { expectedVersion: Number(submitted.body?.data?.version), remark: context.marker }, key(context, 'submit-to-broker')) + context.assert(MODULE, '发送报关行执行同键重放', submitReplay, success(submitReplay), evidence(submitReplay), OPTIONS) + for (const [index, document] of documents.entries()) { + const finalDocument = await routes.platformUploadFinal(context, fixture, { + documentId: id(document.documentId), expectedVersion: 0, + fileName: `${context.marker}-customs-tax-final-${index + 1}.pdf`, + contentType: 'application/pdf', content: Uint8Array.from([0x46, index + 1]), + }, key(context, `final-${index + 1}`)) + context.assert(MODULE, `逐份上传真实报关完税文件${index + 1}`, finalDocument, + success(finalDocument) && zeroDrift(finalDocument.body?.data?.inboundIds, document.inboundIds), + evidence(finalDocument), OPTIONS) + } const completed = await platformDetail(context, routes, fixture) - context.assert(MODULE, '客户确认整份回执后纯退税柜完成', completed, - success(completed) && completed.body?.data?.status === 'COMPLETED' - && confirmed.every(success), evidence(completed), OPTIONS) - + context.assert(MODULE, '真实报关完税文件齐全后纯退税柜完成', completed, + success(completed) && completed.body?.data?.status === 'COMPLETED', evidence(completed), OPTIONS) const firstDocument = documents[0] if (firstDocument) { - const platformDownload = await routes.platformDownload(context, fixture, { - documentId: id(firstDocument.documentId), completion: true, - }) - context.assert(MODULE, 'Admin下载完成文件窗口', platformDownload, - platformDownload.http === 200 && platformDownload.bytes?.length > 0, - `HTTP=${platformDownload.http}, bytes=${platformDownload.bytes?.length ?? 0}`, OPTIONS) - const merchantDownload = await routes.merchantDownload(context, fixture, mutable, { - documentId: id(firstDocument.documentId), completion: true, - }) - context.assert(MODULE, 'Client仅下载本客户完成文件窗口', merchantDownload, - merchantDownload.http === 200 && merchantDownload.bytes?.length > 0, - `HTTP=${merchantDownload.http}, bytes=${merchantDownload.bytes?.length ?? 0}`, OPTIONS) + const platformReceipt = await routes.platformDownloadReceipt(context, fixture, { documentId: id(firstDocument.documentId) }) + context.assert(MODULE, 'Admin下载报关行回执', platformReceipt, + platformReceipt.http === 200 && platformReceipt.bytes?.length > 0, evidence(platformReceipt), OPTIONS) + const platformFinal = await routes.platformDownloadFinal(context, fixture, { documentId: id(firstDocument.documentId) }) + context.assert(MODULE, 'Admin下载真实报关完税文件', platformFinal, + platformFinal.http === 200 && platformFinal.bytes?.length > 0, evidence(platformFinal), OPTIONS) + const merchantFinal = await routes.merchantDownloadFinal(context, fixture, mutable, { documentId: id(firstDocument.documentId) }) + context.assert(MODULE, 'Client仅下载本客户真实报关完税文件', merchantFinal, + merchantFinal.http === 200 && merchantFinal.bytes?.length > 0, evidence(merchantFinal), OPTIONS) } + } async function runMixedConcurrency(context, routes, fixture) { @@ -320,44 +335,55 @@ async function runMixedConcurrency(context, routes, fixture) { }, key(context, 'locked-scope')) await assertZeroDrift(context, '开始后资料范围锁定A0409零漂移', routes, fixture, { body: { data: frozen } }, locked) - const completionRequest = { + const receiptRequest = { documentId: customer.documentId, expectedVersion: 0, - finalConfirmation: true, fileName: `${context.marker}-mixed-result.done`, - contentType: 'application/x-customs-completion', content: Uint8Array.from([0x52, 0x45, 0x53]), + fileName: `${context.marker}-mixed-broker-receipt.pdf`, + contentType: 'application/pdf', content: Uint8Array.from([0x52, 0x45, 0x43]), } - const completionKey = key(context, 'mixed-completion') - const completion = await routes.platformUploadCompletion(context, fixture, completionRequest, completionKey) - const completionReplay = success(completion) - ? await routes.platformUploadCompletion(context, fixture, completionRequest, completionKey) : null - const completionConverged = success(completion) && success(completionReplay) - && id(completion.body?.data?.documentId) === id(completionReplay.body?.data?.documentId) - && id(completion.body?.data?.completion?.fileId) === id(completionReplay.body?.data?.completion?.fileId) - context.assert(MODULE, '最后一份TAX完成文件二次确认并同键重放', completionReplay || completion, - completionConverged, - [completion, completionReplay].filter(Boolean).map((response) => evidence(response)).join('; '), OPTIONS) - if (!completionConverged) { - context.addCoverageGap(MODULE, OPTIONS.scenario, '最后一份完成文件二次确认', - '完成文件未成功或同键重放未收敛,已停止后续自动离仓写入', - 'completion-idempotency', { deploymentBlocker: true }) - return - } - const completionDifferent = await routes.platformUploadCompletion(context, fixture, - { ...completionRequest, content: Uint8Array.from([0x44, 0x49, 0x46]) }, completionKey) - context.assert(MODULE, '完成文件同键异载荷A0409', completionDifferent, - a0409(completionDifferent), evidence(completionDifferent), OPTIONS) - - const receiptVersion = Number(completion.body?.data?.source?.version || completion.body?.data?.version || 0) + const receiptKey = key(context, 'mixed-receipt-upload') + const receipt = await routes.platformUploadReceipt(context, fixture, receiptRequest, receiptKey) + const receiptReplay = success(receipt) + ? await routes.platformUploadReceipt(context, fixture, receiptRequest, receiptKey) : null + const receiptConverged = success(receipt) && success(receiptReplay) + && id(receipt.body?.data?.documentId) === id(receiptReplay.body?.data?.documentId) + && id(receipt.body?.data?.receipt?.fileId) === id(receiptReplay.body?.data?.receipt?.fileId) + context.assert(MODULE, '报关行回执同键重放收敛', receiptReplay || receipt, receiptConverged, + [receipt, receiptReplay].filter(Boolean).map((response) => evidence(response)).join('; '), OPTIONS) + const receiptDifferent = await routes.platformUploadReceipt(context, fixture, + { ...receiptRequest, content: Uint8Array.from([0x44, 0x49, 0x46]) }, receiptKey) + context.assert(MODULE, '回执同键异载荷A0409', receiptDifferent, a0409(receiptDifferent), evidence(receiptDifferent), OPTIONS) + const receiptVersion = Number(receipt.body?.data?.receipt?.version || 0) const receiptConfirm = await routes.merchantConfirmReceipt(context, fixture, customer, { documentId: customer.documentId, expectedVersion: receiptVersion, confirm: true, remark: context.marker, }, key(context, 'mixed-receipt-confirm')) - const receiptReplay = await routes.merchantConfirmReceipt(context, fixture, customer, { + const receiptConfirmReplay = await routes.merchantConfirmReceipt(context, fixture, customer, { documentId: customer.documentId, expectedVersion: receiptVersion, confirm: true, remark: context.marker, }, key(context, 'mixed-receipt-confirm')) - context.assert(MODULE, '客户确认整份回执并同键重放', receiptReplay || receiptConfirm, - success(receiptConfirm) && success(receiptReplay) - && receiptConfirm.body?.data?.status === 'COMPLETED' - && receiptReplay.body?.data?.status === 'COMPLETED', - [receiptConfirm, receiptReplay].map((response) => evidence(response)).join('; '), OPTIONS) + context.assert(MODULE, '客户确认回执后仍未完成整柜', receiptConfirmReplay || receiptConfirm, + success(receiptConfirm) && success(receiptConfirmReplay) + && receiptConfirm.body?.data?.status === 'DECLARING' + && receiptConfirmReplay.body?.data?.status === 'DECLARING', + [receiptConfirm, receiptConfirmReplay].map((response) => evidence(response)).join('; '), OPTIONS) + const submitted = await routes.platformSubmitToBroker(context, fixture, + { expectedVersion: Number(receiptConfirm.body?.data?.version), remark: context.marker }, key(context, 'mixed-submit-to-broker')) + context.assert(MODULE, '混合柜发送确认回执给报关行执行', submitted, + success(submitted) && submitted.body?.data?.status === 'DECLARING', evidence(submitted), OPTIONS) + const finalRequest = { + documentId: customer.documentId, expectedVersion: 0, + fileName: `${context.marker}-mixed-customs-tax-final.pdf`, + contentType: 'application/pdf', content: Uint8Array.from([0x46, 0x49, 0x4e]), + } + const finalKey = key(context, 'mixed-final') + const finalDocument = await routes.platformUploadFinal(context, fixture, finalRequest, finalKey) + const finalReplay = success(finalDocument) + ? await routes.platformUploadFinal(context, fixture, finalRequest, finalKey) : null + const finalConverged = success(finalDocument) && success(finalReplay) + && id(finalDocument.body?.data?.finalDocument?.fileId) === id(finalReplay.body?.data?.finalDocument?.fileId) + context.assert(MODULE, '真实报关完税文件同键重放收敛', finalReplay || finalDocument, finalConverged, + [finalDocument, finalReplay].filter(Boolean).map((response) => evidence(response)).join('; '), OPTIONS) + const finalDifferent = await routes.platformUploadFinal(context, fixture, + { ...finalRequest, content: Uint8Array.from([0x44, 0x49, 0x46]) }, finalKey) + context.assert(MODULE, '完税文件同键异载荷A0409', finalDifferent, a0409(finalDifferent), evidence(finalDifferent), OPTIONS) let completed = await platformDetail(context, routes, fixture) context.assert(MODULE, '混合柜最后一份完成文件后整柜COMPLETED', completed, @@ -396,10 +422,10 @@ async function runMixedConcurrency(context, routes, fixture) { evidence(queryDetail), OPTIONS) } - const completionDownload = await routes.merchantDownload(context, fixture, customer, { - documentId: customer.documentId, completion: true, + const completionDownload = await routes.merchantDownloadFinal(context, fixture, customer, { + documentId: customer.documentId, }) - context.assert(MODULE, '整柜完成后Client可下载自身TAX完成文件', completionDownload, + context.assert(MODULE, '整柜完成后Client可下载自身真实报关完税文件', completionDownload, completionDownload.http === 200 && completionDownload.bytes?.length > 0, `HTTP=${completionDownload.http}, bytes=${completionDownload.bytes?.length ?? 0}`, OPTIONS) } diff --git a/scripts/db/customs-two-file-incremental-20260926.sql b/scripts/db/customs-two-file-incremental-20260926.sql new file mode 100644 index 000000000..6d861f37f --- /dev/null +++ b/scripts/db/customs-two-file-incremental-20260926.sql @@ -0,0 +1,28 @@ +-- 报关两文件流程:回执与真实报关完税文件分离。 +-- 仅在已确认目标测试库后执行;所有 DDL 可重复执行,不创建数据库、不写业务数据。 +USE baiyue_firstleg; + +SET @ddl = IF(NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_customs_container' AND column_name='broker_submitted_at' +), 'ALTER TABLE fl_customs_container ADD COLUMN broker_submitted_by BIGINT UNSIGNED NULL COMMENT ''发送报关行执行的操作人ID'' AFTER completed_at, ADD COLUMN broker_submitted_by_name VARCHAR(64) NULL COMMENT ''发送报关行执行的操作人名称'' AFTER broker_submitted_by, ADD COLUMN broker_submitted_at DATETIME(3) NULL COMMENT ''发送报关行执行时间'' AFTER broker_submitted_by_name', 'SELECT 1'); +PREPARE customs_container_broker_submit_stmt FROM @ddl; EXECUTE customs_container_broker_submit_stmt; DEALLOCATE PREPARE customs_container_broker_submit_stmt; + +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='真实报关完税文件当前版本';