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
@@ -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,
@@ -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,
@@ -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) {}
@@ -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) {}
@@ -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) {