This commit is contained in:
2026-08-11 18:16:59 +08:00
parent 47c1c7922f
commit 7e52d0c025
13 changed files with 1057 additions and 4 deletions
@@ -0,0 +1,72 @@
package com.baiyue.finance.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest;
import com.baiyue.finance.dto.FinancePhase1Dtos.SettlementInclusionRequest;
import com.baiyue.finance.service.FinancePhase1OperationService;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDate;
@RestController
@RequestMapping("/api/platform/finance/v1")
public class FinancePhase1OperationController {
private final FinancePhase1OperationService service;
public FinancePhase1OperationController(FinancePhase1OperationService service) { this.service = service; }
@GetMapping("/accounts")
@PreAuthorize("hasAuthority('platform:finance:account:view')")
public Object listDerivedCustomerAccounts(@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String status) {
return service.listDerivedCustomerAccounts(page, pageSize, keyword, status);
}
@GetMapping("/accounts/{customerId}/ledger")
@PreAuthorize("hasAuthority('platform:finance:account:view')")
public Object listDerivedCustomerAccountLedger(@PathVariable long customerId,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize,
@RequestParam(required = false) String businessType,
@RequestParam(required = false) LocalDate startDate,
@RequestParam(required = false) LocalDate endDate) {
return service.listDerivedCustomerAccountLedger(customerId, page, pageSize, businessType, startDate, endDate);
}
@GetMapping("/fee-sources/{feeSourceId}")
@PreAuthorize("hasAuthority('platform:finance:fee-source:view')")
public ApiResponse<?> getFinanceFeeSource(@PathVariable long feeSourceId) {
return FinanceResponses.success(service.getFinanceFeeSource(feeSourceId));
}
@PostMapping("/fee-sources/{feeSourceId}/review")
@PreAuthorize("hasAuthority('platform:finance:fee-source:review')")
public ApiResponse<?> reviewFinanceFeeSource(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long feeSourceId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ReviewRequest request) {
return FinanceResponses.success(service.reviewFinanceFeeSource(feeSourceId, user.userId(), key, request));
}
@PutMapping("/fee-sources/{feeSourceId}/settlement-inclusion")
@PreAuthorize("hasAuthority('platform:finance:fee-source:settlement')")
public ApiResponse<?> updateFeeSourceSettlementInclusion(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long feeSourceId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody SettlementInclusionRequest request) {
return FinanceResponses.success(service.updateFeeSourceSettlementInclusion(feeSourceId, user.userId(), key, request));
}
}
@@ -0,0 +1,88 @@
package com.baiyue.finance.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.finance.dto.FinanceSettlementDtos.CreatePayableSettlementRequest;
import com.baiyue.finance.dto.FinanceSettlementDtos.CreateReceivableSettlementRequest;
import com.baiyue.finance.dto.FinanceSettlementDtos.CreateSettlementPaymentRequest;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetailEnvelope;
import com.baiyue.finance.service.FinanceSettlementService;
import jakarta.validation.Valid;
import org.slf4j.MDC;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.util.UUID;
@RestController
@RequestMapping("/api/platform/finance/v1")
public class FinanceSettlementController {
private final FinanceSettlementService service;
public FinanceSettlementController(FinanceSettlementService service) {
this.service = service;
}
@PostMapping("/receivable-settlements")
@PreAuthorize("hasAuthority('platform:finance:settlement-ar:create')")
public SettlementDetailEnvelope createReceivableSettlement(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody CreateReceivableSettlementRequest request) {
return success(service.createReceivableSettlement(user.userId(), idempotencyKey, request));
}
@PostMapping("/payable-settlements")
@PreAuthorize("hasAuthority('platform:finance:settlement-ap:create')")
public SettlementDetailEnvelope createPayableSettlement(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody CreatePayableSettlementRequest request) {
return success(service.createPayableSettlement(user.userId(), idempotencyKey, request));
}
@GetMapping("/settlements/{settlementId}")
@PreAuthorize("hasAnyAuthority('platform:finance:settlement-ar:view','platform:finance:settlement-ap:view')")
public SettlementDetailEnvelope getSettlement(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long settlementId) {
SettlementDetail detail = service.getSettlement(settlementId);
String permission = "RECEIVABLE".equals(detail.summary().direction())
? "platform:finance:settlement-ar:view" : "platform:finance:settlement-ap:view";
if (user == null || !user.permissions().contains(permission)) {
throw new AccessDeniedException("无权查看该方向结算单");
}
return success(detail);
}
@PostMapping("/settlements/{settlementId}/transactions")
@PreAuthorize("hasAuthority('platform:finance:payment:create')")
public SettlementDetailEnvelope createSettlementPayment(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long settlementId,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody CreateSettlementPaymentRequest request) {
return success(service.createSettlementPayment(settlementId, user.userId(), idempotencyKey, request));
}
private static SettlementDetailEnvelope success(SettlementDetail detail) {
String requestId = first(MDC.get("requestId"), MDC.get("request_id"));
if (!StringUtils.hasText(requestId)) requestId = UUID.randomUUID().toString();
String traceId = first(MDC.get("traceId"), MDC.get("trace_id"));
if (!StringUtils.hasText(traceId)) traceId = requestId;
return new SettlementDetailEnvelope("200", "success", detail, requestId, traceId, Instant.now());
}
private static String first(String first, String second) {
return StringUtils.hasText(first) ? first : second;
}
}
@@ -0,0 +1,46 @@
package com.baiyue.finance.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.finance.dto.ReconciliationStatementDtos.CreateReconciliationRequest;
import com.baiyue.finance.dto.ReconciliationStatementDtos.DocumentActionRequest;
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail;
import com.baiyue.finance.service.ReconciliationStatementService;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/platform/finance/v1")
public class ReconciliationStatementController {
private final ReconciliationStatementService service;
public ReconciliationStatementController(ReconciliationStatementService service) {
this.service = service;
}
@PostMapping("/statements")
@PreAuthorize("hasAuthority('platform:finance:reconciliation:create')")
public ApiResponse<ReconciliationDetail> createReconciliationStatement(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody CreateReconciliationRequest request) {
return FinanceResponses.success(service.create(user.userId(), idempotencyKey, request));
}
@PostMapping("/reconciliation-statements/{statementId}/actions")
@PreAuthorize("hasAuthority('platform:finance:reconciliation:review')")
public ApiResponse<ReconciliationDetail> actionReconciliationStatement(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long statementId,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody DocumentActionRequest request) {
return FinanceResponses.success(service.action(user.userId(), statementId, idempotencyKey, request));
}
}
@@ -0,0 +1,60 @@
package com.baiyue.finance.dto;
import org.slf4j.MDC;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
public final class FinancePhase1Dtos {
private FinancePhase1Dtos() {}
public record PageMeta(int page, int pageSize, long total) {}
public record PageEnvelope<T>(String code, String message, List<T> data, PageMeta page,
String requestId, String traceId, Instant timestamp) {
public static <T> PageEnvelope<T> success(List<T> data, int page, int pageSize, long total) {
String requestId = correlation("requestId", "request_id");
String traceId = correlation("traceId", "trace_id");
if (!StringUtils.hasText(requestId)) requestId = UUID.randomUUID().toString();
if (!StringUtils.hasText(traceId)) traceId = requestId;
return new PageEnvelope<>("200", "success", data, new PageMeta(page, pageSize, total), requestId, traceId, Instant.now());
}
}
public record AccountSummary(long customerId, String customerCode, String customerName,
String contactName, String contactPhone,
BigDecimal outstandingReceivable, BigDecimal pendingBilling,
BigDecimal receivedInPeriod, BigDecimal outstandingPayable,
String status, LocalDateTime lastChangedAt) {}
public record LedgerEntry(long ledgerId, LocalDateTime occurredAt, String businessType,
String documentType, long documentId, String documentNo,
BigDecimal receivableChange, BigDecimal payableChange,
BigDecimal outstandingReceivableAfter, String operatorName,
String remark) {}
public record FeeSourceDetail(long feeSourceId, String sourceType, long sourceFeeId,
int sourceVersion, String direction, long customerId,
String customerName, Long supplierId, String supplierName,
Long inboundId, String inboundCode, Long containerId,
String containerNo, LocalDate businessDate, String feeItemName,
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount,
String currency, String reviewStatus, boolean settlementIncluded,
String sourceStatus, boolean statementGenerated,
String reviewReason, String settlementReason, String sourceHash,
int operationVersion, List<OperationLog> operationLogs) {}
public record OperationLog(LocalDateTime occurredAt, String action, String operatorName,
String result, String remark) {}
public record ReviewRequest(String action, String reason, int expectedVersion) {}
public record SettlementInclusionRequest(boolean included, String reason, int expectedVersion) {}
private static String correlation(String first, String second) {
String value = MDC.get(first);
return StringUtils.hasText(value) ? value : MDC.get(second);
}
}
@@ -0,0 +1,43 @@
package com.baiyue.finance.dto;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** Stable DTOs for the phase-one AR/AP settlement operations. */
public final class FinanceSettlementDtos {
private FinanceSettlementDtos() {}
public record CreateReceivableSettlementRequest(long reconciliationStatementId, String currency) {}
public record CreatePayableSettlementRequest(long customerId, long supplierId, List<Long> feeSourceIds,
LocalDate dueDate, String currency) {}
public record CreateSettlementPaymentRequest(LocalDate transactionDate, BigDecimal amount, String currency,
String method, String referenceNo, List<String> voucherFileIds,
Integer expectedVersion, String remark) {}
public record SettlementSummary(long settlementId, String settlementNo, String direction, long customerId,
String customerName, Long supplierId, String supplierName,
Long reconciliationStatementId, String businessPeriod, String currency,
BigDecimal totalAmount, BigDecimal paidAmount, BigDecimal outstandingAmount,
LocalDate generatedDate, LocalDate dueDate, String paymentStatus,
String status, int version) {}
public record SettlementLine(long feeSourceId, String sourceType, long sourceFeeId, int sourceVersion,
String direction, long customerId, String customerName, Long supplierId,
String supplierName, Long inboundId, String inboundCode, Long containerId,
String containerNo, LocalDate businessDate, String feeItemName,
BigDecimal amount, String currency, String sourceStatus) {}
public record SettlementTransaction(long transactionId, String transactionType, LocalDate transactionDate,
BigDecimal amount, String method, String referenceNo,
List<String> voucherFileIds, long createdBy, LocalDateTime createdAt,
String remark) {}
public record SettlementRevision(int revisionNo, String action, BigDecimal beforeAmount,
BigDecimal afterAmount, String reason, long operatorId,
LocalDateTime occurredAt) {}
public record SettlementDetail(SettlementSummary summary, List<SettlementLine> lines,
List<SettlementTransaction> transactions,
List<SettlementRevision> revisions) {}
public record SettlementDetailEnvelope(String code, String message, SettlementDetail data,
String requestId, String traceId, Instant timestamp) {}
}
@@ -0,0 +1,35 @@
package com.baiyue.finance.dto;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
public final class ReconciliationStatementDtos {
private ReconciliationStatementDtos() {}
public record CreateReconciliationRequest(long customerId, String generationMode,
List<Long> feeSourceIds, LocalDate periodStart,
LocalDate periodEnd, String remark) {}
public record DocumentActionRequest(String action, String reason, int expectedVersion) {}
public record ReconciliationDetail(long statementId, String statementNo, long customerId,
String customerName, LocalDate periodStart, LocalDate periodEnd,
int creditDays, String currency, BigDecimal totalReceivable,
BigDecimal receivedAmount, BigDecimal outstandingAmount,
boolean settled, String status, int version,
LocalDateTime createdAt, LocalDateTime updatedAt, String remark,
List<FeeSourceLine> lines, List<PaymentHistory> paymentHistory) {}
public record FeeSourceLine(long feeSourceId, String sourceType, long sourceFeeId,
int sourceVersion, String direction, long customerId,
Long orderId, Long inboundId, Long containerId,
String feeItemCode, String feeItemName, BigDecimal amount,
String currency, String sourceStatus) {}
public record PaymentHistory(long transactionId, String transactionType,
LocalDate transactionDate, BigDecimal amount,
String method, String referenceNo, long createdBy,
LocalDateTime createdAt, String remark) {}
}
@@ -0,0 +1,198 @@
package com.baiyue.finance.service;
import com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail;
import com.baiyue.finance.dto.FinancePhase1Dtos.LedgerEntry;
import com.baiyue.finance.dto.FinancePhase1Dtos.OperationLog;
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
import com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest;
import com.baiyue.finance.dto.FinancePhase1Dtos.SettlementInclusionRequest;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.MDC;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
@Service
public class FinancePhase1OperationService {
private static final String CUSTOMER_IDS = "SELECT tenant_id customer_id FROM fin_customer_settlement_profile UNION SELECT tenant_id FROM fin_bill UNION SELECT customer_id FROM fin_fee_source UNION SELECT tenant_id FROM fin_cost";
private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
public FinancePhase1OperationService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
}
public PageEnvelope<AccountSummary> listDerivedCustomerAccounts(int page, int pageSize, String keyword, String status) {
page = page(page); pageSize = pageSize(pageSize);
String normalizedStatus = status == null || status.isBlank() ? null : status.trim().toUpperCase(java.util.Locale.ROOT);
if (normalizedStatus != null && !List.of("ACTIVE", "DISABLED").contains(normalizedStatus)) throw error("FIN-REQ-400", "账户状态无效");
String filter = " WHERE (? IS NULL OR CAST(c.customer_id AS CHAR) LIKE CONCAT('%',?,'%') OR COALESCE(p.customer_name_snapshot,'') LIKE CONCAT('%',?,'%')) AND (? IS NULL OR COALESCE(p.status,'ACTIVE')=?)";
Object[] filterArgs = {blankToNull(keyword), blankToNull(keyword), blankToNull(keyword), normalizedStatus, normalizedStatus};
String base = " FROM (" + CUSTOMER_IDS + ") c LEFT JOIN fin_customer_settlement_profile p ON p.tenant_id=c.customer_id" + filter;
Long total = jdbc.queryForObject("SELECT COUNT(*)" + base, Long.class, filterArgs);
String select = "SELECT c.customer_id,COALESCE(p.customer_name_snapshot,CONCAT('客户',c.customer_id)) customer_name,COALESCE(p.status,'ACTIVE') account_status," +
"(SELECT COALESCE(SUM(outstanding_amount),0) FROM fin_bill b WHERE b.tenant_id=c.customer_id AND b.status<>'VOIDED') outstanding_receivable," +
"(SELECT COALESCE(SUM(amount),0) FROM fin_fee_source f WHERE f.customer_id=c.customer_id AND f.direction='RECEIVABLE' AND f.status='AVAILABLE' AND NOT EXISTS(SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.source_fee_id AND bl.source_version=f.source_fee_version)) pending_billing," +
"(SELECT COALESCE(SUM(received_amount),0) FROM fin_receipt r WHERE r.tenant_id=c.customer_id AND r.status='CONFIRMED') received_period," +
"(SELECT COALESCE(SUM(cost_amount),0) FROM fin_cost x WHERE x.tenant_id=c.customer_id AND x.status<>'VOIDED') outstanding_payable," +
"GREATEST(COALESCE((SELECT MAX(updated_at) FROM fin_bill b WHERE b.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_receipt r WHERE r.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_cost x WHERE x.tenant_id=c.customer_id),'1970-01-01')) last_changed_at" + base + " ORDER BY last_changed_at DESC,c.customer_id DESC LIMIT ? OFFSET ?";
Object[] args = java.util.Arrays.copyOf(filterArgs, filterArgs.length + 2);
args[args.length - 2] = pageSize; args[args.length - 1] = (page - 1) * pageSize;
List<AccountSummary> rows = jdbc.query(select, (rs, row) -> new AccountSummary(rs.getLong("customer_id"), String.valueOf(rs.getLong("customer_id")), rs.getString("customer_name"), null, null,
money(rs, "outstanding_receivable"), money(rs, "pending_billing"), money(rs, "received_period"), money(rs, "outstanding_payable"), rs.getString("account_status"), time(rs, "last_changed_at")), args);
return PageEnvelope.success(rows, page, pageSize, total == null ? 0 : total);
}
public PageEnvelope<LedgerEntry> listDerivedCustomerAccountLedger(long customerId, int page, int pageSize,
String businessType, LocalDate startDate, LocalDate endDate) {
customer(customerId); page = page(page); pageSize = pageSize(pageSize);
if (startDate != null && endDate != null && startDate.isAfter(endDate)) throw error("FIN-REQ-400", "开始日期不能晚于结束日期");
List<RawLedger> rows = new ArrayList<>();
rows.addAll(jdbc.query("SELECT id,bill_no,created_at,total_amount,created_by,remark FROM fin_bill WHERE tenant_id=? AND status<>'VOIDED'", (rs, n) -> raw(rs, "SETTLEMENT_ISSUED", "BILL", "bill_no", "total_amount", null), customerId));
rows.addAll(jdbc.query("SELECT id,receipt_no,received_at received_time,received_amount,created_by,remark FROM fin_receipt WHERE tenant_id=? AND status='CONFIRMED'", (rs, n) -> raw(rs, "RECEIPT", "RECEIPT", "receipt_no", null, "received_amount"), customerId));
rows.addAll(jdbc.query("SELECT id,cost_no,created_at,cost_amount,created_by,remark FROM fin_cost WHERE tenant_id=? AND status<>'VOIDED'", (rs, n) -> raw(rs, "PAYMENT", "COST", "cost_no", null, null), customerId));
rows.sort(Comparator.comparing(RawLedger::occurredAt).thenComparing(RawLedger::id));
BigDecimal outstanding = BigDecimal.ZERO;
List<LedgerEntry> projected = new ArrayList<>();
for (RawLedger row : rows) {
BigDecimal receivableChange = row.receivable();
outstanding = outstanding.add(receivableChange);
BigDecimal payable = "COST".equals(row.documentType()) ? row.amount() : BigDecimal.ZERO;
LedgerEntry entry = new LedgerEntry(row.id(), row.occurredAt(), row.businessType(), row.documentType(), row.id(), row.documentNo(), receivableChange, payable, outstanding, "operator-" + row.operatorId(), row.remark());
if ((businessType == null || businessType.isBlank() || businessType.equals(entry.businessType())) && inRange(entry.occurredAt(), startDate, endDate)) projected.add(entry);
}
projected.sort(Comparator.comparing(LedgerEntry::occurredAt).reversed()
.thenComparing(Comparator.comparingLong(LedgerEntry::ledgerId).reversed()));
int from = Math.min((page - 1) * pageSize, projected.size());
int to = Math.min(from + pageSize, projected.size());
return PageEnvelope.success(List.copyOf(projected.subList(from, to)), page, pageSize, projected.size());
}
public FeeSourceDetail getFinanceFeeSource(long feeSourceId) {
if (feeSourceId <= 0) throw error("FIN-REQ-400", "费用来源ID无效");
List<FeeSourceDetail> rows = jdbc.query("SELECT * FROM fin_fee_source WHERE id=?", (rs, n) -> detail(rs), feeSourceId);
if (rows.isEmpty()) throw error("FIN-FEE-404", "费用来源不存在");
return rows.get(0);
}
@Transactional
public FeeSourceDetail reviewFinanceFeeSource(long feeSourceId, long operatorId, String key, ReviewRequest request) {
if (request == null || !List.of("APPROVE", "REJECT").contains(request.action()) || request.expectedVersion() < 0) throw error("FIN-REQ-400", "审核请求无效");
if ("REJECT".equals(request.action()) && !StringUtils.hasText(request.reason())) throw error("FIN-REQ-400", "驳回原因不能为空");
FeeSourceDetail before = getFinanceFeeSource(feeSourceId);
Long replay = reserve(before.customerId(), operatorId, "FEE_SOURCE_REVIEW", key, "POST", "/api/platform/finance/v1/fee-sources/" + feeSourceId + "/review", request);
if (replay != null) return getFinanceFeeSource(replay);
String review = "APPROVE".equals(request.action()) ? "APPROVED" : "REJECTED";
int changed = jdbc.update("UPDATE fin_fee_source SET review_status=?,review_reason=?,operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE'",
review, request.reason(), feeSourceId, request.expectedVersion());
if (changed != 1) throw error("FIN-FEE-409", "费用来源状态或版本不允许审核");
FeeSourceDetail after = getFinanceFeeSource(feeSourceId);
audit(after.customerId(), feeSourceId, "FEE_SOURCE_REVIEW_" + request.action(), request.reason(), before, after, operatorId);
complete(after.customerId(), operatorId, "FEE_SOURCE_REVIEW", key, feeSourceId);
return after;
}
@Transactional
public FeeSourceDetail updateFeeSourceSettlementInclusion(long feeSourceId, long operatorId, String key, SettlementInclusionRequest request) {
if (request == null || request.expectedVersion() < 0 || !StringUtils.hasText(request.reason())) throw error("FIN-REQ-400", "参与结算请求无效");
FeeSourceDetail before = getFinanceFeeSource(feeSourceId);
Long replay = reserve(before.customerId(), operatorId, "FEE_SOURCE_SETTLEMENT", key, "PUT", "/api/platform/finance/v1/fee-sources/" + feeSourceId + "/settlement-inclusion", request);
if (replay != null) return getFinanceFeeSource(replay);
if (request.included() && !"APPROVED".equals(before.reviewStatus())) throw error("FIN-FEE-409", "只有审核通过的费用来源可参与结算");
if (before.statementGenerated()) throw error("FIN-FEE-409", "已生成对账单的费用来源不可修改参与结算状态");
int changed = jdbc.update("UPDATE fin_fee_source SET settlement_included=?,settlement_reason=?,operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE'",
request.included(), request.reason(), feeSourceId, request.expectedVersion());
if (changed != 1) throw error("FIN-FEE-409", "费用来源状态或版本不允许修改参与结算状态");
FeeSourceDetail after = getFinanceFeeSource(feeSourceId);
audit(after.customerId(), feeSourceId, request.included() ? "FEE_INCLUDED" : "FEE_EXCLUDED", request.reason(), before, after, operatorId);
complete(after.customerId(), operatorId, "FEE_SOURCE_SETTLEMENT", key, feeSourceId);
return after;
}
private FeeSourceDetail detail(ResultSet rs) throws SQLException {
JsonNode snapshot = parse(rs.getString("source_snapshot"));
long id = rs.getLong("id");
boolean generated = Boolean.TRUE.equals(jdbc.queryForObject("SELECT COUNT(*)>0 FROM fin_bill_line WHERE source_id=? AND source_version=?", Boolean.class, rs.getLong("source_fee_id"), rs.getInt("source_fee_version")));
String status = rs.getString("status");
if (generated && "AVAILABLE".equals(status)) status = "BILLED";
BigDecimal quantity = decimal(snapshot, "chargeableQuantity");
BigDecimal amount = rs.getBigDecimal("amount");
BigDecimal unitPrice = quantity != null && quantity.signum() > 0 ? amount.divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null;
return new FeeSourceDetail(id, rs.getString("source_type"), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"), rs.getString("direction"), rs.getLong("customer_id"), text(snapshot, "customerName"), null, null,
nullableLong(rs, "inbound_id"), text(snapshot, "inboundCode"), nullableLong(rs, "container_id"), text(snapshot, "containerNo"), date(snapshot, "businessDate"), rs.getString("fee_item_name"), quantity, unitPrice, amount, rs.getString("currency_code"), defaultString(rs.getString("review_status"), "PENDING"), rs.getBoolean("settlement_included"), status, generated,
rs.getString("review_reason"), rs.getString("settlement_reason"), rs.getString("source_hash"), rs.getInt("operation_version"), logs(rs.getLong("customer_id"), id));
}
private List<OperationLog> logs(long customerId, long id) {
return jdbc.query("SELECT created_at,action,operator_name_snapshot,reason FROM fin_audit_log WHERE tenant_id=? AND resource_type='FIN_FEE_SOURCE' AND resource_id=? ORDER BY created_at DESC,id DESC", (rs, n) ->
new OperationLog(time(rs, "created_at"), rs.getString("action"), rs.getString("operator_name_snapshot"), "SUCCESS", rs.getString("reason")), customerId, id);
}
private Long reserve(long customerId, long operatorId, String operation, String key, String method, String path, Object body) {
if (!StringUtils.hasText(key) || key.length() > 128) throw error("FIN-IDEM-400", "必须提供有效的Idempotency-Key");
String hash = hash(Map.of("method", method, "path", path, "customerId", customerId, "body", body));
List<Map<String,Object>> rows = jdbc.queryForList("SELECT request_hash,status,result_resource_id FROM fin_idempotency_record WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=?", customerId, operatorId, operation, key);
if (!rows.isEmpty()) {
Map<String,Object> row = rows.get(0);
if (!hash.equals(row.get("request_hash"))) throw error("FIN-IDEM-409", "Idempotency-Key已用于其他请求");
if ("SUCCEEDED".equals(row.get("status")) && row.get("result_resource_id") != null) return ((Number) row.get("result_resource_id")).longValue();
throw error("FIN-IDEM-409", "相同请求正在处理中");
}
jdbc.update("INSERT INTO fin_idempotency_record(tenant_id,operator_id,operation,idempotency_key,request_hash,status) VALUES(?,?,?,?,?,'PROCESSING')", customerId, operatorId, operation, key, hash);
return null;
}
private void complete(long customerId, long operatorId, String operation, String key, long id) {
jdbc.update("UPDATE fin_idempotency_record SET status='SUCCEEDED',result_resource_id=? WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='PROCESSING'", id, customerId, operatorId, operation, key);
}
private void audit(long customerId, long id, String action, String reason, Object before, Object after, long operatorId) {
jdbc.update("INSERT INTO fin_audit_log(tenant_id,resource_type,resource_id,action,reason,before_data,after_data,operator_id,operator_name_snapshot,request_id,trace_id) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
customerId, "FIN_FEE_SOURCE", id, action, reason, json(before), json(after), operatorId, "operator-" + operatorId, MDC.get("requestId"), MDC.get("traceId"));
}
private static RawLedger raw(ResultSet rs, String businessType, String documentType, String noColumn, String debitColumn, String creditColumn) throws SQLException {
LocalDateTime occurred = time(rs, documentType.equals("RECEIPT") ? "received_time" : "created_at");
BigDecimal amount = documentType.equals("COST") ? money(rs, "cost_amount") : debitColumn != null ? money(rs, debitColumn) : money(rs, creditColumn);
BigDecimal receivable = debitColumn != null ? amount : creditColumn != null ? amount.negate() : BigDecimal.ZERO;
return new RawLedger(rs.getLong("id"), occurred, businessType, documentType, rs.getString(noColumn), amount, receivable, rs.getLong("created_by"), rs.getString("remark"));
}
private record RawLedger(long id, LocalDateTime occurredAt, String businessType, String documentType,
String documentNo, BigDecimal amount, BigDecimal receivable, long operatorId, String remark) {}
private static int page(int value) { if (value < 1) throw error("FIN-REQ-400", "页码必须大于0"); return value; }
private static int pageSize(int value) { if (value < 1 || value > 200) throw error("FIN-REQ-400", "每页数量必须在1到200之间"); return value; }
private static void customer(long value) { if (value <= 0) throw error("FIN-REQ-400", "客户ID无效"); }
private static boolean inRange(LocalDateTime value, LocalDate start, LocalDate end) { return value != null && (start == null || !value.toLocalDate().isBefore(start)) && (end == null || !value.toLocalDate().isAfter(end)); }
private static String blankToNull(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static BigDecimal money(ResultSet rs, String column) throws SQLException { BigDecimal value = rs.getBigDecimal(column); return value == null ? BigDecimal.ZERO : value; }
private static LocalDateTime time(ResultSet rs, String column) throws SQLException { Timestamp value = rs.getTimestamp(column); return value == null ? null : value.toLocalDateTime(); }
private static Long nullableLong(ResultSet rs, String column) throws SQLException { long value = rs.getLong(column); return rs.wasNull() ? null : value; }
private JsonNode parse(String json) { try { return objectMapper.readTree(json); } catch (Exception e) { throw error("FIN-DATA-500", "费用来源快照损坏"); } }
private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw error("FIN-DATA-500", "财务数据序列化失败"); } }
private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw error("FIN-DATA-500", "幂等请求哈希失败"); } }
private static String text(JsonNode node, String field) { JsonNode value = node.path(field); return value.isMissingNode() || value.isNull() ? null : value.asText(); }
private static String defaultText(JsonNode node, String field, String fallback) { String value = text(node, field); return value == null || value.isBlank() ? fallback : value; }
private static String defaultString(String value, String fallback) { return value == null || value.isBlank() ? fallback : value; }
private static BigDecimal decimal(JsonNode node, String field) { String value = text(node, field); return value == null || value.isBlank() ? null : new BigDecimal(value); }
private static LocalDate date(JsonNode node, String field) { String value = text(node, field); return value == null || value.isBlank() ? LocalDate.of(1970, 1, 1) : LocalDate.parse(value); }
private static FinanceEventException error(String code, String message) { return new FinanceEventException(code, message); }
}
@@ -0,0 +1,358 @@
package com.baiyue.finance.service;
import com.baiyue.finance.dto.ReconciliationStatementDtos.CreateReconciliationRequest;
import com.baiyue.finance.dto.ReconciliationStatementDtos.DocumentActionRequest;
import com.baiyue.finance.dto.ReconciliationStatementDtos.FeeSourceLine;
import com.baiyue.finance.dto.ReconciliationStatementDtos.PaymentHistory;
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.MDC;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
@Service
public class ReconciliationStatementService {
private static final Set<String> MODES = Set.of("BY_CUTOFF", "SELECTED");
private static final Set<String> ACTIONS = Set.of("SUBMIT", "APPROVE", "REJECT", "VOID");
private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
public ReconciliationStatementService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
}
@Transactional
public ReconciliationDetail create(long operatorId, String key, CreateReconciliationRequest request) {
validateCreate(request);
Long replay = reserve(request.customerId(), operatorId, "RECONCILIATION_CREATE", key,
"POST", "/api/platform/finance/v1/statements", request);
if (replay != null) return detail(request.customerId(), replay);
CustomerProfile customer = customer(request.customerId());
long subjectId = activeSubject();
List<Source> sources = eligibleSources(request);
if (sources.isEmpty()) throw error("FIN-REC-409", "没有符合结算条件的CNY应收费用来源");
BigDecimal total = sources.stream().map(Source::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
LocalDate dueDate = request.periodEnd().plusDays(customer.creditDays());
KeyHolder holder = new GeneratedKeyHolder();
jdbc.update(connection -> {
PreparedStatement statement = connection.prepareStatement(
"INSERT INTO fin_bill(tenant_id,bill_no,subject_id,customer_name_snapshot,currency_code,bill_date,due_date,status,total_amount,received_amount,outstanding_amount,remark,created_by,updated_by) VALUES(?,?,?,?,? ,?,?,'DRAFT',?,0,?,?,?,?)",
Statement.RETURN_GENERATED_KEYS);
statement.setLong(1, request.customerId());
statement.setString(2, "REC-PENDING-" + UUID.randomUUID());
statement.setLong(3, subjectId);
statement.setString(4, customer.name());
statement.setString(5, "CNY");
statement.setObject(6, request.periodEnd());
statement.setObject(7, dueDate);
statement.setBigDecimal(8, total);
statement.setBigDecimal(9, total);
statement.setString(10, request.remark());
statement.setLong(11, operatorId);
statement.setLong(12, operatorId);
return statement;
}, holder);
if (holder.getKey() == null) throw error("FIN-DATA-500", "对账单主键生成失败");
long statementId = holder.getKey().longValue();
jdbc.update("UPDATE fin_bill SET bill_no=? WHERE id=?", stableNo(statementId), statementId);
for (Source source : sources) {
jdbc.update("INSERT INTO fin_bill_line(tenant_id,bill_id,source_type,source_id,source_version,source_no,order_id,inbound_id,fee_item_code,fee_item_name,amount,currency_code) VALUES(?,?,?,?,?,?,?,?,?,?,?,'CNY')",
request.customerId(), statementId, "FIRSTLEG_FEE", source.sourceFeeId(), source.sourceVersion(),
source.sourceNo(), source.orderId(), source.inboundId(), source.feeItemCode(), source.feeItemName(), source.amount());
int locked = jdbc.update("UPDATE fin_fee_source SET status='LOCKED' WHERE id=? AND customer_id=? AND status='AVAILABLE'",
source.id(), request.customerId());
if (locked != 1) throw error("FIN-REC-409", "费用来源已被其他对账单占用");
}
CreationMetadata metadata = new CreationMetadata(request.generationMode().toUpperCase(Locale.ROOT),
request.periodStart(), request.periodEnd());
audit(request.customerId(), statementId, "RECONCILIATION_CREATED", request.remark(), null, metadata, operatorId);
complete(request.customerId(), operatorId, "RECONCILIATION_CREATE", key, statementId);
return detail(request.customerId(), statementId);
}
@Transactional
public ReconciliationDetail action(long operatorId, long statementId, String key, DocumentActionRequest request) {
validateAction(statementId, request);
Bill before = bill(statementId);
Long replay = reserve(before.customerId(), operatorId, "RECONCILIATION_ACTION", key,
"POST", "/api/platform/finance/v1/reconciliation-statements/" + statementId + "/actions", request);
if (replay != null) return detail(before.customerId(), replay);
String action = request.action().toUpperCase(Locale.ROOT);
Transition transition = transition(action, before.status());
if ("VOID".equals(action)) requireNoReceipt(before.customerId(), statementId);
snapshot(before, request.reason(), operatorId);
int changed = jdbc.update("UPDATE fin_bill SET status=?,issued_at=CASE WHEN ?='APPROVED' THEN CURRENT_TIMESTAMP(3) ELSE issued_at END,issued_by=CASE WHEN ?='APPROVED' THEN ? ELSE issued_by END,voided_at=CASE WHEN ?='VOIDED' THEN CURRENT_TIMESTAMP(3) ELSE voided_at END,voided_by=CASE WHEN ?='VOIDED' THEN ? ELSE voided_by END,void_reason=CASE WHEN ?='VOIDED' THEN ? ELSE void_reason END,updated_by=?,version=version+1 WHERE id=? AND tenant_id=? AND status=? AND version=?",
transition.to(), transition.to(), transition.to(), operatorId, transition.to(), transition.to(), operatorId,
transition.to(), request.reason(), operatorId, statementId, before.customerId(), transition.from(), request.expectedVersion());
if (changed != 1) throw error("FIN-REC-409", "对账单状态或版本已变化");
if ("APPROVE".equals(action)) {
jdbc.update("UPDATE fin_fee_source f JOIN fin_bill_line l ON l.source_id=f.source_fee_id AND l.source_version=f.source_fee_version SET f.status='BILLED' WHERE l.tenant_id=? AND l.bill_id=? AND f.customer_id=? AND f.status='LOCKED'",
before.customerId(), statementId, before.customerId());
}
ReconciliationDetail after = detail(before.customerId(), statementId);
audit(before.customerId(), statementId, "RECONCILIATION_" + action, request.reason(), before, after, operatorId);
complete(before.customerId(), operatorId, "RECONCILIATION_ACTION", key, statementId);
return after;
}
public ReconciliationDetail detail(long customerId, long statementId) {
Bill bill = bill(customerId, statementId);
CreationMetadata metadata = creationMetadata(customerId, statementId);
List<FeeSourceLine> lines = jdbc.query(
"SELECT f.id,f.source_type,f.source_fee_id,f.source_fee_version,f.direction,f.customer_id,f.order_id,f.inbound_id,f.container_id,f.fee_item_code,f.fee_item_name,f.amount,f.currency_code,f.status FROM fin_bill_line l JOIN fin_fee_source f ON f.source_fee_id=l.source_id AND f.source_fee_version=l.source_version AND f.customer_id=l.tenant_id WHERE l.tenant_id=? AND l.bill_id=? ORDER BY l.id",
(rs, row) -> line(rs), customerId, statementId);
List<PaymentHistory> payments = jdbc.query(
"SELECT r.id,r.received_at,r.received_amount,r.payment_method,r.voucher_no,r.created_by,r.created_at,r.remark FROM fin_receipt_bill rb JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id WHERE rb.tenant_id=? AND rb.bill_id=? AND r.status='CONFIRMED' ORDER BY r.received_at,r.id",
(rs, row) -> payment(rs), customerId, statementId);
return new ReconciliationDetail(bill.id(), bill.number(), bill.customerId(), bill.customerName(),
metadata.periodStart(), metadata.periodEnd(), bill.creditDays(), "CNY", bill.total(), bill.received(),
bill.outstanding(), bill.outstanding().signum() == 0, bill.status(), bill.version(), bill.createdAt(),
bill.updatedAt(), bill.remark(), lines, payments);
}
private List<Source> eligibleSources(CreateReconciliationRequest request) {
List<Object> args = new ArrayList<>();
StringBuilder sql = new StringBuilder("SELECT id,source_fee_id,source_fee_version,source_type,source_no,order_id,inbound_id,container_id,fee_item_code,fee_item_name,amount FROM fin_fee_source WHERE customer_id=? AND direction='RECEIVABLE' AND status='AVAILABLE' AND currency_code='CNY' AND JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.reviewStatus'))='APPROVED' AND JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.settlementIncluded'))='true' AND STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.businessDate')),'%Y-%m-%d') BETWEEN ? AND ?");
args.add(request.customerId()); args.add(request.periodStart()); args.add(request.periodEnd());
if ("SELECTED".equalsIgnoreCase(request.generationMode())) {
sql.append(" AND id IN (");
for (int index = 0; index < request.feeSourceIds().size(); index++) {
if (index > 0) sql.append(',');
sql.append('?'); args.add(request.feeSourceIds().get(index));
}
sql.append(')');
}
sql.append(" ORDER BY id FOR UPDATE");
List<Source> rows = jdbc.query(sql.toString(), (rs, row) -> source(rs), args.toArray());
if ("SELECTED".equalsIgnoreCase(request.generationMode())) {
Set<Long> actual = new HashSet<>(rows.stream().map(Source::id).toList());
if (!actual.equals(new HashSet<>(request.feeSourceIds()))) {
throw error("FIN-REC-409", "所选费用来源不存在、跨客户或不符合结算条件");
}
}
return rows;
}
private CustomerProfile customer(long customerId) {
List<CustomerProfile> rows = jdbc.query("SELECT customer_name_snapshot,credit_days FROM fin_customer_settlement_profile WHERE tenant_id=? AND status='ACTIVE'",
(rs, row) -> new CustomerProfile(rs.getString("customer_name_snapshot"), rs.getInt("credit_days")), customerId);
return rows.isEmpty() ? new CustomerProfile("客户" + customerId, 30) : rows.get(0);
}
private long activeSubject() {
List<Long> rows = jdbc.query("SELECT id FROM fin_subject WHERE status='ACTIVE' ORDER BY id LIMIT 1", (rs, row) -> rs.getLong(1));
if (rows.isEmpty()) throw error("FIN-REC-409", "平台财务主体未配置");
return rows.get(0);
}
private Bill bill(long statementId) {
List<Bill> rows = jdbc.query("SELECT b.*,COALESCE(p.credit_days,30) credit_days FROM fin_bill b LEFT JOIN fin_customer_settlement_profile p ON p.tenant_id=b.tenant_id WHERE b.id=?",
(rs, row) -> bill(rs), statementId);
if (rows.isEmpty()) throw error("FIN-REC-404", "对账单不存在");
return rows.get(0);
}
private Bill bill(long customerId, long statementId) {
List<Bill> rows = jdbc.query("SELECT b.*,COALESCE(p.credit_days,30) credit_days FROM fin_bill b LEFT JOIN fin_customer_settlement_profile p ON p.tenant_id=b.tenant_id WHERE b.id=? AND b.tenant_id=?",
(rs, row) -> bill(rs), statementId, customerId);
if (rows.isEmpty()) throw error("FIN-REC-404", "对账单不存在或不属于客户");
return rows.get(0);
}
private CreationMetadata creationMetadata(long customerId, long statementId) {
List<String> rows = jdbc.query("SELECT after_data FROM fin_audit_log WHERE tenant_id=? AND resource_type='RECONCILIATION_STATEMENT' AND resource_id=? AND action='RECONCILIATION_CREATED' ORDER BY id LIMIT 1",
(rs, row) -> rs.getString(1), customerId, statementId);
if (rows.isEmpty()) throw error("FIN-DATA-500", "对账单期间审计快照缺失");
try {
JsonNode node = objectMapper.readTree(rows.get(0));
return new CreationMetadata(node.path("generationMode").asText(), LocalDate.parse(node.path("periodStart").asText()), LocalDate.parse(node.path("periodEnd").asText()));
} catch (Exception exception) {
throw error("FIN-DATA-500", "对账单期间审计快照损坏");
}
}
private void snapshot(Bill bill, String reason, long operatorId) {
List<Map<String, Object>> lines = jdbc.queryForList("SELECT * FROM fin_bill_line WHERE tenant_id=? AND bill_id=? ORDER BY id", bill.customerId(), bill.id());
jdbc.update("INSERT INTO fin_bill_revision(tenant_id,bill_id,version,reason,bill_snapshot,line_snapshot,created_by) VALUES(?,?,?,?,?,?,?)",
bill.customerId(), bill.id(), bill.version(), reason, json(bill), json(lines), operatorId);
}
private void requireNoReceipt(long customerId, long statementId) {
Long count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_receipt_bill WHERE tenant_id=? AND bill_id=?", Long.class, customerId, statementId);
if (count != null && count > 0) throw error("FIN-REC-409", "已有收款记录的对账单不可作废");
}
private Long reserve(long customerId, long operatorId, String operation, String key,
String method, String path, Object body) {
if (!StringUtils.hasText(key) || key.length() > 128) throw error("FIN-IDEM-400", "必须提供有效的Idempotency-Key");
String requestHash = hash(Map.of("method", method, "path", path, "customerId", customerId, "body", body));
List<Map<String, Object>> rows = jdbc.queryForList("SELECT request_hash,status,result_resource_id,updated_at FROM fin_idempotency_record WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=?",
customerId, operatorId, operation, key);
if (!rows.isEmpty()) {
Map<String, Object> row = rows.get(0);
if (!requestHash.equals(row.get("request_hash"))) throw error("FIN-IDEM-409", "Idempotency-Key已用于其他请求");
if ("SUCCEEDED".equals(row.get("status")) && row.get("result_resource_id") != null) return ((Number) row.get("result_resource_id")).longValue();
if ("FAILED".equals(row.get("status")) && jdbc.update("UPDATE fin_idempotency_record SET status='PROCESSING',error_code=NULL WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='FAILED'",
customerId, operatorId, operation, key) == 1) return null;
LocalDateTime updatedAt = localTime(row.get("updated_at"));
if ("PROCESSING".equals(row.get("status")) && updatedAt != null && updatedAt.isBefore(LocalDateTime.now().minusMinutes(5))
&& jdbc.update("UPDATE fin_idempotency_record SET updated_at=CURRENT_TIMESTAMP(3) WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='PROCESSING' AND updated_at=?",
customerId, operatorId, operation, key, updatedAt) == 1) return null;
throw error("FIN-IDEM-409", "相同请求正在处理中");
}
jdbc.update("INSERT INTO fin_idempotency_record(tenant_id,operator_id,operation,idempotency_key,request_hash,status) VALUES(?,?,?,?,?,'PROCESSING')",
customerId, operatorId, operation, key, requestHash);
return null;
}
private void complete(long customerId, long operatorId, String operation, String key, long resourceId) {
int changed = jdbc.update("UPDATE fin_idempotency_record SET status='SUCCEEDED',result_resource_id=? WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='PROCESSING'",
resourceId, customerId, operatorId, operation, key);
if (changed != 1) throw error("FIN-IDEM-409", "幂等请求完成状态冲突");
}
private void audit(long customerId, long statementId, String action, String reason,
Object before, Object after, long operatorId) {
jdbc.update("INSERT INTO fin_audit_log(tenant_id,resource_type,resource_id,action,reason,before_data,after_data,operator_id,operator_name_snapshot,request_id,trace_id) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
customerId, "RECONCILIATION_STATEMENT", statementId, action, reason, before == null ? null : json(before),
after == null ? null : json(after), operatorId, "operator-" + operatorId, MDC.get("requestId"), MDC.get("traceId"));
}
private static void validateCreate(CreateReconciliationRequest request) {
if (request == null || request.customerId() <= 0 || request.periodStart() == null || request.periodEnd() == null
|| request.periodStart().isAfter(request.periodEnd())) throw error("FIN-REQ-400", "对账单客户和期间无效");
String mode = request.generationMode() == null ? "" : request.generationMode().toUpperCase(Locale.ROOT);
if (!MODES.contains(mode)) throw error("FIN-REQ-400", "对账单生成方式无效");
if (request.remark() != null && request.remark().length() > 500) throw error("FIN-REQ-400", "备注不能超过500个字符");
if ("SELECTED".equals(mode)) {
if (request.feeSourceIds() == null || request.feeSourceIds().isEmpty()) throw error("FIN-REQ-400", "勾选生成必须选择费用来源");
if (request.feeSourceIds().stream().anyMatch(id -> id == null || id <= 0)
|| new HashSet<>(request.feeSourceIds()).size() != request.feeSourceIds().size()) {
throw error("FIN-REQ-400", "费用来源ID无效或重复");
}
} else if (request.feeSourceIds() != null && !request.feeSourceIds().isEmpty()) {
throw error("FIN-REQ-400", "按截止期生成不能同时指定费用来源");
}
}
private static void validateAction(long statementId, DocumentActionRequest request) {
if (statementId <= 0 || request == null || request.expectedVersion() < 0 || !StringUtils.hasText(request.reason())
|| request.reason().length() > 500) throw error("FIN-REQ-400", "对账单动作请求无效");
if (request.action() == null || !ACTIONS.contains(request.action().toUpperCase(Locale.ROOT))) throw error("FIN-REQ-400", "对账单动作无效");
}
private static Transition transition(String action, String status) {
return switch (action) {
case "SUBMIT" -> requireTransition(status, "DRAFT", "PENDING_REVIEW");
case "APPROVE" -> requireTransition(status, "PENDING_REVIEW", "APPROVED");
case "REJECT" -> requireTransition(status, "PENDING_REVIEW", "REJECTED");
case "VOID" -> {
if (!Set.of("DRAFT", "PENDING_REVIEW", "REJECTED", "APPROVED").contains(status)) throw error("FIN-REC-409", "当前状态不允许作废");
yield new Transition(status, "VOIDED");
}
default -> throw error("FIN-REQ-400", "对账单动作无效");
};
}
private static Transition requireTransition(String actual, String expected, String target) {
if (!expected.equals(actual)) throw error("FIN-REC-409", "当前状态不允许执行该动作");
return new Transition(expected, target);
}
private static Source source(ResultSet rs) throws SQLException {
return new Source(rs.getLong("id"), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"),
rs.getString("source_type"), rs.getString("source_no"), nullableLong(rs, "order_id"),
nullableLong(rs, "inbound_id"), nullableLong(rs, "container_id"), rs.getString("fee_item_code"),
rs.getString("fee_item_name"), rs.getBigDecimal("amount"));
}
private static FeeSourceLine line(ResultSet rs) throws SQLException {
return new FeeSourceLine(rs.getLong("id"), rs.getString("source_type"), rs.getLong("source_fee_id"),
rs.getInt("source_fee_version"), rs.getString("direction"), rs.getLong("customer_id"),
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), nullableLong(rs, "container_id"),
rs.getString("fee_item_code"), rs.getString("fee_item_name"), rs.getBigDecimal("amount"),
rs.getString("currency_code"), rs.getString("status"));
}
private static PaymentHistory payment(ResultSet rs) throws SQLException {
Timestamp received = rs.getTimestamp("received_at");
return new PaymentHistory(rs.getLong("id"), "RECEIPT", received.toLocalDateTime().toLocalDate(),
rs.getBigDecimal("received_amount"), rs.getString("payment_method"), rs.getString("voucher_no"),
rs.getLong("created_by"), rs.getTimestamp("created_at").toLocalDateTime(), rs.getString("remark"));
}
private static Bill bill(ResultSet rs) throws SQLException {
return new Bill(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"),
rs.getString("customer_name_snapshot"), rs.getString("status"), rs.getBigDecimal("total_amount"),
rs.getBigDecimal("received_amount"), rs.getBigDecimal("outstanding_amount"), rs.getInt("version"),
rs.getInt("credit_days"), rs.getString("remark"), rs.getTimestamp("created_at").toLocalDateTime(),
rs.getTimestamp("updated_at").toLocalDateTime());
}
private String json(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (Exception exception) { throw error("FIN-DATA-500", "财务数据序列化失败"); }
}
private String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); }
catch (Exception exception) { throw error("FIN-DATA-500", "幂等请求哈希失败"); }
}
private static LocalDateTime localTime(Object value) {
if (value instanceof LocalDateTime dateTime) return dateTime;
if (value instanceof Timestamp timestamp) return timestamp.toLocalDateTime();
return null;
}
private static Long nullableLong(ResultSet rs, String column) throws SQLException {
long value = rs.getLong(column);
return rs.wasNull() ? null : value;
}
private static String stableNo(long id) {
return "REC-" + String.format(Locale.ROOT, "%020d", id);
}
private static FinanceEventException error(String code, String message) {
return new FinanceEventException(code, message);
}
private record CustomerProfile(String name, int creditDays) {}
private record CreationMetadata(String generationMode, LocalDate periodStart, LocalDate periodEnd) {}
private record Transition(String from, String to) {}
private record Source(long id, long sourceFeeId, int sourceVersion, String sourceType, String sourceNo,
Long orderId, Long inboundId, Long containerId, String feeItemCode,
String feeItemName, BigDecimal amount) {}
private record Bill(long id, long customerId, String number, String customerName, String status,
BigDecimal total, BigDecimal received, BigDecimal outstanding, int version,
int creditDays, String remark, LocalDateTime createdAt, LocalDateTime updatedAt) {}
}