fix(customs): add broker submission and receipt action history

This commit is contained in:
2026-09-28 13:21:37 +08:00
parent ab64699245
commit e63cb2b29e
15 changed files with 365 additions and 8 deletions
@@ -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'))) 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='装柜完成后的整柜出口报关聚合'; ) 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 ( CREATE TABLE fl_customs_container_history (
id bigint unsigned NOT NULL COMMENT '报关历史记录ID', id bigint unsigned NOT NULL COMMENT '报关历史记录ID',
customs_container_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) 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='报关完成文件不可变覆盖版本'; ) 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 ( CREATE TABLE fl_customs_customer_document_inbound (
document_id bigint unsigned NOT NULL COMMENT '客户报送资料ID', document_id bigint unsigned NOT NULL COMMENT '客户报送资料ID',
inbound_id bigint unsigned NOT NULL COMMENT '覆盖入仓单ID', inbound_id bigint unsigned NOT NULL COMMENT '覆盖入仓单ID',
@@ -84,6 +84,13 @@ public class MerchantContainerCustomsController {
return ApiResponse.success(service.deleteSource(tenantId,documentId,user.userId(),key,expectedVersion)); return ApiResponse.success(service.deleteSource(tenantId,documentId,user.userId(),key,expectedVersion));
} }
@GetMapping("/documents/{documentId}/receipt/actions")
@PreAuthorize("hasAuthority('merchant:order:view')")
public ApiResponse<List<ReceiptActionView>> receiptActions(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long documentId) {
return ApiResponse.success(service.receiptActions(FirstlegScopeGuard.merchant(user),documentId,false));
}
@GetMapping("/documents/{documentId}/download") @GetMapping("/documents/{documentId}/download")
@PreAuthorize("hasAuthority('merchant:order:view')") @PreAuthorize("hasAuthority('merchant:order:view')")
public ResponseEntity<byte[]> downloadSource(@AuthenticationPrincipal AuthenticatedUser user, public ResponseEntity<byte[]> downloadSource(@AuthenticationPrincipal AuthenticatedUser user,
@@ -105,6 +105,17 @@ public class PlatformContainerCustomsController {
return ApiResponse.success(service.start(planId,user.userId(),user.username(),key,request)); 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<ContainerView> 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") @PostMapping("/{planId}/submit-to-broker")
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
@BusinessAudit(event="CUSTOMS_SUBMIT_TO_BROKER",resourceType="CUSTOMS_CONTAINER") @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)); 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<List<ReceiptActionView>> receiptActions(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long documentId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.receiptActions(0,documentId,true));
}
@GetMapping("/documents/{documentId}/download") @GetMapping("/documents/{documentId}/download")
@PreAuthorize("hasAuthority('platform:firstleg:customs:list')") @PreAuthorize("hasAuthority('platform:firstleg:customs:list')")
public ResponseEntity<byte[]> downloadSource(@AuthenticationPrincipal AuthenticatedUser user, public ResponseEntity<byte[]> downloadSource(@AuthenticationPrincipal AuthenticatedUser user,
@@ -20,18 +20,27 @@ public final class ContainerCustomsDtos {
public record ReceiptVersionView(long version, FileView file, String operatorName, public record ReceiptVersionView(long version, FileView file, String operatorName,
LocalDateTime createdAt) {} 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, public record DocumentView(long documentId, long tenantId, FileView source,
List<Long> inboundIds, List<String> inboundCodes, List<Long> inboundIds, List<String> inboundCodes,
boolean frozen, FileView receipt, String receiptStatus, boolean frozen, FileView receipt, String receiptStatus,
String receiptRejectReason, FileView finalDocument, String receiptRejectReason, FileView finalDocument,
List<ReceiptVersionView> receiptHistory) {} List<ReceiptVersionView> receiptHistory,
List<ReceiptActionView> confirmationHistory) {}
public record ContainerView(long aggregateId, long loadingPlanId, long containerBookingId, public record ContainerView(long aggregateId, long loadingPlanId, long containerBookingId,
String planNo, String cabinetNo, String containerNo, String status, boolean hasActualCargo, String planNo, String cabinetNo, String containerNo, String status, boolean hasActualCargo,
String merchantStatus, String merchantStatus,
int version, int taxRefundCount, int coveredTaxRefundCount, long actualBoxCount, int version, int taxRefundCount, int coveredTaxRefundCount, long actualBoxCount,
List<InboundView> inbounds, List<DocumentView> documents, List<InboundView> inbounds, List<DocumentView> 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, public record ScopeRequest(@NotEmpty @Size(max = 200) List<@Positive Long> inboundIds,
@Min(0) int expectedVersion) {} @Min(0) int expectedVersion) {}
@@ -119,6 +119,32 @@ public interface ContainerCustomsMapper {
@Select("SELECT broker_submitted_at FROM fl_customs_container WHERE id=#{id}") @Select("SELECT broker_submitted_at FROM fl_customs_container WHERE id=#{id}")
LocalDateTime brokerSubmittedAt(@Param("id") long 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 " @Select("SELECT " + DECLARATION_BATCH_COLUMNS + " FROM fl_customs_declaration_batch b "
+ "WHERE customs_container_id=#{containerId} ORDER BY created_at DESC,id DESC") + "WHERE customs_container_id=#{containerId} ORDER BY created_at DESC,id DESC")
List<DeclarationBatchRow> declarationBatches(@Param("containerId") long containerId); List<DeclarationBatchRow> declarationBatches(@Param("containerId") long containerId);
@@ -387,6 +413,30 @@ public interface ContainerCustomsMapper {
@Param("status") String status,@Param("reason") String reason, @Param("status") String status,@Param("reason") String reason,
@Param("operatorId") long operatorId,@Param("operatorName") String operatorName); @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("""
<script>
SELECT h.id id,h.receipt_version receiptVersion,h.action action,h.reason reason,
h.operator_name operatorName,h.created_at createdAt
FROM fl_customs_completion_action_history h
JOIN fl_customs_completion_document r ON r.id=h.completion_document_id
JOIN fl_customs_customer_document d ON d.id=r.source_document_id
WHERE d.id=#{documentId} AND d.status='ACTIVE'
<if test="tenantId != null">AND d.tenant_id=#{tenantId}</if>
ORDER BY h.created_at,h.id
</script>
""")
List<ReceiptActionRow> receiptActionHistory(@Param("documentId") long documentId,@Param("tenantId") Long tenantId);
@Select(""" @Select("""
SELECT COUNT(*) FROM fl_customs_customer_document d SELECT COUNT(*) FROM fl_customs_customer_document d
JOIN fl_customs_completion_document r ON r.source_document_id=d.id JOIN fl_customs_completion_document r ON r.source_document_id=d.id
@@ -561,6 +611,8 @@ public interface ContainerCustomsMapper {
String customerConfirmStatus,String customerRejectReason) {} String customerConfirmStatus,String customerRejectReason) {}
record CompletionVersionRow(Integer documentVersion,Long fileId,String fileName,String fileHash,String contentType, record CompletionVersionRow(Integer documentVersion,Long fileId,String fileName,String fileHash,String contentType,
Long sizeBytes,Long operatorId,String operatorName,LocalDateTime createdAt) {} 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, record FinalRow(Long id,Long sourceDocumentId,Long fileId,String fileName,String fileHash,String contentType,
Long sizeBytes,Long uploadedBy,String uploadedByName,LocalDateTime uploadedAt,Integer version) {} Long sizeBytes,Long uploadedBy,String uploadedByName,LocalDateTime uploadedAt,Integer version) {}
record CommandRow(Long id,Long actorId,String action,String idempotencyKey,String requestHash,String status, 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,"+ 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.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"; "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,"+ 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.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,"+ "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, Integer sourceVersion,String customsEntryNo,String submittedByName,LocalDateTime submittedAt,
String acknowledgedByName,LocalDateTime acknowledgedAt,String remark,Integer version, String acknowledgedByName,LocalDateTime acknowledgedAt,String remark,Integer version,
LocalDateTime createdAt,LocalDateTime updatedAt) {} 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, record ClearanceResultRow(Long id,Long declarationBatchId,Long customsContainerId,String resultType,String resultStatus,
String customsEntryNo,LocalDateTime occurredAt,String resolution,Long evidenceFileId, String customsEntryNo,LocalDateTime occurredAt,String resolution,Long evidenceFileId,
Long recordedBy,String recordedByName,LocalDateTime recordedAt,Long previousResultId) {} Long recordedBy,String recordedByName,LocalDateTime recordedAt,Long previousResultId) {}
@@ -21,6 +21,7 @@ import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream; import java.io.InputStream;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.*; import java.util.*;
import java.util.function.Function; import java.util.function.Function;
@@ -173,6 +174,35 @@ public class ContainerCustomsService {
return platformView(planId); 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<DocumentRow> 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 @Transactional
public ContainerView submitToBroker(long planId, long operatorId, String operatorName, String key, ActionRequest request) { public ContainerView submitToBroker(long planId, long operatorId, String operatorName, String key, ActionRequest request) {
String action = "SUBMIT_CUSTOMS_TO_BROKER_" + planId; String action = "SUBMIT_CUSTOMS_TO_BROKER_" + planId;
@@ -185,6 +215,7 @@ public class ContainerCustomsService {
if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent(); if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent();
if (mapper.taxRefundCount(container.loadingPlanId()) == 0) throw conflict("全买单柜无需发送客户回执给报关行"); if (mapper.taxRefundCount(container.loadingPlanId()) == 0) throw conflict("全买单柜无需发送客户回执给报关行");
if (mapper.unconfirmedReceiptCount(container.id()) != 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.brokerSubmittedAt(container.id()) != null) throw conflict("当前柜已发送给报关行执行");
if (mapper.submitToBroker(container.id(), container.version(), operatorId, operatorName) != 1) throw concurrent(); 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); 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) { if (mapper.brokerSubmittedAt(container.id()) != null) {
throw conflict("客户确认回执已发送给报关行,不可再修改回执"); throw conflict("客户确认回执已发送给报关行,不可再修改回执");
} }
if (mapper.brokerSubmission(container.id()) == null) {
throw conflict("当前柜尚未完成首次发送报关行资料");
}
CompletionRow completion = mapper.lockCompletion(documentId); CompletionRow completion = mapper.lockCompletion(documentId);
CustomsFileClient.Metadata metadata = upload(source.tenantId(), RECEIPT_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; long completionId;
@@ -249,6 +283,8 @@ public class ContainerCustomsService {
if (!confirm && (reason == null || reason.isBlank())) throw bad("打回回执必须填写原因"); if (!confirm && (reason == null || reason.isBlank())) throw bad("打回回执必须填写原因");
if (mapper.confirmReceipt(documentId, tenantId, confirm ? "CONFIRMED" : "REJECTED", if (mapper.confirmReceipt(documentId, tenantId, confirm ? "CONFIRMED" : "REJECTED",
confirm ? null : trim(reason), operatorId, operatorName) != 1) throw conflict("回执状态已变化,请刷新后重试"); 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) { if (confirm && mapper.pendingReceiptConfirmationCount(container.id()) == 0) {
history(container, "ALL_CUSTOMS_RECEIPTS_CONFIRMED", container.status(), container.status(), "客户已确认全部报关行回执,待发送给报关行执行", operatorId, operatorName); 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()); 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) { private String declarationSnapshot(long containerId) {
List<Map<String,Object>> snapshot = mapper.documents(containerId, null).stream().map(doc -> { List<Map<String,Object>> snapshot = mapper.documents(containerId, null).stream().map(doc -> {
CompletionRow receipt = mapper.completion(doc.id()); CompletionRow receipt = mapper.completion(doc.id());
@@ -451,6 +493,26 @@ public class ContainerCustomsService {
catch (JsonProcessingException ex) { throw new IllegalStateException(ex); } 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<Map<String,Object>> snapshot = mapper.documents(containerId, null).stream().map(document -> {
List<ScopeRow> scopes = mapper.frozenScopes(document.id());
return Map.<String,Object>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) { public Download downloadSource(long tenantId, long documentId, boolean platform) {
DocumentRow source = requiredDocument(documentId); DocumentRow source = requiredDocument(documentId);
if (!platform && source.tenantId() != tenantId) throw notFound("客户报送资料不存在"); 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()); return download(source.tenantId(), SOURCE_FILE_TYPE, Long.toString(container.loadingPlanId()), source.fileId(), source.fileName());
} }
public List<ReceiptActionView> 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<ReceiptActionView> 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) { public Download downloadCompletion(long tenantId, long documentId, boolean platform) {
DocumentRow source = requiredDocument(documentId); DocumentRow source = requiredDocument(documentId);
ContainerRow container = mapper.containerById(source.customsContainerId()); ContainerRow container = mapper.containerById(source.customsContainerId());
@@ -570,9 +644,11 @@ public class ContainerCustomsService {
long actualBoxCount = mapper.actualBoxCount(container.loadingPlanId(), tenantId, long actualBoxCount = mapper.actualBoxCount(container.loadingPlanId(), tenantId,
tenantId == null ? null : "TAX_REFUND"); tenantId == null ? null : "TAX_REFUND");
String merchantStatus=tenantId==null?null:merchantStatus(container.status(),refund,coveredRefund); 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(), return new ContainerView(container.id(), container.loadingPlanId(), container.containerBookingId(), plan.planNo(),
plan.cabinetNo(), plan.containerNo(), container.status(), !rows.isEmpty(), merchantStatus, container.version(), refund, coveredRefund, 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) { private DocumentView documentView(DocumentRow source, boolean merchant) {
@@ -587,7 +663,8 @@ public class ContainerCustomsService {
scopes.stream().map(ScopeRow::inboundCode).toList(), source.frozenAt() != null, result, scopes.stream().map(ScopeRow::inboundCode).toList(), source.frozenAt() != null, result,
completion == null ? null : completion.customerConfirmStatus(), completion == null ? null : completion.customerConfirmStatus(),
completion == null ? null : completion.customerRejectReason(), 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) { private void completeContainer(ContainerRow container, long operatorId, String operatorName, String remark) {
@@ -26,6 +26,28 @@ CREATE TABLE IF NOT EXISTS fl_customs_container (
('WAITING_CUSTOMER_DOCUMENTS','READY_TO_START','DECLARING','COMPLETED')) ('WAITING_CUSTOMER_DOCUMENTS','READY_TO_START','DECLARING','COMPLETED'))
) ENGINE=InnoDB COMMENT='装柜完成后的整柜出口报关聚合'; ) 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 ( CREATE TABLE IF NOT EXISTS fl_customs_customer_document (
id BIGINT UNSIGNED NOT NULL COMMENT '客户报送资料ID', id BIGINT UNSIGNED NOT NULL COMMENT '客户报送资料ID',
customs_container_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) CONSTRAINT fk_customs_completion_version FOREIGN KEY(completion_document_id) REFERENCES fl_customs_completion_document(id)
) ENGINE=InnoDB COMMENT='报关完成文件不可变覆盖版本'; ) 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 ( CREATE TABLE IF NOT EXISTS fl_customs_final_document (
id BIGINT UNSIGNED NOT NULL COMMENT '真实报关完税文件ID', id BIGINT UNSIGNED NOT NULL COMMENT '真实报关完税文件ID',
source_document_id BIGINT UNSIGNED NOT NULL COMMENT '对应冻结客户资料ID', source_document_id BIGINT UNSIGNED NOT NULL COMMENT '对应冻结客户资料ID',
@@ -21,6 +21,13 @@ paths:
parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/IdempotencyKey'}] parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {$ref: '#/components/requestBodies/ActionBody'} requestBody: {$ref: '#/components/requestBodies/ActionBody'}
responses: {'200': {$ref: '#/components/responses/ContainerResponse'}} 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: /api/platform/v1/customs/loading-plans/{planId}/declaration-batches:
get: get:
summary: 查询整柜正式申报批次和海关结果 summary: 查询整柜正式申报批次和海关结果
@@ -77,6 +84,12 @@ paths:
- {name: expectedVersion, in: query, required: true, schema: {type: integer, minimum: 0}} - {name: expectedVersion, in: query, required: true, schema: {type: integer, minimum: 0}}
requestBody: {$ref: '#/components/requestBodies/FileBody'} requestBody: {$ref: '#/components/requestBodies/FileBody'}
responses: {'200': {$ref: '#/components/responses/DocumentResponse'}} 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: /api/platform/v1/customs/loading-plans/documents/{documentId}/download:
get: get:
summary: 平台下载客户报送资料 summary: 平台下载客户报送资料
@@ -151,6 +164,12 @@ paths:
- {$ref: '#/components/parameters/IdempotencyKey'} - {$ref: '#/components/parameters/IdempotencyKey'}
- {name: expectedVersion, in: query, required: true, schema: {type: integer, minimum: 1}} - {name: expectedVersion, in: query, required: true, schema: {type: integer, minimum: 1}}
responses: {'200': {$ref: '#/components/responses/ContainerResponse'}} 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: /api/merchant/v1/customs/documents/{documentId}/download:
get: get:
summary: 客户下载自己当前报送资料 summary: 客户下载自己当前报送资料
@@ -204,6 +223,7 @@ components:
responses: responses:
ContainerResponse: {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerEnvelope'}}}} ContainerResponse: {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerEnvelope'}}}}
DocumentResponse: {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentEnvelope'}}}} 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}}}} 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'}}}} DeclarationBatchResponse: {description: 正式申报批次, content: {application/json: {schema: {$ref: '#/components/schemas/DeclarationBatchEnvelope'}}}}
DeclarationBatchListResponse: {description: 正式申报批次列表, content: {application/json: {schema: {$ref: '#/components/schemas/DeclarationBatchListEnvelope'}}}} 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}}} 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}}} 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}}} 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: 报关行回执历次上传记录,按版本升序}}} 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}}}
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}}} 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'}}}} 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}}} 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'}}}]} 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'}}}}]} 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'}}}]} 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'}}}}]} 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}}} 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}}}}}]} 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}}}}}]}
@@ -17,7 +17,9 @@ class ContainerCustomsControllerContractTest {
.containsExactly("/api/platform/v1/customs/loading-plans"); .containsExactly("/api/platform/v1/customs/loading-plans");
assertPermission(PlatformContainerCustomsController.class,"detail","platform:firstleg:customs:list"); assertPermission(PlatformContainerCustomsController.class,"detail","platform:firstleg:customs:list");
assertPermission(PlatformContainerCustomsController.class,"start","platform:firstleg:customs:manage"); 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,"uploadCompletion","platform:firstleg:customs:manage");
assertPermission(PlatformContainerCustomsController.class,"receiptActions","platform:firstleg:customs:list");
assertPermission(PlatformContainerCustomsController.class,"completeAllBuy","platform:firstleg:customs:manage"); assertPermission(PlatformContainerCustomsController.class,"completeAllBuy","platform:firstleg:customs:manage");
assertPermission(PlatformContainerCustomsController.class,"declarationBatches","platform:firstleg:customs:list"); assertPermission(PlatformContainerCustomsController.class,"declarationBatches","platform:firstleg:customs:list");
assertPermission(PlatformContainerCustomsController.class,"createDeclarationBatch","platform:firstleg:customs:manage"); assertPermission(PlatformContainerCustomsController.class,"createDeclarationBatch","platform:firstleg:customs:manage");
@@ -34,12 +36,13 @@ class ContainerCustomsControllerContractTest {
assertPermission(MerchantContainerCustomsController.class,"scope","merchant:order:edit"); assertPermission(MerchantContainerCustomsController.class,"scope","merchant:order:edit");
assertPermission(MerchantContainerCustomsController.class,"delete","merchant:order:edit"); assertPermission(MerchantContainerCustomsController.class,"delete","merchant:order:edit");
assertPermission(MerchantContainerCustomsController.class,"clearanceResults","merchant:order:view"); assertPermission(MerchantContainerCustomsController.class,"clearanceResults","merchant:order:view");
assertPermission(MerchantContainerCustomsController.class,"receiptActions","merchant:order:view");
} }
@Test void openApiPublishesEveryPathParameterAndGeneratableDtos()throws Exception{ @Test void openApiPublishesEveryPathParameterAndGeneratableDtos()throws Exception{
String yaml=new ClassPathResource("openapi/customs-v1.yaml").getContentAsString(java.nio.charset.StandardCharsets.UTF_8); String yaml=new ClassPathResource("openapi/customs-v1.yaml").getContentAsString(java.nio.charset.StandardCharsets.UTF_8);
new Yaml().load(yaml); new Yaml().load(yaml);
assertThat(yaml).contains("ContainerView:","DocumentView:","FileView:","InboundView:","ActionRequest:","ScopeRequest:", 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("merchantStatus:","WAITING_UPLOAD","CUSTOMS_COMPLETED","loadingPlanId:","hasActualCargo:","nullable: true")
.contains("LOADING_LIST_BUY_DECLARATION_ITEMS") .contains("LOADING_LIST_BUY_DECLARATION_ITEMS")
.doesNotContain("/cases/","CUSTOMS_DECLARATION"); .doesNotContain("/cases/","CUSTOMS_DECLARATION");
@@ -46,6 +46,7 @@ class ContainerCustomsMapperContractTest {
assertRecordProjection("completion", ContainerCustomsMapper.CompletionRow.class, long.class); assertRecordProjection("completion", ContainerCustomsMapper.CompletionRow.class, long.class);
assertRecordProjection("lockCompletion", ContainerCustomsMapper.CompletionRow.class, long.class); assertRecordProjection("lockCompletion", ContainerCustomsMapper.CompletionRow.class, long.class);
assertRecordProjectionWithoutVersion("completionVersions", ContainerCustomsMapper.CompletionVersionRow.class, long.class); assertRecordProjectionWithoutVersion("completionVersions", ContainerCustomsMapper.CompletionVersionRow.class, long.class);
assertRecordProjectionWithoutVersion("receiptActionHistory", ContainerCustomsMapper.ReceiptActionRow.class, long.class, Long.class);
} }
@Test void declarationAndClearanceQueriesMapExplicitHistoricalFacts() throws Exception { @Test void declarationAndClearanceQueriesMapExplicitHistoricalFacts() throws Exception {
@@ -57,6 +58,11 @@ class ContainerCustomsMapperContractTest {
assertRecordProjectionWithoutVersion("lockClearanceResult", ContainerCustomsMapper.ClearanceResultRow.class, long.class); 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 { private static void assertRecordProjection(String method,Class<?> recordType,Class<?>... parameterTypes) throws Exception {
assertProjection(method, recordType, true, parameterTypes); assertProjection(method, recordType, true, parameterTypes);
} }
@@ -150,6 +150,61 @@ class ContainerCustomsServiceTest {
verify(mapper,never()).freezeScopes(anyLong()); 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(){ @Test void declaringContainerFreezesUploadReplacementScopeAndDeletionWithoutDrift(){
var inbound=new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","TAX_REFUND"); 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 declaring=container(ContainerCustomsService.DECLARING,3);
@@ -247,6 +302,7 @@ class ContainerCustomsServiceTest {
var source=document(1L);var container=container(ContainerCustomsService.DECLARING,3); 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.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.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(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())) 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, .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.command(5L,"UPLOAD_COMPLETION_1","completion-key")).thenAnswer(invocation->commandState.get());
when(mapper.lockContainerById(2L)).thenAnswer(invocation->containerState.get()); when(mapper.lockContainerById(2L)).thenAnswer(invocation->containerState.get());
when(mapper.lockDocument(1L)).thenReturn(source); when(mapper.lockDocument(1L)).thenReturn(source);
when(mapper.brokerSubmission(2L)).thenReturn(brokerSubmission());
when(mapper.lockCompletion(1L)).thenAnswer(invocation->completionState.get()); 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))) 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)); .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.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 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){ private void assertMerchantOrderNotFound(long tenantId,long orderId){
assertThatThrownBy(()->service.merchantOrderView(tenantId,orderId)) assertThatThrownBy(()->service.merchantOrderView(tenantId,orderId))
@@ -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', for (const token of ['EXACT_50_MB + 1', '.customblob', 'application/octet-stream',
'缺退税资料禁止开始且零漂移', '开始后资料范围锁定A0409零漂移', '缺退税资料禁止开始且零漂移', '开始后资料范围锁定A0409零漂移',
"['上传'", "['覆盖'", "['改关联'", "['删除'", '开始后${label}锁定A0409零漂移', "['上传'", "['覆盖'", "['改关联'", "['删除'", '开始后${label}锁定A0409零漂移',
'首次发送报关行并生成独立批次', '混合柜冻结资料首次发送报关行并生成独立批次',
'回执未确认且未发送执行时禁止上传完税文件', '客户确认整份回执后仍处于报关中', '真实报关完税文件齐全后纯退税柜完成', '客户确认回执后仍未完成整柜', '回执未确认且未发送执行时禁止上传完税文件', '客户确认整份回执后仍处于报关中', '真实报关完税文件齐全后纯退税柜完成', '客户确认回执后仍未完成整柜',
'混合柜平台详情精确包含BUY与TAX长ID票', 'Client只见本客户TAX票且完全隐藏BUY票', '混合柜平台详情精确包含BUY与TAX长ID票', 'Client只见本客户TAX票且完全隐藏BUY票',
'买单导出只处理实际装柜BUY行', '混合柜最后一份完成文件后整柜COMPLETED', '买单导出只处理实际装柜BUY行', '混合柜最后一份完成文件后整柜COMPLETED',
@@ -17,6 +17,7 @@ export const REQUIRED_ROUTE_OPERATIONS = Object.freeze([
'merchantDeleteDocument', 'merchantDeleteDocument',
'merchantConfirmReceipt', 'merchantConfirmReceipt',
'platformStart', 'platformStart',
'platformSendInitialToBroker',
'platformUploadReceipt', 'platformUploadReceipt',
'platformSubmitToBroker', 'platformSubmitToBroker',
'platformUploadFinal', 'platformUploadFinal',
@@ -88,6 +88,9 @@ function routes(context, merchantToken) {
platformStart: (ctx, fixture, request, idempotencyKey) => ctx.request('POST', platformStart: (ctx, fixture, request, idempotencyKey) => ctx.request('POST',
`${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/start`, request, ctx.token, `${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/start`, request, ctx.token,
{ 'Idempotency-Key': idempotencyKey }), { '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( platformUploadReceipt: (ctx, fixture, request, idempotencyKey) => multipart(
`${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/receipt/upload?expectedVersion=${Number(request.expectedVersion)}`, `${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/receipt/upload?expectedVersion=${Number(request.expectedVersion)}`,
{}, request, ctx.token, idempotencyKey), {}, request, ctx.token, idempotencyKey),
@@ -160,7 +160,17 @@ async function runRefund(context, routes, fixture) {
&& started.body?.data?.documents?.every?.((item) => item.frozen === true), && started.body?.data?.documents?.every?.((item) => item.frozen === true),
evidence(started), OPTIONS) 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 = [ const lockedOperations = [
['上传', () => routes.merchantUploadDocument(context, fixture, mutable, { ['上传', () => routes.merchantUploadDocument(context, fixture, mutable, {
...mutable.document, fileName: `${context.marker}-locked.customblob`, content: Uint8Array.from([1]), ...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')) }, key(context, 'locked-scope'))
await assertZeroDrift(context, '开始后资料范围锁定A0409零漂移', routes, fixture, { body: { data: frozen } }, locked) 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 = { const receiptRequest = {
documentId: customer.documentId, expectedVersion: 0, documentId: customer.documentId, expectedVersion: 0,
fileName: `${context.marker}-mixed-broker-receipt.pdf`, fileName: `${context.marker}-mixed-broker-receipt.pdf`,