diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index c60b2b3b0..c15f04c7f 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -1769,6 +1769,28 @@ CREATE TABLE fl_customs_container ( CONSTRAINT chk_customs_container_status CHECK ((status in (_utf8mb4'WAITING_CUSTOMER_DOCUMENTS',_utf8mb4'READY_TO_START',_utf8mb4'DECLARING',_utf8mb4'COMPLETED'))) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='装柜完成后的整柜出口报关聚合'; +CREATE TABLE fl_customs_broker_submission ( + id bigint unsigned NOT NULL COMMENT '首次发送报关行批次ID', + customs_container_id bigint unsigned NOT NULL COMMENT '所属整柜报关聚合ID', + loading_plan_id bigint unsigned NOT NULL COMMENT '装柜计划ID快照', + batch_no varchar(64) NOT NULL COMMENT '首次发送报关行批次号', + status varchar(16) NOT NULL COMMENT '发送状态:SENT已发送', + source_version int unsigned NOT NULL COMMENT '发送时整柜报关聚合版本', + source_snapshot_json json NOT NULL COMMENT '发送时冻结资料版本与覆盖范围快照', + sent_by bigint unsigned NOT NULL COMMENT '首次发送操作人ID', + sent_by_name varchar(64) DEFAULT NULL COMMENT '首次发送操作人名称快照', + sent_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_broker_submission_container (customs_container_id), + UNIQUE KEY uk_customs_broker_submission_batch (batch_no), + KEY idx_customs_broker_submission_plan (loading_plan_id,sent_at), + CONSTRAINT fk_customs_broker_submission_container FOREIGN KEY (customs_container_id) REFERENCES fl_customs_container (id), + CONSTRAINT chk_customs_broker_submission_status CHECK ((status in (_utf8mb4'SENT'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='冻结退税报关资料首次发送报关行批次事实'; + CREATE TABLE fl_customs_container_history ( id bigint unsigned NOT NULL COMMENT '报关历史记录ID', customs_container_id bigint unsigned NOT NULL COMMENT '所属整柜报关聚合ID', @@ -1865,6 +1887,21 @@ CREATE TABLE fl_customs_completion_document_version ( CONSTRAINT fk_customs_completion_version FOREIGN KEY (completion_document_id) REFERENCES fl_customs_completion_document (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='报关完成文件不可变覆盖版本'; +CREATE TABLE fl_customs_completion_action_history ( + id bigint unsigned NOT NULL COMMENT '客户回执动作历史ID', + completion_document_id bigint unsigned NOT NULL COMMENT '报关行回执当前版本逻辑根ID', + receipt_version int unsigned NOT NULL COMMENT '动作对应的回执文件版本', + action varchar(16) NOT NULL COMMENT '客户动作:CONFIRM确认、REJECT打回', + reason varchar(500) DEFAULT NULL COMMENT '客户打回原因;确认动作为空', + operator_id bigint unsigned NOT NULL COMMENT '客户操作人ID', + operator_name varchar(64) DEFAULT NULL COMMENT '客户操作人名称快照', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '客户动作时间', + PRIMARY KEY (id), + KEY idx_customs_completion_action_history (completion_document_id,receipt_version,created_at,id), + CONSTRAINT fk_customs_completion_action_history_document FOREIGN KEY (completion_document_id) REFERENCES fl_customs_completion_document (id), + CONSTRAINT chk_customs_completion_action_history_action CHECK ((action in (_utf8mb4'CONFIRM',_utf8mb4'REJECT'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户报关行回执确认与打回不可变历史'; + CREATE TABLE fl_customs_customer_document_inbound ( document_id bigint unsigned NOT NULL COMMENT '客户报送资料ID', inbound_id bigint unsigned NOT NULL COMMENT '覆盖入仓单ID', 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 5fe429d46..faa6b9f41 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,6 +84,13 @@ public class MerchantContainerCustomsController { return ApiResponse.success(service.deleteSource(tenantId,documentId,user.userId(),key,expectedVersion)); } + @GetMapping("/documents/{documentId}/receipt/actions") + @PreAuthorize("hasAuthority('merchant:order:view')") + public ApiResponse> receiptActions(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long documentId) { + return ApiResponse.success(service.receiptActions(FirstlegScopeGuard.merchant(user),documentId,false)); + } + @GetMapping("/documents/{documentId}/download") @PreAuthorize("hasAuthority('merchant:order:view')") public ResponseEntity downloadSource(@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 b6e5d7132..afe1b81e6 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 @@ -105,6 +105,17 @@ public class PlatformContainerCustomsController { return ApiResponse.success(service.start(planId,user.userId(),user.username(),key,request)); } + @PostMapping("/{planId}/send-to-broker") + @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") + @BusinessAudit(event="CUSTOMS_INITIAL_SEND_TO_BROKER",resourceType="CUSTOMS_CONTAINER") + public ApiResponse sendInitialToBroker(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long planId, + @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody ActionRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.sendInitialToBroker(planId,user.userId(),user.username(),key,request)); + } + @PostMapping("/{planId}/submit-to-broker") @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") @BusinessAudit(event="CUSTOMS_SUBMIT_TO_BROKER",resourceType="CUSTOMS_CONTAINER") @@ -152,6 +163,14 @@ public class PlatformContainerCustomsController { return ApiResponse.success(service.completeAllBuy(planId,user.userId(),user.username(),key,request)); } + @GetMapping("/documents/{documentId}/receipt/actions") + @PreAuthorize("hasAuthority('platform:firstleg:customs:list')") + public ApiResponse> receiptActions(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long documentId) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.receiptActions(0,documentId,true)); + } + @GetMapping("/documents/{documentId}/download") @PreAuthorize("hasAuthority('platform:firstleg:customs:list')") public ResponseEntity downloadSource(@AuthenticationPrincipal AuthenticatedUser user, 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 e99febf5e..776b65125 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 @@ -20,18 +20,27 @@ public final class ContainerCustomsDtos { public record ReceiptVersionView(long version, FileView file, String operatorName, LocalDateTime createdAt) {} + public record ReceiptActionView(long id, int receiptVersion, String action, String reason, + String operatorName, LocalDateTime createdAt) {} + + public record BrokerSubmissionView(long id, long customsContainerId, long loadingPlanId, + String batchNo, String status, int sourceVersion, + String sentByName, LocalDateTime sentAt, long version) {} + public record DocumentView(long documentId, long tenantId, FileView source, List inboundIds, List inboundCodes, boolean frozen, FileView receipt, String receiptStatus, String receiptRejectReason, FileView finalDocument, - List receiptHistory) {} + List receiptHistory, + List confirmationHistory) {} public record ContainerView(long aggregateId, long loadingPlanId, long containerBookingId, String planNo, String cabinetNo, String containerNo, String status, boolean hasActualCargo, String merchantStatus, int version, int taxRefundCount, int coveredTaxRefundCount, long actualBoxCount, List inbounds, List documents, - LocalDateTime startedAt, LocalDateTime completedAt, LocalDateTime brokerSubmittedAt) {} + LocalDateTime startedAt, LocalDateTime completedAt, LocalDateTime brokerSubmittedAt, + BrokerSubmissionView brokerSubmission) {} public record ScopeRequest(@NotEmpty @Size(max = 200) List<@Positive Long> inboundIds, @Min(0) int expectedVersion) {} 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 aafb2231c..e4946da34 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 @@ -119,6 +119,32 @@ public interface ContainerCustomsMapper { @Select("SELECT broker_submitted_at FROM fl_customs_container WHERE id=#{id}") LocalDateTime brokerSubmittedAt(@Param("id") long id); + @Select("SELECT " + BROKER_SUBMISSION_COLUMNS + " FROM fl_customs_broker_submission s " + + "WHERE s.customs_container_id=#{containerId}") + BrokerSubmissionRow brokerSubmission(@Param("containerId") long containerId); + + @Select("SELECT " + BROKER_SUBMISSION_COLUMNS + " FROM fl_customs_broker_submission s " + + "WHERE s.customs_container_id=#{containerId} FOR UPDATE") + BrokerSubmissionRow lockBrokerSubmission(@Param("containerId") long containerId); + + @Insert(""" + INSERT INTO fl_customs_broker_submission + (id,customs_container_id,loading_plan_id,batch_no,status,source_version,source_snapshot_json, + sent_by,sent_by_name,sent_at,version,created_at,updated_at) + VALUES(#{id},#{containerId},#{planId},#{batchNo},'SENT',#{sourceVersion},CAST(#{snapshotJson} AS JSON), + #{operatorId},#{operatorName},CURRENT_TIMESTAMP(3),1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)) + """) + int insertBrokerSubmission(@Param("id") long id,@Param("containerId") long containerId, + @Param("planId") long planId,@Param("batchNo") String batchNo, + @Param("sourceVersion") int sourceVersion,@Param("snapshotJson") String snapshotJson, + @Param("operatorId") long operatorId,@Param("operatorName") String operatorName); + + @Update(""" + UPDATE fl_customs_container SET version=version+1,updated_at=CURRENT_TIMESTAMP(3) + WHERE id=#{id} AND version=#{version} AND status='DECLARING' + """) + int bumpVersionAfterBrokerSubmission(@Param("id") long id,@Param("version") int version); + @Select("SELECT " + DECLARATION_BATCH_COLUMNS + " FROM fl_customs_declaration_batch b " + "WHERE customs_container_id=#{containerId} ORDER BY created_at DESC,id DESC") List declarationBatches(@Param("containerId") long containerId); @@ -387,6 +413,30 @@ public interface ContainerCustomsMapper { @Param("status") String status,@Param("reason") String reason, @Param("operatorId") long operatorId,@Param("operatorName") String operatorName); + @Insert(""" + INSERT INTO fl_customs_completion_action_history + (id,completion_document_id,receipt_version,action,reason,operator_id,operator_name) + VALUES(#{id},#{completionId},#{receiptVersion},#{action},#{reason},#{operatorId},#{operatorName}) + """) + int insertReceiptAction(@Param("id") long id,@Param("completionId") long completionId, + @Param("receiptVersion") int receiptVersion,@Param("action") String action, + @Param("reason") String reason,@Param("operatorId") long operatorId, + @Param("operatorName") String operatorName); + + @Select(""" + + """) + List receiptActionHistory(@Param("documentId") long documentId,@Param("tenantId") Long tenantId); + @Select(""" SELECT COUNT(*) FROM fl_customs_customer_document d JOIN fl_customs_completion_document r ON r.source_document_id=d.id @@ -561,6 +611,8 @@ public interface ContainerCustomsMapper { String customerConfirmStatus,String customerRejectReason) {} record CompletionVersionRow(Integer documentVersion,Long fileId,String fileName,String fileHash,String contentType, Long sizeBytes,Long operatorId,String operatorName,LocalDateTime createdAt) {} + record ReceiptActionRow(Long id,Integer receiptVersion,String action,String reason, + String operatorName,LocalDateTime createdAt) {} 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, @@ -568,6 +620,8 @@ public interface ContainerCustomsMapper { String DECLARATION_BATCH_COLUMNS = "b.id id,b.customs_container_id customsContainerId,b.loading_plan_id loadingPlanId,"+ "b.batch_no batchNo,b.status status,b.source_version sourceVersion,b.customs_entry_no customsEntryNo,b.submitted_by_name submittedByName,"+ "b.submitted_at submittedAt,b.acknowledged_by_name acknowledgedByName,b.acknowledged_at acknowledgedAt,b.remark remark,b.version version,b.created_at createdAt,b.updated_at updatedAt"; + String BROKER_SUBMISSION_COLUMNS = "s.id id,s.customs_container_id customsContainerId,s.loading_plan_id loadingPlanId,"+ + "s.batch_no batchNo,s.status status,s.source_version sourceVersion,s.sent_by_name sentByName,s.sent_at sentAt,s.version version"; String CLEARANCE_RESULT_COLUMNS = "r.id id,r.declaration_batch_id declarationBatchId,r.customs_container_id customsContainerId,"+ "r.result_type resultType,r.result_status resultStatus,r.customs_entry_no customsEntryNo,r.occurred_at occurredAt,"+ "r.resolution resolution,r.evidence_file_id evidenceFileId,r.recorded_by recordedBy,r.recorded_by_name recordedByName,"+ @@ -576,6 +630,8 @@ public interface ContainerCustomsMapper { Integer sourceVersion,String customsEntryNo,String submittedByName,LocalDateTime submittedAt, String acknowledgedByName,LocalDateTime acknowledgedAt,String remark,Integer version, LocalDateTime createdAt,LocalDateTime updatedAt) {} + record BrokerSubmissionRow(Long id,Long customsContainerId,Long loadingPlanId,String batchNo,String status, + Integer sourceVersion,String sentByName,LocalDateTime sentAt,Integer version) {} record ClearanceResultRow(Long id,Long declarationBatchId,Long customsContainerId,String resultType,String resultStatus, String customsEntryNo,LocalDateTime occurredAt,String resolution,Long evidenceFileId, Long recordedBy,String recordedByName,LocalDateTime recordedAt,Long previousResultId) {} 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 5d6b7eb12..b4ab07155 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 @@ -21,6 +21,7 @@ import org.springframework.web.multipart.MultipartFile; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.*; import java.util.function.Function; @@ -173,6 +174,35 @@ public class ContainerCustomsService { return platformView(planId); } + @Transactional + public ContainerView sendInitialToBroker(long planId, long operatorId, String operatorName, String key, + ActionRequest request) { + String action = "SEND_CUSTOMS_INITIAL_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.taxRefundCount(planId) == 0) throw conflict("全买单柜无需发送报关资料给报关行"); + if (mapper.brokerSubmission(container.id()) != null) throw conflict("当前柜已完成首次发送报关行批次"); + List documents = mapper.documents(container.id(), null); + if (documents.isEmpty() || documents.stream().anyMatch(document -> document.frozenAt() == null)) { + throw conflict("报关资料尚未全部冻结,不能发送给报关行"); + } + long submissionId = IdWorker.getId(); + String batchNo = brokerBatchNo(planId); + String snapshot = brokerSubmissionSnapshot(container.id()); + if (mapper.insertBrokerSubmission(submissionId, container.id(), planId, batchNo, container.version(), snapshot, + operatorId, operatorName) != 1) throw conflict("首次发送报关行批次创建失败"); + if (mapper.bumpVersionAfterBrokerSubmission(container.id(), container.version()) != 1) throw concurrent(); + history(container, "SEND_CUSTOMS_INITIAL_TO_BROKER", container.status(), container.status(), + request.remark(), operatorId, operatorName); + completeCommand(operatorId, action, key, submissionId); + return platformView(planId); + } + @Transactional public ContainerView submitToBroker(long planId, long operatorId, String operatorName, String key, ActionRequest request) { String action = "SUBMIT_CUSTOMS_TO_BROKER_" + planId; @@ -185,6 +215,7 @@ public class ContainerCustomsService { if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent(); if (mapper.taxRefundCount(container.loadingPlanId()) == 0) throw conflict("全买单柜无需发送客户回执给报关行"); if (mapper.unconfirmedReceiptCount(container.id()) != 0) throw conflict("仍有报关行回执未完成客户确认"); + if (mapper.brokerSubmission(container.id()) == null) 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); @@ -209,6 +240,9 @@ public class ContainerCustomsService { if (mapper.brokerSubmittedAt(container.id()) != null) { throw conflict("客户确认回执已发送给报关行,不可再修改回执"); } + if (mapper.brokerSubmission(container.id()) == null) { + throw conflict("当前柜尚未完成首次发送报关行资料"); + } CompletionRow completion = mapper.lockCompletion(documentId); CustomsFileClient.Metadata metadata = upload(source.tenantId(), RECEIPT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file); long completionId; @@ -249,6 +283,8 @@ public class ContainerCustomsService { if (!confirm && (reason == null || reason.isBlank())) throw bad("打回回执必须填写原因"); if (mapper.confirmReceipt(documentId, tenantId, confirm ? "CONFIRMED" : "REJECTED", confirm ? null : trim(reason), operatorId, operatorName) != 1) throw conflict("回执状态已变化,请刷新后重试"); + if (mapper.insertReceiptAction(IdWorker.getId(), receipt.id(), receipt.version(), confirm ? "CONFIRM" : "REJECT", + trim(reason), operatorId, operatorName) != 1) throw concurrent(); if (confirm && mapper.pendingReceiptConfirmationCount(container.id()) == 0) { history(container, "ALL_CUSTOMS_RECEIPTS_CONFIRMED", container.status(), container.status(), "客户已确认全部报关行回执,待发送给报关行执行", operatorId, operatorName); } @@ -439,6 +475,12 @@ public class ContainerCustomsService { row.recordedBy() == null ? 0 : row.recordedBy(), row.recordedByName(), row.recordedAt(), row.previousResultId()); } + private BrokerSubmissionView brokerSubmissionView(BrokerSubmissionRow row) { + return new BrokerSubmissionView(row.id(), row.customsContainerId(), row.loadingPlanId(), row.batchNo(), + row.status(), row.sourceVersion() == null ? 0 : row.sourceVersion(), row.sentByName(), row.sentAt(), + row.version() == null ? 0 : row.version()); + } + private String declarationSnapshot(long containerId) { List> snapshot = mapper.documents(containerId, null).stream().map(doc -> { CompletionRow receipt = mapper.completion(doc.id()); @@ -451,6 +493,26 @@ public class ContainerCustomsService { catch (JsonProcessingException ex) { throw new IllegalStateException(ex); } } + private String brokerBatchNo(long planId) { + PlanRow plan = requiredPlan(planId); + String planNo = trim(plan.planNo()); + if (planNo != null && planNo.matches("[A-Za-z0-9_-]{1,52}")) return "BR-" + planNo + "-01"; + String suffix = UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase(Locale.ROOT); + return "BR-" + LocalDate.now() + "-" + suffix; + } + + private String brokerSubmissionSnapshot(long containerId) { + List> snapshot = mapper.documents(containerId, null).stream().map(document -> { + List scopes = mapper.frozenScopes(document.id()); + return Map.of("documentId", document.id(), "tenantId", document.tenantId(), + "sourceFileId", document.fileId(), "sourceFileName", document.fileName(), "sourceFileHash", document.fileHash(), + "frozenVersion", document.frozenVersion(), "inboundIds", scopes.stream().map(ScopeRow::inboundId).toList(), + "inboundCodes", scopes.stream().map(ScopeRow::inboundCode).toList()); + }).toList(); + try { return objectMapper.writeValueAsString(snapshot); } + catch (JsonProcessingException ex) { throw new IllegalStateException(ex); } + } + public Download downloadSource(long tenantId, long documentId, boolean platform) { DocumentRow source = requiredDocument(documentId); if (!platform && source.tenantId() != tenantId) throw notFound("客户报送资料不存在"); @@ -458,6 +520,18 @@ public class ContainerCustomsService { return download(source.tenantId(), SOURCE_FILE_TYPE, Long.toString(container.loadingPlanId()), source.fileId(), source.fileName()); } + public List receiptActions(long tenantId, long documentId, boolean platform) { + DocumentRow source = requiredDocument(documentId); + if (!platform && source.tenantId() != tenantId) throw notFound("客户报送资料不存在"); + return receiptActionViews(documentId, platform ? null : tenantId); + } + + private List receiptActionViews(long documentId, Long tenantId) { + return Optional.ofNullable(mapper.receiptActionHistory(documentId, tenantId)).orElseGet(List::of).stream() + .map(row -> new ReceiptActionView(row.id(), row.receiptVersion(), row.action(), row.reason(), + row.operatorName(), row.createdAt())).toList(); + } + public Download downloadCompletion(long tenantId, long documentId, boolean platform) { DocumentRow source = requiredDocument(documentId); ContainerRow container = mapper.containerById(source.customsContainerId()); @@ -570,9 +644,11 @@ public class ContainerCustomsService { long actualBoxCount = mapper.actualBoxCount(container.loadingPlanId(), tenantId, tenantId == null ? null : "TAX_REFUND"); String merchantStatus=tenantId==null?null:merchantStatus(container.status(),refund,coveredRefund); + BrokerSubmissionRow submission = mapper.brokerSubmission(container.id()); return new ContainerView(container.id(), container.loadingPlanId(), container.containerBookingId(), plan.planNo(), plan.cabinetNo(), plan.containerNo(), container.status(), !rows.isEmpty(), merchantStatus, container.version(), refund, coveredRefund, - actualBoxCount, inbounds, documentViews, container.startedAt(), container.completedAt(), container.brokerSubmittedAt()); + actualBoxCount, inbounds, documentViews, container.startedAt(), container.completedAt(), container.brokerSubmittedAt(), + submission == null ? null : brokerSubmissionView(submission)); } private DocumentView documentView(DocumentRow source, boolean merchant) { @@ -587,7 +663,8 @@ public class ContainerCustomsService { scopes.stream().map(ScopeRow::inboundCode).toList(), source.frozenAt() != null, result, completion == null ? null : completion.customerConfirmStatus(), completion == null ? null : completion.customerRejectReason(), - finalDocument == null ? null : file(finalDocument), receiptHistory); + finalDocument == null ? null : file(finalDocument), receiptHistory, + receiptActionViews(source.id(), merchant ? source.tenantId() : null)); } private void completeContainer(ContainerRow container, long operatorId, String operatorName, String remark) { 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 fd34035cb..a0c425f30 100644 --- a/firstleg/src/main/resources/db/phase3-container-customs-schema.sql +++ b/firstleg/src/main/resources/db/phase3-container-customs-schema.sql @@ -26,6 +26,28 @@ CREATE TABLE IF NOT EXISTS fl_customs_container ( ('WAITING_CUSTOMER_DOCUMENTS','READY_TO_START','DECLARING','COMPLETED')) ) ENGINE=InnoDB COMMENT='装柜完成后的整柜出口报关聚合'; +CREATE TABLE IF NOT EXISTS fl_customs_broker_submission ( + id BIGINT UNSIGNED NOT NULL COMMENT '首次发送报关行批次ID', + customs_container_id BIGINT UNSIGNED NOT NULL COMMENT '所属整柜报关聚合ID', + loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID快照', + batch_no VARCHAR(64) NOT NULL COMMENT '首次发送报关行批次号', + status VARCHAR(16) NOT NULL COMMENT '发送状态:SENT已发送', + source_version INT UNSIGNED NOT NULL COMMENT '发送时整柜报关聚合版本', + source_snapshot_json JSON NOT NULL COMMENT '发送时冻结资料版本与覆盖范围快照', + sent_by BIGINT UNSIGNED NOT NULL COMMENT '首次发送操作人ID', + sent_by_name VARCHAR(64) NULL COMMENT '首次发送操作人名称快照', + sent_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_broker_submission_container(customs_container_id), + UNIQUE KEY uk_customs_broker_submission_batch(batch_no), + KEY idx_customs_broker_submission_plan(loading_plan_id,sent_at), + CONSTRAINT fk_customs_broker_submission_container FOREIGN KEY(customs_container_id) REFERENCES fl_customs_container(id), + CONSTRAINT chk_customs_broker_submission_status CHECK(status IN('SENT')) +) ENGINE=InnoDB COMMENT='冻结退税报关资料首次发送报关行批次事实'; + CREATE TABLE IF NOT EXISTS fl_customs_customer_document ( id BIGINT UNSIGNED NOT NULL COMMENT '客户报送资料ID', customs_container_id BIGINT UNSIGNED NOT NULL COMMENT '所属整柜报关聚合ID', @@ -136,6 +158,21 @@ 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_completion_action_history ( + id BIGINT UNSIGNED NOT NULL COMMENT '客户回执动作历史ID', + completion_document_id BIGINT UNSIGNED NOT NULL COMMENT '报关行回执当前版本逻辑根ID', + receipt_version INT UNSIGNED NOT NULL COMMENT '动作对应的回执文件版本', + action VARCHAR(16) NOT NULL COMMENT '客户动作:CONFIRM确认、REJECT打回', + reason VARCHAR(500) NULL COMMENT '客户打回原因;确认动作为空', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '客户操作人ID', + operator_name VARCHAR(64) NULL COMMENT '客户操作人名称快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '客户动作时间', + PRIMARY KEY(id), + KEY idx_customs_completion_action_history(completion_document_id,receipt_version,created_at,id), + CONSTRAINT fk_customs_completion_action_history_document FOREIGN KEY(completion_document_id) REFERENCES fl_customs_completion_document(id), + CONSTRAINT chk_customs_completion_action_history_action CHECK(action IN('CONFIRM','REJECT')) +) 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', diff --git a/firstleg/src/main/resources/openapi/customs-v1.yaml b/firstleg/src/main/resources/openapi/customs-v1.yaml index 0c0519f64..c10bb9c70 100644 --- a/firstleg/src/main/resources/openapi/customs-v1.yaml +++ b/firstleg/src/main/resources/openapi/customs-v1.yaml @@ -21,6 +21,13 @@ 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/{planId}/send-to-broker: + post: + 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/{planId}/declaration-batches: get: summary: 查询整柜正式申报批次和海关结果 @@ -77,6 +84,12 @@ paths: - {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}/receipt/actions: + get: + summary: 平台查询客户回执确认与打回历史 + x-permission: platform:firstleg:customs:list + parameters: [{$ref: '#/components/parameters/DocumentId'}] + responses: {'200': {$ref: '#/components/responses/ReceiptActionListResponse'}} /api/platform/v1/customs/loading-plans/documents/{documentId}/download: get: summary: 平台下载客户报送资料 @@ -151,6 +164,12 @@ paths: - {$ref: '#/components/parameters/IdempotencyKey'} - {name: expectedVersion, in: query, required: true, schema: {type: integer, minimum: 1}} responses: {'200': {$ref: '#/components/responses/ContainerResponse'}} + /api/merchant/v1/customs/documents/{documentId}/receipt/actions: + get: + summary: 客户查询自身资料的回执确认与打回历史 + x-permission: merchant:order:view + parameters: [{$ref: '#/components/parameters/DocumentId'}] + responses: {'200': {$ref: '#/components/responses/ReceiptActionListResponse'}} /api/merchant/v1/customs/documents/{documentId}/download: get: summary: 客户下载自己当前报送资料 @@ -204,6 +223,7 @@ components: responses: ContainerResponse: {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerEnvelope'}}}} DocumentResponse: {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentEnvelope'}}}} + ReceiptActionListResponse: {description: 回执确认与打回历史, content: {application/json: {schema: {$ref: '#/components/schemas/ReceiptActionListEnvelope'}}}} BinaryResponse: {description: 文件二进制, headers: {Content-Disposition: {schema: {type: string}}, Cache-Control: {schema: {type: string, example: no-store}}}, content: {application/octet-stream: {schema: {type: string, format: binary}}}} DeclarationBatchResponse: {description: 正式申报批次, content: {application/json: {schema: {$ref: '#/components/schemas/DeclarationBatchEnvelope'}}}} DeclarationBatchListResponse: {description: 正式申报批次列表, content: {application/json: {schema: {$ref: '#/components/schemas/DeclarationBatchListEnvelope'}}}} @@ -222,13 +242,16 @@ components: 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}}} ReceiptVersionView: {type: object, required: [version, file, createdAt], properties: {version: {type: integer}, file: {$ref: '#/components/schemas/FileView'}, operatorName: {type: string, nullable: true}, createdAt: {type: string, format: date-time}}} - DocumentView: {type: object, required: [documentId, tenantId, source, inboundIds, inboundCodes, frozen, receiptHistory], 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: 真实报关完税文件}, receiptHistory: {type: array, items: {$ref: '#/components/schemas/ReceiptVersionView'}, 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}, brokerSubmittedAt: {type: string, format: date-time, nullable: true, description: 已将客户确认后的回执发送给报关行执行的时间;不表示报关完成}, completedAt: {type: string, format: date-time, nullable: true}}} + ReceiptActionView: {type: object, required: [id, receiptVersion, action, createdAt], properties: {id: {$ref: '#/components/schemas/LongId'}, receiptVersion: {type: integer, minimum: 1}, action: {type: string, enum: [CONFIRM, REJECT]}, reason: {type: string, nullable: true}, operatorName: {type: string, nullable: true}, createdAt: {type: string, format: date-time}}} + BrokerSubmissionView: {type: object, required: [id, customsContainerId, loadingPlanId, batchNo, status, sourceVersion, sentAt, version], properties: {id: {$ref: '#/components/schemas/LongId'}, customsContainerId: {$ref: '#/components/schemas/LongId'}, loadingPlanId: {$ref: '#/components/schemas/LongId'}, batchNo: {type: string}, status: {type: string, enum: [SENT]}, sourceVersion: {type: integer}, sentByName: {type: string, nullable: true}, sentAt: {type: string, format: date-time}, version: {type: integer}}} + DocumentView: {type: object, required: [documentId, tenantId, source, inboundIds, inboundCodes, frozen, receiptHistory, confirmationHistory], 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: 真实报关完税文件}, receiptHistory: {type: array, items: {$ref: '#/components/schemas/ReceiptVersionView'}, description: 报关行回执历次上传记录,按版本升序}, confirmationHistory: {type: array, items: {$ref: '#/components/schemas/ReceiptActionView'}, 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}, brokerSubmission: {allOf: [{$ref: '#/components/schemas/BrokerSubmissionView'}], nullable: true, description: 资料冻结后首次发送报关行的独立事实}, brokerSubmittedAt: {type: string, format: date-time, nullable: true, description: 已将客户确认后的回执发送给报关行执行的时间;不表示报关完成}, completedAt: {type: string, format: date-time, nullable: true}}} DeclarationBatch: {type: object, required: [id, customsContainerId, loadingPlanId, status, sourceVersion, version, results], properties: {id: {$ref: '#/components/schemas/LongId'}, customsContainerId: {$ref: '#/components/schemas/LongId'}, loadingPlanId: {$ref: '#/components/schemas/LongId'}, batchNo: {type: string}, status: {type: string, enum: [READY, SUBMITTED, ACKNOWLEDGED, RESULT_PENDING, CLOSED, CANCELLED]}, sourceVersion: {type: integer}, customsEntryNo: {type: string, nullable: true}, version: {type: integer}, submittedAt: {type: string, format: date-time, nullable: true}, submittedByName: {type: string, nullable: true}, acknowledgedAt: {type: string, format: date-time, nullable: true}, acknowledgedByName: {type: string, nullable: true}, remark: {type: string, nullable: true}, results: {type: array, items: {$ref: '#/components/schemas/ClearanceResult'}}}} ClearanceResult: {type: object, required: [id, declarationBatchId, customsContainerId, resultType, resultStatus, occurredAt, recordedAt], properties: {id: {$ref: '#/components/schemas/LongId'}, declarationBatchId: {$ref: '#/components/schemas/LongId'}, customsContainerId: {$ref: '#/components/schemas/LongId'}, resultType: {type: string, enum: [INSPECTION, SUPPLEMENT_REQUIRED, RELEASED, DETAINED, OTHER]}, resultStatus: {type: string, enum: [OPEN, RESOLVED, CANCELLED]}, customsEntryNo: {type: string, nullable: true}, occurredAt: {type: string, format: date-time}, resolution: {type: string, nullable: true}, evidenceFileId: {$ref: '#/components/schemas/LongId', nullable: true}, recordedBy: {$ref: '#/components/schemas/LongId'}, recordedByName: {type: string, nullable: true}, recordedAt: {type: string, format: date-time}, previousResultId: {$ref: '#/components/schemas/LongId', nullable: true}}} DeclarationBatchEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {$ref: '#/components/schemas/DeclarationBatch'}}}]} DeclarationBatchListEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {type: array, items: {$ref: '#/components/schemas/DeclarationBatch'}}}}]} ClearanceResultEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {$ref: '#/components/schemas/ClearanceResult'}}}]} ClearanceResultListEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {type: array, items: {$ref: '#/components/schemas/ClearanceResult'}}}}]} + ReceiptActionListEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {type: array, items: {$ref: '#/components/schemas/ReceiptActionView'}}}}]} 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/ContainerCustomsControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsControllerContractTest.java index 2a0adb447..3a2d1f28c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsControllerContractTest.java @@ -17,7 +17,9 @@ class ContainerCustomsControllerContractTest { .containsExactly("/api/platform/v1/customs/loading-plans"); assertPermission(PlatformContainerCustomsController.class,"detail","platform:firstleg:customs:list"); assertPermission(PlatformContainerCustomsController.class,"start","platform:firstleg:customs:manage"); + assertPermission(PlatformContainerCustomsController.class,"sendInitialToBroker","platform:firstleg:customs:manage"); assertPermission(PlatformContainerCustomsController.class,"uploadCompletion","platform:firstleg:customs:manage"); + assertPermission(PlatformContainerCustomsController.class,"receiptActions","platform:firstleg:customs:list"); assertPermission(PlatformContainerCustomsController.class,"completeAllBuy","platform:firstleg:customs:manage"); assertPermission(PlatformContainerCustomsController.class,"declarationBatches","platform:firstleg:customs:list"); assertPermission(PlatformContainerCustomsController.class,"createDeclarationBatch","platform:firstleg:customs:manage"); @@ -34,12 +36,13 @@ class ContainerCustomsControllerContractTest { assertPermission(MerchantContainerCustomsController.class,"scope","merchant:order:edit"); assertPermission(MerchantContainerCustomsController.class,"delete","merchant:order:edit"); assertPermission(MerchantContainerCustomsController.class,"clearanceResults","merchant:order:view"); + assertPermission(MerchantContainerCustomsController.class,"receiptActions","merchant:order:view"); } @Test void openApiPublishesEveryPathParameterAndGeneratableDtos()throws Exception{ String yaml=new ClassPathResource("openapi/customs-v1.yaml").getContentAsString(java.nio.charset.StandardCharsets.UTF_8); new Yaml().load(yaml); assertThat(yaml).contains("ContainerView:","DocumentView:","FileView:","InboundView:","ActionRequest:","ScopeRequest:", - "DeclarationBatch:","ClearanceResult:","declaration-batches","clearance-results") + "DeclarationBatch:","BrokerSubmissionView:","ClearanceResult:","ReceiptActionView:","confirmationHistory","brokerSubmission","send-to-broker","declaration-batches","clearance-results") .contains("merchantStatus:","WAITING_UPLOAD","CUSTOMS_COMPLETED","loadingPlanId:","hasActualCargo:","nullable: true") .contains("LOADING_LIST_BUY_DECLARATION_ITEMS") .doesNotContain("/cases/","CUSTOMS_DECLARATION"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMapperContractTest.java index 8f9e16cea..1e1a59fab 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsMapperContractTest.java @@ -46,6 +46,7 @@ class ContainerCustomsMapperContractTest { assertRecordProjection("completion", ContainerCustomsMapper.CompletionRow.class, long.class); assertRecordProjection("lockCompletion", ContainerCustomsMapper.CompletionRow.class, long.class); assertRecordProjectionWithoutVersion("completionVersions", ContainerCustomsMapper.CompletionVersionRow.class, long.class); + assertRecordProjectionWithoutVersion("receiptActionHistory", ContainerCustomsMapper.ReceiptActionRow.class, long.class, Long.class); } @Test void declarationAndClearanceQueriesMapExplicitHistoricalFacts() throws Exception { @@ -57,6 +58,11 @@ class ContainerCustomsMapperContractTest { assertRecordProjectionWithoutVersion("lockClearanceResult", ContainerCustomsMapper.ClearanceResultRow.class, long.class); } + @Test void brokerSubmissionQueriesMapIndependentFirstSendFact() throws Exception { + assertRecordProjection("brokerSubmission", ContainerCustomsMapper.BrokerSubmissionRow.class, long.class); + assertRecordProjection("lockBrokerSubmission", ContainerCustomsMapper.BrokerSubmissionRow.class, long.class); + } + private static void assertRecordProjection(String method,Class recordType,Class... parameterTypes) throws Exception { assertProjection(method, recordType, true, parameterTypes); } 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 363ae3538..279227547 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 @@ -150,6 +150,61 @@ class ContainerCustomsServiceTest { verify(mapper,never()).freezeScopes(anyLong()); } + @Test void initialBrokerSendCreatesIndependentFrozenSnapshotBatch() throws Exception { + var inbound=new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","TAX_REFUND"); + var declaring=container(ContainerCustomsService.DECLARING,3); + var source=new ContainerCustomsMapper.DocumentRow(31L,2L,7L,10L,"source.bin","a".repeat(64), + "application/octet-stream",1L,"ACTIVE",5L,java.time.LocalDateTime.now(),1,java.time.LocalDateTime.now(),1); + var submission=new ContainerCustomsMapper.BrokerSubmissionRow(41L,2L,9L,"BR-LP9-01","SENT",3,"admin", + java.time.LocalDateTime.now(),1); + when(mapper.claimCommand(anyLong(),eq(5L),eq("SEND_CUSTOMS_INITIAL_TO_BROKER_9"),eq("send-key"),anyString())).thenReturn(1); + when(mapper.lockContainer(9L)).thenReturn(declaring); + when(mapper.taxRefundCount(9L)).thenReturn(1); + when(mapper.brokerSubmission(2L)).thenReturn(null,submission); + when(mapper.documents(2L,null)).thenReturn(List.of(source)); + when(mapper.frozenScopes(31L)).thenReturn(List.of(new ContainerCustomsMapper.ScopeRow(31L,11L,21L,"I"))); + when(objectMapper.writeValueAsString(any())).thenReturn("[]"); + when(mapper.insertBrokerSubmission(anyLong(),eq(2L),eq(9L),eq("BR-LP9-01"),eq(3),eq("[]"),eq(5L),eq("admin"))).thenReturn(1); + when(mapper.bumpVersionAfterBrokerSubmission(2L,3)).thenReturn(1); + when(mapper.insertHistory(anyLong(),eq(2L),eq("SEND_CUSTOMS_INITIAL_TO_BROKER"),eq(ContainerCustomsService.DECLARING),eq(ContainerCustomsService.DECLARING),isNull(),eq(5L),eq("admin"))).thenReturn(1); + when(mapper.completeCommand(eq(5L),eq("SEND_CUSTOMS_INITIAL_TO_BROKER_9"),eq("send-key"),anyLong())).thenReturn(1); + when(mapper.planInbounds(9L)).thenReturn(List.of(inbound)); + when(mapper.plan(9L)).thenReturn(new ContainerCustomsMapper.PlanRow(9L,"LP9",8L,"COMPLETED","CAB","C")); + when(mapper.container(9L)).thenReturn(declaring); + when(mapper.actualBoxCount(9L,null,null)).thenReturn(0L); + when(mapper.finalDocument(31L)).thenReturn(null); + when(mapper.completion(31L)).thenReturn(null); + + var result=service.sendInitialToBroker(9L,5L,"admin","send-key",new ActionRequest(3,true,null)); + + assertThat(result.brokerSubmission()).isNotNull(); + assertThat(result.brokerSubmission().batchNo()).isEqualTo("BR-LP9-01"); + assertThat(result.brokerSubmission().sourceVersion()).isEqualTo(3); + verify(mapper).insertBrokerSubmission(anyLong(),eq(2L),eq(9L),eq("BR-LP9-01"),eq(3),eq("[]"),eq(5L),eq("admin")); + } + + @Test void initialBrokerSendRejectsStaleAggregateVersion(){ + var declaring=container(ContainerCustomsService.DECLARING,4); + when(mapper.claimCommand(anyLong(),eq(5L),eq("SEND_CUSTOMS_INITIAL_TO_BROKER_9"),eq("stale-key"),anyString())).thenReturn(1); + when(mapper.lockContainer(9L)).thenReturn(declaring); + + assertThatThrownBy(()->service.sendInitialToBroker(9L,5L,"admin","stale-key",new ActionRequest(3,true,null))) + .isInstanceOf(BizException.class).hasMessageContaining("其他人修改"); + verify(mapper,never()).insertBrokerSubmission(anyLong(),anyLong(),anyLong(),anyString(),anyInt(),anyString(),anyLong(),anyString()); + } + + @Test void receiptUploadRejectsWhenInitialBrokerSubmissionIsMissing(){ + var source=document(1L);var container=container(ContainerCustomsService.DECLARING,3); + when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("before-send"),anyString())).thenReturn(1); + when(mapper.document(1L)).thenReturn(source);when(mapper.lockContainerById(2L)).thenReturn(container); + when(mapper.lockDocument(1L)).thenReturn(source);when(mapper.brokerSubmission(2L)).thenReturn(null); + + assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","before-send",0, + new MockMultipartFile("file","receipt.pdf","application/pdf",new byte[]{1}))) + .isInstanceOf(BizException.class).hasMessageContaining("首次发送报关行资料"); + verifyNoInteractions(files); + } + @Test void declaringContainerFreezesUploadReplacementScopeAndDeletionWithoutDrift(){ var inbound=new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","TAX_REFUND"); var declaring=container(ContainerCustomsService.DECLARING,3); @@ -247,6 +302,7 @@ class ContainerCustomsServiceTest { var source=document(1L);var container=container(ContainerCustomsService.DECLARING,3); 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.brokerSubmission(2L)).thenReturn(brokerSubmission()); when(mapper.lockDocument(1L)).thenReturn(source);when(mapper.lockCompletion(1L)).thenReturn(null); 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, @@ -307,6 +363,7 @@ class ContainerCustomsServiceTest { when(mapper.command(5L,"UPLOAD_COMPLETION_1","completion-key")).thenAnswer(invocation->commandState.get()); when(mapper.lockContainerById(2L)).thenAnswer(invocation->containerState.get()); when(mapper.lockDocument(1L)).thenReturn(source); + when(mapper.brokerSubmission(2L)).thenReturn(brokerSubmission()); when(mapper.lockCompletion(1L)).thenAnswer(invocation->completionState.get()); 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)); @@ -382,6 +439,7 @@ class ContainerCustomsServiceTest { } private static ContainerCustomsMapper.DocumentRow document(long id){return new ContainerCustomsMapper.DocumentRow(id,2L,7L,10L,"source.bin","a".repeat(64),"application/octet-stream",1L,"ACTIVE",5L,java.time.LocalDateTime.now(),1,java.time.LocalDateTime.now(),1);} + private static ContainerCustomsMapper.BrokerSubmissionRow brokerSubmission(){return new ContainerCustomsMapper.BrokerSubmissionRow(41L,2L,9L,"BR-LP9-01","SENT",3,"admin",java.time.LocalDateTime.now(),1);} private static ContainerCustomsMapper.ContainerRow container(String status,int version){return new ContainerCustomsMapper.ContainerRow(2L,9L,8L,status,null,null,null,null,null,null,null,null,version);} private void assertMerchantOrderNotFound(long tenantId,long orderId){ assertThatThrownBy(()->service.merchantOrderView(tenantId,orderId)) diff --git a/scripts/admin-test-env-modules/container-customs-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/container-customs-acceptance-contract.test.mjs index d5d3f09ed..e96684265 100644 --- a/scripts/admin-test-env-modules/container-customs-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/container-customs-acceptance-contract.test.mjs @@ -69,6 +69,7 @@ test('HTTP scenario contains file, state, idempotency, concurrency and zero-drif for (const token of ['EXACT_50_MB + 1', '.customblob', 'application/octet-stream', '缺退税资料禁止开始且零漂移', '开始后资料范围锁定A0409零漂移', "['上传'", "['覆盖'", "['改关联'", "['删除'", '开始后${label}锁定A0409零漂移', + '首次发送报关行并生成独立批次', '混合柜冻结资料首次发送报关行并生成独立批次', '回执未确认且未发送执行时禁止上传完税文件', '客户确认整份回执后仍处于报关中', '真实报关完税文件齐全后纯退税柜完成', '客户确认回执后仍未完成整柜', '混合柜平台详情精确包含BUY与TAX长ID票', 'Client只见本客户TAX票且完全隐藏BUY票', '买单导出只处理实际装柜BUY行', '混合柜最后一份完成文件后整柜COMPLETED', 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 d44cff54d..c29cd76b2 100644 --- a/scripts/admin-test-env-modules/container-customs-acceptance-matrix.mjs +++ b/scripts/admin-test-env-modules/container-customs-acceptance-matrix.mjs @@ -17,6 +17,7 @@ export const REQUIRED_ROUTE_OPERATIONS = Object.freeze([ 'merchantDeleteDocument', 'merchantConfirmReceipt', 'platformStart', + 'platformSendInitialToBroker', 'platformUploadReceipt', 'platformSubmitToBroker', 'platformUploadFinal', 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 51785dbd0..0d92e3282 100644 --- a/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs +++ b/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs @@ -88,6 +88,9 @@ function routes(context, merchantToken) { platformStart: (ctx, fixture, request, idempotencyKey) => ctx.request('POST', `${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/start`, request, ctx.token, { 'Idempotency-Key': idempotencyKey }), + platformSendInitialToBroker: (ctx, fixture, request, idempotencyKey) => ctx.request('POST', + `${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/send-to-broker`, request, ctx.token, + { 'Idempotency-Key': idempotencyKey }), platformUploadReceipt: (ctx, fixture, request, idempotencyKey) => multipart( `${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/receipt/upload?expectedVersion=${Number(request.expectedVersion)}`, {}, request, ctx.token, idempotencyKey), diff --git a/scripts/admin-test-env-modules/phase3-container-customs.mjs b/scripts/admin-test-env-modules/phase3-container-customs.mjs index b467a8b65..108fbc619 100644 --- a/scripts/admin-test-env-modules/phase3-container-customs.mjs +++ b/scripts/admin-test-env-modules/phase3-container-customs.mjs @@ -160,7 +160,17 @@ async function runRefund(context, routes, fixture) { && started.body?.data?.documents?.every?.((item) => item.frozen === true), evidence(started), OPTIONS) - const frozenSnapshot = started.body?.data + const initialSent = await routes.platformSendInitialToBroker(context, fixture, { + expectedVersion: Number(started.body?.data?.version), confirm: true, remark: context.marker, + }, key(context, 'refund-send-initial-to-broker')) + context.assert(MODULE, '冻结资料首次发送报关行并生成独立批次', initialSent, + success(initialSent) && initialSent.body?.data?.status === 'DECLARING' + && initialSent.body?.data?.brokerSubmission?.status === 'SENT' + && typeof initialSent.body?.data?.brokerSubmission?.batchNo === 'string' + && initialSent.body?.data?.brokerSubmission?.batchNo.length > 0, + evidence(initialSent), OPTIONS) + + const frozenSnapshot = initialSent.body?.data const lockedOperations = [ ['上传', () => routes.merchantUploadDocument(context, fixture, mutable, { ...mutable.document, fileName: `${context.marker}-locked.customblob`, content: Uint8Array.from([1]), @@ -335,6 +345,16 @@ async function runMixedConcurrency(context, routes, fixture) { }, key(context, 'locked-scope')) await assertZeroDrift(context, '开始后资料范围锁定A0409零漂移', routes, fixture, { body: { data: frozen } }, locked) + const initialSent = await routes.platformSendInitialToBroker(context, fixture, { + expectedVersion: Number(frozen?.version), confirm: true, remark: context.marker, + }, key(context, 'mixed-send-initial-to-broker')) + context.assert(MODULE, '混合柜冻结资料首次发送报关行并生成独立批次', initialSent, + success(initialSent) && initialSent.body?.data?.status === 'DECLARING' + && initialSent.body?.data?.brokerSubmission?.status === 'SENT' + && typeof initialSent.body?.data?.brokerSubmission?.batchNo === 'string' + && initialSent.body?.data?.brokerSubmission?.batchNo.length > 0, + evidence(initialSent), OPTIONS) + const receiptRequest = { documentId: customer.documentId, expectedVersion: 0, fileName: `${context.marker}-mixed-broker-receipt.pdf`,