fix(customs): add broker submission and receipt action history
This commit is contained in:
@@ -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',
|
||||
|
||||
+7
@@ -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<List<ReceiptActionView>> 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<byte[]> downloadSource(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
|
||||
+19
@@ -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<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")
|
||||
@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<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")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:list')")
|
||||
public ResponseEntity<byte[]> downloadSource(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
|
||||
+11
-2
@@ -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<Long> inboundIds, List<String> inboundCodes,
|
||||
boolean frozen, FileView receipt, String receiptStatus,
|
||||
String receiptRejectReason, FileView finalDocument,
|
||||
List<ReceiptVersionView> receiptHistory) {}
|
||||
List<ReceiptVersionView> receiptHistory,
|
||||
List<ReceiptActionView> 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<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,
|
||||
@Min(0) int expectedVersion) {}
|
||||
|
||||
+56
@@ -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<DeclarationBatchRow> 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("""
|
||||
<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 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) {}
|
||||
|
||||
+79
-2
@@ -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<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
|
||||
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<Map<String,Object>> 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<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) {
|
||||
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<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) {
|
||||
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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}}}}}]}
|
||||
|
||||
+4
-1
@@ -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");
|
||||
|
||||
+6
@@ -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);
|
||||
}
|
||||
|
||||
+58
@@ -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))
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -17,6 +17,7 @@ export const REQUIRED_ROUTE_OPERATIONS = Object.freeze([
|
||||
'merchantDeleteDocument',
|
||||
'merchantConfirmReceipt',
|
||||
'platformStart',
|
||||
'platformSendInitialToBroker',
|
||||
'platformUploadReceipt',
|
||||
'platformSubmitToBroker',
|
||||
'platformUploadFinal',
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user