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 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.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 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 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 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 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 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, 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 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> 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 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); } }