完善退税报关回执确认流程

This commit is contained in:
2026-09-24 16:28:58 +08:00
parent bcf44affd7
commit 2486a01225
8 changed files with 154 additions and 46 deletions
@@ -91,6 +91,26 @@ public class MerchantContainerCustomsController {
return response(service.downloadCompletion(FirstlegScopeGuard.merchant(user),documentId,false));
}
@PostMapping("/documents/{documentId}/receipt/confirm")
@PreAuthorize("hasAuthority('merchant:order:edit')")
public ApiResponse<ContainerView> confirmReceipt(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long documentId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ActionRequest request) {
long tenantId=FirstlegScopeGuard.merchant(user);
return ApiResponse.success(service.confirmReceipt(tenantId,documentId,user.userId(),user.username(),key,request.expectedVersion(),true,request.remark()));
}
@PostMapping("/documents/{documentId}/receipt/reject")
@PreAuthorize("hasAuthority('merchant:order:edit')")
public ApiResponse<ContainerView> rejectReceipt(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long documentId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ActionRequest request) {
long tenantId=FirstlegScopeGuard.merchant(user);
return ApiResponse.success(service.confirmReceipt(tenantId,documentId,user.userId(),user.username(),key,request.expectedVersion(),false,request.remark()));
}
private static ResponseEntity<byte[]> response(Download value) {
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(value.contentType()))
@@ -51,11 +51,10 @@ public class PlatformContainerCustomsController {
@PathVariable @Positive long documentId,
@RequestHeader("Idempotency-Key") String key,
@RequestParam @Min(0) int expectedVersion,
@RequestParam(defaultValue="false") boolean finalConfirmation,
@RequestPart("file") MultipartFile file) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.uploadCompletion(documentId,user.userId(),user.username(),key,
expectedVersion,finalConfirmation,file));
expectedVersion,file));
}
@PostMapping("/{planId}/complete-all-buy")
@@ -19,12 +19,13 @@ public final class ContainerCustomsDtos {
public record DocumentView(long documentId, long tenantId, FileView source,
List<Long> inboundIds, List<String> inboundCodes,
boolean frozen, FileView completion) {}
boolean frozen, FileView completion, String receiptStatus,
String receiptRejectReason) {}
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,
int version, int taxRefundCount, int coveredTaxRefundCount, long actualBoxCount,
List<InboundView> inbounds, List<DocumentView> documents,
LocalDateTime startedAt, LocalDateTime completedAt) {}
@@ -17,7 +17,8 @@ public interface ContainerCustomsMapper {
String COMPLETION_COLUMNS = """
r.id id,r.source_document_id sourceDocumentId,r.file_id fileId,r.file_name fileName,
r.file_hash fileHash,r.content_type contentType,r.size_bytes sizeBytes,r.uploaded_by uploadedBy,
r.uploaded_by_name uploadedByName,r.uploaded_at uploadedAt,r.version version
r.uploaded_by_name uploadedByName,r.uploaded_at uploadedAt,r.version version,
r.customer_confirm_status customerConfirmStatus,r.customer_reject_reason customerRejectReason
""";
@Select("""
@@ -34,6 +35,18 @@ public interface ContainerCustomsMapper {
""")
List<InboundRow> planInbounds(@Param("planId") long planId);
@Select("""
SELECT COALESCE(SUM(item.package_count),0)
FROM fl_loading_plan_item item
JOIN fl_loading_plan p ON p.id=item.loading_plan_id AND p.plan_status='COMPLETED'
JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0
WHERE item.loading_plan_id=#{planId} AND item.deleted=0
AND (#{tenantId} IS NULL OR i.tenant_id=#{tenantId})
AND (#{taxMode} IS NULL OR i.tax_mode=#{taxMode})
""")
long actualBoxCount(@Param("planId") long planId, @Param("tenantId") Long tenantId,
@Param("taxMode") String taxMode);
@Select("""
SELECT p.id loadingPlanId,p.plan_no planNo,p.container_booking_id containerBookingId,
p.plan_status loadingPlanStatus,b.cabinet_no cabinetNo,b.container_no containerNo
@@ -217,9 +230,9 @@ public interface ContainerCustomsMapper {
@Insert("""
INSERT INTO fl_customs_completion_document
(id,source_document_id,file_id,file_name,file_hash,content_type,size_bytes,uploaded_by,uploaded_by_name,
uploaded_at,version,created_at,updated_at)
uploaded_at,version,customer_confirm_status,created_at,updated_at)
VALUES(#{id},#{documentId},#{fileId},#{fileName},#{fileHash},#{contentType},#{sizeBytes},#{operatorId},#{operatorName},
CURRENT_TIMESTAMP(3),1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))
CURRENT_TIMESTAMP(3),1,'PENDING',CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))
""")
int insertCompletion(@Param("id") long id,@Param("documentId") long documentId,@Param("fileId") long fileId,
@Param("fileName") String fileName,@Param("fileHash") String fileHash,
@@ -229,7 +242,9 @@ public interface ContainerCustomsMapper {
@Update("""
UPDATE fl_customs_completion_document SET file_id=#{fileId},file_name=#{fileName},file_hash=#{fileHash},
content_type=#{contentType},size_bytes=#{sizeBytes},uploaded_by=#{operatorId},uploaded_by_name=#{operatorName},
uploaded_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),version=version+1
uploaded_at=CURRENT_TIMESTAMP(3),customer_confirm_status='PENDING',customer_confirmed_by=NULL,
customer_confirmed_by_name=NULL,customer_confirmed_at=NULL,customer_reject_reason=NULL,
updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{id} AND version=#{version}
AND EXISTS(SELECT 1 FROM fl_customs_customer_document d JOIN fl_customs_container c ON c.id=d.customs_container_id
WHERE d.id=source_document_id AND c.status='DECLARING')
@@ -239,6 +254,28 @@ public interface ContainerCustomsMapper {
@Param("contentType") String contentType,@Param("sizeBytes") long sizeBytes,
@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_customs_completion_document r
JOIN fl_customs_customer_document d ON d.id=r.source_document_id
JOIN fl_customs_container c ON c.id=d.customs_container_id
SET r.customer_confirm_status=#{status},r.customer_confirmed_by=#{operatorId},
r.customer_confirmed_by_name=#{operatorName},r.customer_confirmed_at=CURRENT_TIMESTAMP(3),
r.customer_reject_reason=#{reason},r.updated_at=CURRENT_TIMESTAMP(3)
WHERE r.source_document_id=#{documentId} AND d.tenant_id=#{tenantId} AND c.status='DECLARING'
AND r.customer_confirm_status IN ('PENDING','REJECTED')
""")
int confirmReceipt(@Param("documentId") long documentId,@Param("tenantId") long tenantId,
@Param("status") String status,@Param("reason") String reason,
@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Select("""
SELECT COUNT(*) FROM fl_customs_customer_document d
JOIN fl_customs_completion_document r ON r.source_document_id=d.id
WHERE d.customs_container_id=#{containerId} AND d.status='ACTIVE' AND d.frozen_at IS NOT NULL
AND r.customer_confirm_status <> 'CONFIRMED'
""")
int pendingReceiptConfirmationCount(@Param("containerId") long containerId);
@Insert("""
INSERT INTO fl_customs_completion_document_version
(id,completion_document_id,document_version,file_id,file_name,file_hash,content_type,size_bytes,
@@ -251,13 +288,6 @@ public interface ContainerCustomsMapper {
@Param("contentType") String contentType,@Param("sizeBytes") long sizeBytes,
@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Select("""
SELECT COUNT(*) FROM fl_customs_customer_document d
LEFT JOIN fl_customs_completion_document r ON r.source_document_id=d.id
WHERE d.customs_container_id=#{containerId} AND d.status='ACTIVE' AND d.frozen_at IS NOT NULL AND r.id IS NULL
""")
int missingCompletionCount(@Param("containerId") long containerId);
@Insert("""
INSERT INTO fl_customs_container_history
(id,customs_container_id,action,from_status,to_status,remark,operator_id,operator_name,created_at)
@@ -350,7 +380,8 @@ public interface ContainerCustomsMapper {
Integer frozenVersion,LocalDateTime frozenAt,Integer version) {}
record ScopeRow(Long documentId,Long inboundId,Long orderId,String inboundCode) {}
record CompletionRow(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,
String customerConfirmStatus,String customerRejectReason) {}
record CommandRow(Long id,Long actorId,String action,String idempotencyKey,String requestHash,String status,
Long resultId,LocalDateTime createdAt,LocalDateTime completedAt) {}
record BuyExportRow(Long itemId,String planNo,String cabinetNo,String containerNo,String customerName,String orderNo,
@@ -174,11 +174,11 @@ public class ContainerCustomsService {
@Transactional
public DocumentView uploadCompletion(long documentId, long operatorId, String operatorName, String key,
int expectedVersion, boolean finalConfirmation, MultipartFile file) {
int expectedVersion, MultipartFile file) {
validateFile(file);
DocumentRow readable=requiredDocument(documentId);
String action = "UPLOAD_COMPLETION_" + documentId;
String requestHash = hash(action, expectedVersion, finalConfirmation, fileHash(file));
String requestHash = hash(action, expectedVersion, fileHash(file));
CommandRow replay = claim(operatorId, action, key, requestHash);
if (replay != null) return documentView(requiredDocument(documentId), false);
ContainerRow container = mapper.lockContainerById(readable.customsContainerId());
@@ -187,10 +187,6 @@ public class ContainerCustomsService {
throw conflict("当前客户资料不在报关中冻结版本");
}
CompletionRow completion = mapper.lockCompletion(documentId);
int missingBefore=mapper.missingCompletionCount(container.id());
boolean lastNewCompletion=completion==null&&missingBefore==1;
if(lastNewCompletion&&!finalConfirmation)throw conflict("这是最后一份完成文件,请二次确认后上传");
if(finalConfirmation&&!lastNewCompletion)throw conflict("只有最后一份尚未上传的完成文件才能确认整柜完成");
CustomsFileClient.Metadata metadata = upload(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file);
long completionId;
int version;
@@ -206,13 +202,37 @@ public class ContainerCustomsService {
}
mapper.insertCompletionVersion(IdWorker.getId(), completionId, version, metadata.fileId(), metadata.fileName(),
metadata.sha256(), metadata.contentType(), size(metadata), operatorId, operatorName);
if (lastNewCompletion) {
completeContainer(container, operatorId, operatorName, "全部冻结客户资料已回传完成文件");
}
completeCommand(operatorId, action, key, completionId);
return documentView(requiredDocument(documentId), false);
}
@Transactional
public ContainerView confirmReceipt(long tenantId, long documentId, long operatorId, String operatorName,
String key, int expectedVersion, boolean confirm, String reason) {
String action = (confirm ? "CONFIRM_CUSTOMS_RECEIPT_" : "REJECT_CUSTOMS_RECEIPT_") + documentId;
String requestHash = hash(action, confirm, reason);
CommandRow replay = claim(operatorId, action, key, requestHash);
if (replay != null) {
DocumentRow source = requiredDocument(documentId);
return merchantViewForDocument(tenantId, source);
}
DocumentRow source = requiredDocumentLocked(documentId, tenantId);
ContainerRow container = mapper.lockContainerById(source.customsContainerId());
CompletionRow receipt = mapper.lockCompletion(documentId);
if (container == null || !DECLARING.equals(container.status()) || source.frozenAt() == null || receipt == null) {
throw conflict("当前回执不在待确认状态");
}
if (!Objects.equals(receipt.version(), expectedVersion)) throw concurrent();
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 (confirm && mapper.pendingReceiptConfirmationCount(container.id()) == 0) {
completeContainer(container, operatorId, operatorName, "客户已确认全部报关行回执");
}
completeCommand(operatorId, action, key, documentId);
return merchantViewForDocument(tenantId, source);
}
@Transactional
public ContainerView completeAllBuy(long planId, long operatorId, String operatorName, String key, ActionRequest request) {
String action = "COMPLETE_ALL_BUY_CUSTOMS_" + planId;
@@ -336,19 +356,23 @@ public class ContainerCustomsService {
List<DocumentView> documentViews = documents.stream().map(doc -> documentView(doc, merchant)).toList();
int refund = (int) visibleRows.stream().filter(row -> "TAX_REFUND".equals(row.taxMode())).count();
int coveredRefund = (int) visibleRows.stream().filter(row -> covered.contains(row.inboundId())).count();
long actualBoxCount = mapper.actualBoxCount(container.loadingPlanId(), tenantId,
tenantId == null ? null : "TAX_REFUND");
String merchantStatus=tenantId==null?null:merchantStatus(container.status(),refund,coveredRefund);
return new ContainerView(container.id(), container.loadingPlanId(), container.containerBookingId(), plan.planNo(),
plan.cabinetNo(), plan.containerNo(), container.status(), !rows.isEmpty(), merchantStatus, container.version(), refund, coveredRefund,
inbounds, documentViews, container.startedAt(), container.completedAt());
actualBoxCount, inbounds, documentViews, container.startedAt(), container.completedAt());
}
private DocumentView documentView(DocumentRow source, boolean merchant) {
List<ScopeRow> scopes = source.frozenAt() == null ? mapper.scopes(source.id()) : mapper.frozenScopes(source.id());
CompletionRow completion = mapper.completion(source.id());
ContainerRow container = mapper.containerById(source.customsContainerId());
FileView result = completion == null || (merchant && !COMPLETED.equals(container.status())) ? null : file(completion);
FileView result = completion == null ? null : file(completion);
return new DocumentView(source.id(), source.tenantId(), file(source), scopes.stream().map(ScopeRow::inboundId).toList(),
scopes.stream().map(ScopeRow::inboundCode).toList(), source.frozenAt() != null, result);
scopes.stream().map(ScopeRow::inboundCode).toList(), source.frozenAt() != null, result,
completion == null ? null : completion.customerConfirmStatus(),
completion == null ? null : completion.customerRejectReason());
}
private void completeContainer(ContainerRow container, long operatorId, String operatorName, String remark) {
@@ -406,6 +430,12 @@ public class ContainerCustomsService {
return value;
}
private ContainerView merchantViewForDocument(long tenantId, DocumentRow source) {
List<ScopeRow> scopes = source.frozenAt() == null ? mapper.scopes(source.id()) : mapper.frozenScopes(source.id());
if (scopes.isEmpty()) throw notFound("客户报送资料未关联入仓单");
return merchantOrderView(tenantId, scopes.get(0).orderId());
}
private String scopeJson(List<InboundRow> scope) {
try { return objectMapper.writeValueAsString(scope.stream().map(row -> Map.of("inboundId", row.inboundId(),
"orderId", row.orderId(), "inboundCode", row.inboundCode())).toList()); }