Files
baiyue-firstleg/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java
T
2026-08-25 22:08:44 +08:00

564 lines
38 KiB
Java

package com.baiyue.finance.service;
import com.baiyue.finance.dto.ReconciliationStatementDtos.CreateReconciliationRequest;
import com.baiyue.finance.dto.ReconciliationStatementDtos.CustomerConfirmationRequest;
import com.baiyue.finance.dto.ReconciliationStatementDtos.CustomerConfirmationResult;
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationActionRequest;
import com.baiyue.finance.dto.ReconciliationStatementDtos.PaymentHistory;
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail;
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationLine;
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision;
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.beans.factory.annotation.Autowired;
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;
@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", "ISSUE", "ADJUST", "VOID", "REOPEN");
private static final Set<String> REASON_REQUIRED_ACTIONS = Set.of("REJECT", "ADJUST", "VOID", "REOPEN");
private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
private final FinanceDocumentNumberGenerator documentNumbers;
public ReconciliationStatementService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
this(jdbc, objectMapper, new FinanceDocumentNumberGenerator(jdbc));
}
@Autowired
public ReconciliationStatementService(JdbcTemplate jdbc, ObjectMapper objectMapper,
FinanceDocumentNumberGenerator documentNumbers) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
this.documentNumbers = documentNumbers;
}
@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_409", "当前客户在所选账期内没有可生成的CNY应收费用,请调整账期或选择其他客户后重试");
}
BigDecimal total = sources.stream().map(Source::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
LocalDate dueDate = request.periodEnd().plusDays(customer.creditDays());
String statementNo = documentNumbers.next("REC");
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,created_by_name,updated_by,updated_by_name) VALUES(?,?,?,?,? ,?,?,'DRAFT',?,0,?,?,?,?,?,?)",
Statement.RETURN_GENERATED_KEYS);
statement.setLong(1, request.customerId());
statement.setString(2, statementNo);
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.setString(12, FinanceOperatorSnapshot.currentName(operatorId));
statement.setLong(13, operatorId);
statement.setString(14, FinanceOperatorSnapshot.currentName(operatorId));
return statement;
}, holder);
if (holder.getKey() == null) throw error("FIN_001", "对账单主键生成失败");
long statementId = holder.getKey().longValue();
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.id(), 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_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, ReconciliationActionRequest 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);
if ("ADJUST".equals(action)) {
return adjust(operatorId, statementId, key, request, before);
}
Transition transition = transition(action, before.status());
if ("VOID".equals(action)) requireVoidable(before.customerId(), statementId);
if ("REOPEN".equals(action) && "VOIDED".equals(before.status())) reacquireSources(before.customerId(), statementId);
snapshot(before, request.reason(), operatorId);
int changed = jdbc.update("UPDATE fin_bill SET status=?,issued_at=CASE WHEN ?='ISSUED' THEN CURRENT_TIMESTAMP(3) ELSE issued_at END,issued_by=CASE WHEN ?='ISSUED' 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=?,updated_by_name=?,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, FinanceOperatorSnapshot.currentName(operatorId),
statementId, before.customerId(), transition.from(), request.expectedVersion());
if (changed != 1) throw error("FIN_409", "对账单状态或版本已变化");
if ("ISSUE".equals(action)) {
jdbc.update("UPDATE fin_bill SET customer_confirmation_status='PENDING_CONFIRMATION',customer_confirmed_at=NULL,customer_confirmed_by=NULL,customer_confirmed_by_name=NULL WHERE id=? AND tenant_id=?",
statementId, before.customerId());
jdbc.update("UPDATE fin_fee_source f JOIN fin_bill_line l ON l.source_id=f.id 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());
} else if ("VOID".equals(action)) {
jdbc.update("UPDATE fin_bill SET customer_confirmation_status='NOT_REQUIRED',customer_confirmed_at=NULL,customer_confirmed_by=NULL,customer_confirmed_by_name=NULL WHERE id=? AND tenant_id=?",
statementId, before.customerId());
jdbc.update("UPDATE fin_fee_source f JOIN fin_bill_line l ON l.source_id=f.id SET f.status='AVAILABLE' WHERE l.tenant_id=? AND l.bill_id=? AND f.customer_id=? AND f.status IN ('LOCKED','BILLED')",
before.customerId(), statementId, before.customerId());
} else if ("REOPEN".equals(action)) {
jdbc.update("UPDATE fin_bill SET customer_confirmation_status='NOT_REQUIRED',customer_confirmed_at=NULL,customer_confirmed_by=NULL,customer_confirmed_by_name=NULL WHERE id=? AND tenant_id=?",
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;
}
private ReconciliationDetail adjust(long operatorId, long statementId, String key,
ReconciliationActionRequest request, Bill before) {
if (!"ISSUED".equals(before.status())) throw error("FIN_409", "仅已出账对账单允许调整");
snapshot(before, request.reason(), operatorId);
for (var line : request.adjustmentLines()) {
int changed = jdbc.update("UPDATE fin_bill_line SET amount=?,adjustment_reason=? WHERE id=? AND bill_id=? AND tenant_id=?",
line.adjustedAmount(), line.reason(), line.statementLineId(), statementId, before.customerId());
if (changed != 1) throw error("FIN_404", "调整行不存在或不属于该对账单");
}
BigDecimal total = jdbc.queryForObject(
"SELECT COALESCE(SUM(amount),0) FROM fin_bill_line WHERE tenant_id=? AND bill_id=?",
BigDecimal.class, before.customerId(), statementId);
if (total == null) total = BigDecimal.ZERO;
if (total.compareTo(before.received()) < 0) throw error("FIN_409", "调整后金额不得低于已收金额");
int changed = jdbc.update("UPDATE fin_bill SET total_amount=?,outstanding_amount=?-received_amount,customer_confirmation_status='PENDING_CONFIRMATION',customer_confirmed_at=NULL,customer_confirmed_by=NULL,customer_confirmed_by_name=NULL,updated_by=?,updated_by_name=?,version=version+1 WHERE id=? AND tenant_id=? AND status='ISSUED' AND version=?",
total, total, operatorId, FinanceOperatorSnapshot.currentName(operatorId), statementId,
before.customerId(), request.expectedVersion());
if (changed != 1) throw error("FIN_409", "对账单状态或版本已变化");
ReconciliationDetail after = detail(before.customerId(), statementId);
audit(before.customerId(), statementId, "RECONCILIATION_ADJUST", 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<ReconciliationLine> lines = jdbc.query(
"SELECT f.id,l.id statement_line_id,f.source_type,f.source_no,f.source_fee_id,f.source_fee_version,f.source_hash,f.direction,f.customer_id,f.order_id,f.inbound_id,f.container_id," +
"JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.inboundCode')) inbound_code,JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.containerNo')) container_no," +
"STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.businessDate')),'%Y-%m-%d') business_date," +
"CAST(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.quantity')) AS DECIMAL(20,6)) quantity," +
"CAST(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.unitPrice')) AS DECIMAL(20,6)) unit_price," +
"f.fee_item_code,f.fee_item_name,f.amount source_amount,l.amount statement_amount,l.adjustment_reason,f.currency_code,f.status " +
"FROM fin_bill_line l JOIN fin_fee_source f ON f.id=l.source_id 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,rb.applied_amount,r.payment_method,r.voucher_no,r.voucher_file_ids,r.created_by,r.created_by_name,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);
List<ReconciliationRevision> revisions = jdbc.query(
"SELECT action,reason,before_data,after_data,operator_id,operator_name_snapshot,created_at FROM fin_audit_log WHERE tenant_id=? AND resource_type='RECONCILIATION_STATEMENT' AND resource_id=? AND action<>'RECONCILIATION_CUSTOMER_CONFIRM' AND before_data IS NOT NULL ORDER BY created_at,id",
(rs, row) -> revision(rs, row + 1), 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, reconciliationStatus(bill.status()), bill.version(),
bill.customerConfirmationStatus(), bill.customerConfirmedBy(), bill.customerConfirmedByName(),
bill.customerConfirmedAt(),
bill.createdBy(), bill.createdByName(), bill.createdAt(), bill.updatedBy(), bill.updatedByName(),
bill.updatedAt(), bill.remark(), lines, payments, revisions);
}
@Transactional
public CustomerConfirmationResult confirmForCustomer(long customerId, long operatorId, long statementId,
String key, CustomerConfirmationRequest request) {
if (customerId <= 0 || operatorId <= 0 || statementId <= 0 || request == null || request.expectedVersion() < 0) {
throw error("FIN_400", "客户确认请求无效");
}
Bill before = bill(customerId, statementId);
Long replay = reserve(customerId, operatorId, "RECONCILIATION_CUSTOMER_CONFIRM", key,
"POST", "/api/client/finance/v1/statements/" + statementId + "/confirm", request);
if (replay != null) return customerConfirmation(customerId, replay);
if (!Set.of("ISSUED", "PARTIALLY_PAID", "PAID").contains(before.status())) {
throw error("FIN_409", "仅已出账对账单允许客户确认");
}
if (!"PENDING_CONFIRMATION".equals(before.customerConfirmationStatus())) {
throw error("FIN_409", "对账单已确认或当前无需确认");
}
String operatorName = FinanceOperatorSnapshot.requiredName(operatorId);
int changed = jdbc.update("UPDATE fin_bill SET customer_confirmation_status='CONFIRMED',customer_confirmed_at=CURRENT_TIMESTAMP(3),customer_confirmed_by=?,customer_confirmed_by_name=?,updated_by=?,updated_by_name=?,version=version+1 WHERE id=? AND tenant_id=? AND status IN ('ISSUED','PARTIALLY_PAID','PAID') AND customer_confirmation_status='PENDING_CONFIRMATION' AND version=?",
operatorId, operatorName, operatorId, operatorName, statementId, customerId, request.expectedVersion());
if (changed != 1) throw error("FIN_409", "对账单状态或版本已变化");
CustomerConfirmationResult after = customerConfirmation(customerId, statementId);
audit(customerId, statementId, "RECONCILIATION_CUSTOMER_CONFIRM", null, before, after, operatorId);
complete(customerId, operatorId, "RECONCILIATION_CUSTOMER_CONFIRM", key, statementId);
return after;
}
private CustomerConfirmationResult customerConfirmation(long customerId, long statementId) {
Bill current = bill(customerId, statementId);
if (!"CONFIRMED".equals(current.customerConfirmationStatus()) || current.customerConfirmedBy() == null
|| current.customerConfirmedAt() == null) {
throw error("FIN_409", "对账单尚未完成客户确认");
}
return new CustomerConfirmationResult(current.id(), current.customerConfirmationStatus(),
current.customerConfirmedBy(), current.customerConfirmedByName(), current.customerConfirmedAt(), current.version());
}
public ReconciliationDetail detailForPlatform(long statementId) {
return detail(bill(statementId).customerId(), statementId);
}
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 review_status='APPROVED' AND amount>0 AND JSON_UNQUOTE(JSON_EXTRACT(source_snapshot,'$.publicationStatus'))='PUBLISHED' 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())) {
List<Map<String, Object>> selected = jdbc.queryForList(
"SELECT id,customer_id FROM fin_fee_source WHERE id IN (" +
String.join(",", java.util.Collections.nCopies(request.feeSourceIds().size(), "?")) + ") FOR UPDATE",
request.feeSourceIds().toArray());
if (selected.size() != request.feeSourceIds().size()
|| selected.stream().anyMatch(row -> number(row, "customer_id").longValue() != request.customerId())) {
throw error("FIN_404", "费用来源不存在或不属于客户");
}
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_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_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_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_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_001", "对账单期间审计快照缺失");
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_001", "对账单期间审计快照损坏");
}
}
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,created_by_name) VALUES(?,?,?,?,?,?,?,?)",
bill.customerId(), bill.id(), bill.version(), reason, json(bill), json(lines), operatorId,
FinanceOperatorSnapshot.currentName(operatorId));
}
private void requireVoidable(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_409", "已有收款记录的对账单不可作废");
count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_settlement WHERE tenant_id=? AND reconciliation_statement_id=? AND status<>'VOIDED'",
Long.class, customerId, statementId);
if (count != null && count > 0) throw error("FIN_409", "已有有效结算单的对账单不可作废");
}
private void reacquireSources(long customerId, long statementId) {
Integer expected = jdbc.queryForObject("SELECT COUNT(*) FROM fin_bill_line WHERE tenant_id=? AND bill_id=?",
Integer.class, customerId, statementId);
int changed = jdbc.update("UPDATE fin_fee_source f JOIN fin_bill_line l ON l.source_id=f.id SET f.status='LOCKED' WHERE l.tenant_id=? AND l.bill_id=? AND f.customer_id=? AND f.status='AVAILABLE'",
customerId, statementId, customerId);
if (expected == null || expected == 0 || changed != expected) throw error("FIN_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_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_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_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_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, FinanceOperatorSnapshot.requiredName(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_400", "对账单客户和期间无效");
if (!"CNY".equals(request.currency())) throw error("FIN_400", "一期仅支持CNY");
String mode = request.generationMode() == null ? "" : request.generationMode().toUpperCase(Locale.ROOT);
if (!MODES.contains(mode)) throw error("FIN_400", "对账单生成方式无效");
if (request.remark() != null && request.remark().length() > 500) throw error("FIN_400", "备注不能超过500个字符");
if ("SELECTED".equals(mode)) {
if (request.feeSourceIds() == null || request.feeSourceIds().isEmpty()) throw error("FIN_400", "勾选生成必须选择费用来源");
if (request.feeSourceIds().stream().anyMatch(id -> id == null || id <= 0)
|| new HashSet<>(request.feeSourceIds()).size() != request.feeSourceIds().size()) {
throw error("FIN_400", "费用来源ID无效或重复");
}
} else if (request.feeSourceIds() != null && !request.feeSourceIds().isEmpty()) {
throw error("FIN_400", "按截止期生成不能同时指定费用来源");
}
}
private static void validateAction(long statementId, ReconciliationActionRequest request) {
if (statementId <= 0 || request == null || request.expectedVersion() < 0) throw error("FIN_400", "对账单动作请求无效");
if (request.action() == null || !ACTIONS.contains(request.action().toUpperCase(Locale.ROOT))) throw error("FIN_400", "对账单动作无效");
String action = request.action().toUpperCase(Locale.ROOT);
if (request.reason() != null && request.reason().length() > 500) throw error("FIN_400", "原因不能超过500个字符");
if (REASON_REQUIRED_ACTIONS.contains(action) && !StringUtils.hasText(request.reason())) throw error("FIN_400", "该动作必须填写原因");
List<com.baiyue.finance.dto.ReconciliationStatementDtos.StatementAdjustmentLineRequest> lines = request.adjustmentLines();
if ("ADJUST".equals(action)) {
if (lines == null || lines.isEmpty()) throw error("FIN_400", "调整必须包含至少一条明细");
Set<Long> ids = new HashSet<>();
for (var line : lines) {
if (line == null || line.statementLineId() <= 0 || line.adjustedAmount() == null
|| line.adjustedAmount().signum() <= 0 || !StringUtils.hasText(line.reason())
|| line.reason().length() > 500 || !ids.add(line.statementLineId())) {
throw error("FIN_400", "调整明细无效或重复");
}
}
} else if (lines != null && !lines.isEmpty()) {
throw error("FIN_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 "ISSUE" -> requireTransition(status, "APPROVED", "ISSUED");
case "REOPEN" -> {
if (!Set.of("REJECTED", "VOIDED").contains(status)) throw error("FIN_409", "当前状态不允许重开");
yield new Transition(status, "DRAFT");
}
case "VOID" -> {
if (!Set.of("DRAFT", "PENDING_REVIEW", "APPROVED", "ISSUED").contains(status)) throw error("FIN_409", "当前状态不允许作废");
yield new Transition(status, "VOIDED");
}
default -> throw error("FIN_400", "对账单动作无效");
};
}
private static Transition requireTransition(String actual, String expected, String target) {
if (!expected.equals(actual)) throw error("FIN_409", "当前状态不允许执行该动作");
return new Transition(expected, target);
}
private static String reconciliationStatus(String billStatus) {
return billStatus;
}
private static Number number(Map<String, Object> row, String key) {
Object value = row.get(key);
if (value instanceof Number number) return number;
if (value == null) throw error("FIN_001", "财务字段缺失:" + key);
return new BigDecimal(value.toString());
}
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 ReconciliationLine line(ResultSet rs) throws SQLException {
return new ReconciliationLine(rs.getLong("statement_line_id"), rs.getLong("id"),
rs.getInt("source_fee_version"), rs.getString("source_hash"), rs.getString("direction"),
rs.getString("source_type"), rs.getString("source_no"), nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"),
nullableLong(rs, "container_id"), rs.getString("inbound_code"), rs.getString("container_no"),
rs.getDate("business_date") == null ? null : rs.getDate("business_date").toLocalDate(),
rs.getBigDecimal("quantity"), rs.getBigDecimal("unit_price"), rs.getString("fee_item_name"), rs.getBigDecimal("source_amount"),
rs.getBigDecimal("statement_amount"), rs.getString("currency_code"), rs.getString("adjustment_reason"));
}
private PaymentHistory payment(ResultSet rs) throws SQLException {
Timestamp received = rs.getTimestamp("received_at");
return new PaymentHistory(rs.getLong("id"), "RECEIPT", received.toLocalDateTime().toLocalDate(),
rs.getBigDecimal("applied_amount"), rs.getString("payment_method"), rs.getString("voucher_no"),
stringList(rs.getString("voucher_file_ids")), rs.getLong("created_by"), rs.getString("created_by_name"),
rs.getTimestamp("created_at").toLocalDateTime(), rs.getString("remark"));
}
private List<String> stringList(String value) {
if (!StringUtils.hasText(value)) return List.of();
try {
JsonNode node = objectMapper.readTree(value);
if (!node.isArray()) throw error("FIN_001", "收款凭证快照损坏");
List<String> result = new ArrayList<>();
node.forEach(item -> { if (item.isTextual() && StringUtils.hasText(item.asText())) result.add(item.asText()); });
return List.copyOf(result);
} catch (FinanceEventException exception) {
throw exception;
} catch (Exception exception) {
throw error("FIN_001", "收款凭证快照损坏");
}
}
private ReconciliationRevision revision(ResultSet rs, int revisionNo) throws SQLException {
return new ReconciliationRevision(revisionNo, rs.getString("action").replaceFirst("^RECONCILIATION_", ""), amountFromJson(rs.getString("before_data")),
amountFromJson(rs.getString("after_data")), rs.getString("reason"), rs.getLong("operator_id"),
rs.getString("operator_name_snapshot"),
rs.getTimestamp("created_at").toLocalDateTime());
}
private BigDecimal amountFromJson(String value) {
if (!StringUtils.hasText(value)) return BigDecimal.ZERO;
try {
JsonNode node = objectMapper.readTree(value);
JsonNode amount = node.has("totalAmount") ? node.get("totalAmount") : node.get("total");
return amount == null || amount.isNull() ? BigDecimal.ZERO : amount.decimalValue();
} catch (Exception exception) {
throw error("FIN_001", "对账单修订快照损坏");
}
}
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.getString("customer_confirmation_status"), nullableLong(rs, "customer_confirmed_by"),
rs.getString("customer_confirmed_by_name"), timestamp(rs, "customer_confirmed_at"),
rs.getInt("credit_days"), rs.getString("remark"), rs.getLong("created_by"),
rs.getString("created_by_name"), rs.getTimestamp("created_at").toLocalDateTime(), rs.getLong("updated_by"),
rs.getString("updated_by_name"),
rs.getTimestamp("updated_at").toLocalDateTime());
}
private String json(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (Exception exception) { throw error("FIN_001", "财务数据序列化失败"); }
}
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_001", "幂等请求哈希失败"); }
}
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 LocalDateTime timestamp(ResultSet rs, String column) throws SQLException {
Timestamp value = rs.getTimestamp(column);
return value == null ? null : value.toLocalDateTime();
}
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,
String customerConfirmationStatus, Long customerConfirmedBy,
String customerConfirmedByName, LocalDateTime customerConfirmedAt,
int creditDays, String remark, long createdBy, String createdByName,
LocalDateTime createdAt, long updatedBy, String updatedByName, LocalDateTime updatedAt) {}
}