优化
This commit is contained in:
+7
@@ -9,6 +9,7 @@ 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.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
@@ -34,6 +35,12 @@ public class ReconciliationStatementController {
|
||||
return FinanceResponses.success(service.create(user.userId(), idempotencyKey, request));
|
||||
}
|
||||
|
||||
@GetMapping("/reconciliation-statements/{statementId}")
|
||||
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
|
||||
public ApiResponse<ReconciliationDetail> getReconciliationStatement(@PathVariable long statementId) {
|
||||
return FinanceResponses.success(service.detailForPlatform(statementId));
|
||||
}
|
||||
|
||||
@PostMapping("/reconciliation-statements/{statementId}/actions")
|
||||
@PreAuthorize("(#request.action != null && #request.action.equalsIgnoreCase('SUBMIT') && hasAuthority('platform:finance:reconciliation:create'))"
|
||||
+ " || (#request.action != null && (#request.action.equalsIgnoreCase('APPROVE') || #request.action.equalsIgnoreCase('REJECT')) && hasAuthority('platform:finance:reconciliation:review'))"
|
||||
|
||||
@@ -188,6 +188,15 @@ public class FinanceService {
|
||||
BigDecimal applied = entry.getValue();
|
||||
jdbc.update("INSERT INTO fin_receipt_bill(tenant_id,receipt_id,bill_id,applied_amount,created_by) VALUES(?,?,?,?,?)", tenantId, receiptId, entry.getKey(), applied, operatorId);
|
||||
jdbc.update("UPDATE fin_bill SET received_amount=received_amount+?, outstanding_amount=outstanding_amount-?, status=CASE WHEN outstanding_amount-?=0 THEN 'PAID' ELSE 'PARTIALLY_PAID' END, updated_by=?, version=version+1 WHERE id=? AND tenant_id=?", applied, applied, applied, operatorId, entry.getKey(), tenantId);
|
||||
Integer settlementCount = jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM fin_settlement WHERE tenant_id=? AND direction='RECEIVABLE' AND reconciliation_statement_id=? AND status<>'VOIDED'",
|
||||
Integer.class, tenantId, entry.getKey());
|
||||
if (settlementCount != null && settlementCount > 0) {
|
||||
int settlementUpdated = jdbc.update(
|
||||
"UPDATE fin_settlement SET paid_amount=paid_amount+?,outstanding_amount=outstanding_amount-?,payment_status=CASE WHEN outstanding_amount-?=0 THEN 'PAID' ELSE 'PARTIALLY_PAID' END,status=CASE WHEN outstanding_amount-?=0 THEN 'PAID' ELSE 'PARTIALLY_PAID' END,updated_by=?,version=version+1 WHERE tenant_id=? AND direction='RECEIVABLE' AND reconciliation_statement_id=? AND status IN ('ISSUED','PARTIALLY_PAID') AND outstanding_amount>=?",
|
||||
applied, applied, applied, applied, operatorId, tenantId, entry.getKey(), applied);
|
||||
if (settlementUpdated != 1) throw new BizException(ResultCode.CONFLICT, "应收结算单状态或余额已变化");
|
||||
}
|
||||
}
|
||||
complete(tenantId, operatorId, "RECEIPT_CREATE", idempotencyKey, receiptId);
|
||||
return jdbc.queryForMap("SELECT * FROM fin_receipt WHERE id=?", receiptId);
|
||||
|
||||
@@ -171,10 +171,17 @@ public class FinanceSettlementService {
|
||||
"FROM fin_settlement_line sl JOIN fin_fee_source f ON f.id=sl.fee_source_id " +
|
||||
"WHERE sl.tenant_id=? AND sl.settlement_id=? ORDER BY sl.id", summary.customerId(), settlementId)
|
||||
.stream().map(source -> line(source, summary)).toList();
|
||||
List<SettlementTransaction> transactions = jdbc.queryForList(
|
||||
List<SettlementTransaction> transactions = new ArrayList<>(jdbc.queryForList(
|
||||
"SELECT id,transaction_type,transaction_date,amount,method,reference_no,voucher_file_ids,created_by,created_at,remark " +
|
||||
"FROM fin_settlement_payment WHERE tenant_id=? AND settlement_id=? ORDER BY created_at,id",
|
||||
summary.customerId(), settlementId).stream().map(this::transaction).toList();
|
||||
summary.customerId(), settlementId).stream().map(this::transaction).toList());
|
||||
if ("RECEIVABLE".equals(summary.direction()) && summary.reconciliationStatementId() != null) {
|
||||
transactions.addAll(jdbc.queryForList(
|
||||
"SELECT r.id,'RECEIPT' transaction_type,DATE(r.received_at) transaction_date,rb.applied_amount amount,r.payment_method method,r.voucher_no reference_no,r.voucher_file_ids,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.created_at,r.id",
|
||||
summary.customerId(), summary.reconciliationStatementId()).stream().map(this::transaction).toList());
|
||||
}
|
||||
List<Map<String, Object>> audits = jdbc.queryForList(
|
||||
"SELECT action,reason,before_data,after_data,operator_id,created_at FROM fin_audit_log " +
|
||||
"WHERE tenant_id=? AND resource_type='FIN_SETTLEMENT' AND resource_id=? ORDER BY created_at,id",
|
||||
@@ -306,7 +313,7 @@ public class FinanceSettlementService {
|
||||
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 FROM fin_idempotency_record " +
|
||||
"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()) {
|
||||
@@ -315,6 +322,18 @@ public class FinanceSettlementService {
|
||||
if ("SUCCEEDED".equals(text(row, "status")) && row.get("result_resource_id") != null) {
|
||||
return number(row, "result_resource_id").longValue();
|
||||
}
|
||||
if ("FAILED".equals(text(row, "status"))) {
|
||||
int reset = jdbc.update("UPDATE fin_idempotency_record SET status='PROCESSING',error_code=NULL,updated_at=CURRENT_TIMESTAMP(3) WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='FAILED'",
|
||||
customerId, operatorId, operation, key);
|
||||
if (reset == 1) return null;
|
||||
}
|
||||
LocalDateTime updatedAt = localTime(row.get("updated_at"));
|
||||
if ("PROCESSING".equals(text(row, "status")) && updatedAt != null
|
||||
&& updatedAt.isBefore(LocalDateTime.now().minusMinutes(5))) {
|
||||
int reset = 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);
|
||||
if (reset == 1) return null;
|
||||
}
|
||||
throw error("FIN_409", "相同请求正在处理中或等待恢复");
|
||||
}
|
||||
jdbc.update("INSERT INTO fin_idempotency_record(tenant_id,operator_id,operation,idempotency_key,request_hash,status) " +
|
||||
@@ -323,9 +342,10 @@ public class FinanceSettlementService {
|
||||
}
|
||||
|
||||
private void complete(long customerId, long operatorId, String operation, String key, long resourceId) {
|
||||
jdbc.update("UPDATE fin_idempotency_record SET status='SUCCEEDED',result_resource_id=? WHERE tenant_id=? " +
|
||||
int changed = jdbc.update("UPDATE fin_idempotency_record SET status='SUCCEEDED',result_resource_id=?,updated_at=CURRENT_TIMESTAMP(3) 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 resourceId, String action, String reason,
|
||||
@@ -454,6 +474,11 @@ public class FinanceSettlementService {
|
||||
if (value instanceof LocalDateTime time) return time;
|
||||
return LocalDateTime.parse(value.toString());
|
||||
}
|
||||
private static LocalDateTime localTime(Object value) {
|
||||
if (value instanceof Timestamp timestamp) return timestamp.toLocalDateTime();
|
||||
if (value instanceof LocalDateTime time) return time;
|
||||
return value == null ? null : LocalDateTime.parse(value.toString());
|
||||
}
|
||||
private static String blank(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
|
||||
private static boolean truth(Object value) { return value != null && Boolean.parseBoolean(value.toString()); }
|
||||
private static String jsonText(JsonNode node, String key) {
|
||||
|
||||
+153
-50
@@ -1,10 +1,11 @@
|
||||
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.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;
|
||||
@@ -37,7 +38,8 @@ 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 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;
|
||||
|
||||
@@ -56,7 +58,7 @@ public class ReconciliationStatementService {
|
||||
CustomerProfile customer = customer(request.customerId());
|
||||
long subjectId = activeSubject();
|
||||
List<Source> sources = eligibleSources(request);
|
||||
if (sources.isEmpty()) throw error("FIN-REC-409", "没有符合结算条件的CNY应收费用来源");
|
||||
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());
|
||||
|
||||
@@ -79,7 +81,7 @@ public class ReconciliationStatementService {
|
||||
statement.setLong(12, operatorId);
|
||||
return statement;
|
||||
}, holder);
|
||||
if (holder.getKey() == null) throw error("FIN-DATA-500", "对账单主键生成失败");
|
||||
if (holder.getKey() == null) throw error("FIN_001", "对账单主键生成失败");
|
||||
long statementId = holder.getKey().longValue();
|
||||
jdbc.update("UPDATE fin_bill SET bill_no=? WHERE id=?", stableNo(statementId), statementId);
|
||||
for (Source source : sources) {
|
||||
@@ -88,7 +90,7 @@ public class ReconciliationStatementService {
|
||||
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", "费用来源已被其他对账单占用");
|
||||
if (locked != 1) throw error("FIN_409", "费用来源已被其他对账单占用");
|
||||
}
|
||||
CreationMetadata metadata = new CreationMetadata(request.generationMode().toUpperCase(Locale.ROOT),
|
||||
request.periodStart(), request.periodEnd());
|
||||
@@ -98,7 +100,7 @@ public class ReconciliationStatementService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ReconciliationDetail action(long operatorId, long statementId, String key, DocumentActionRequest request) {
|
||||
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,
|
||||
@@ -106,16 +108,23 @@ public class ReconciliationStatementService {
|
||||
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)) requireNoReceipt(before.customerId(), statementId);
|
||||
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=?,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)) {
|
||||
if (changed != 1) throw error("FIN_409", "对账单状态或版本已变化");
|
||||
if ("ISSUE".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());
|
||||
} else if ("VOID".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='AVAILABLE' WHERE l.tenant_id=? AND l.bill_id=? AND f.customer_id=? AND f.status IN ('LOCKED','BILLED')",
|
||||
before.customerId(), statementId, before.customerId());
|
||||
}
|
||||
ReconciliationDetail after = detail(before.customerId(), statementId);
|
||||
audit(before.customerId(), statementId, "RECONCILIATION_" + action, request.reason(), before, after, operatorId);
|
||||
@@ -123,19 +132,49 @@ public class ReconciliationStatementService {
|
||||
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,updated_by=?,version=version+1 WHERE id=? AND tenant_id=? AND status='ISSUED' AND version=?",
|
||||
total, total, 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<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",
|
||||
List<ReconciliationLine> lines = jdbc.query(
|
||||
"SELECT f.id,l.id statement_line_id,f.source_type,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,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.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",
|
||||
"SELECT r.id,r.received_at,rb.applied_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);
|
||||
List<ReconciliationRevision> revisions = jdbc.query(
|
||||
"SELECT action,reason,before_data,after_data,operator_id,created_at FROM fin_audit_log WHERE tenant_id=? AND resource_type='RECONCILIATION_STATEMENT' AND resource_id=? 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.createdAt(),
|
||||
bill.updatedAt(), bill.remark(), lines, payments);
|
||||
bill.updatedAt(), bill.remark(), lines, payments, revisions);
|
||||
}
|
||||
|
||||
public ReconciliationDetail detailForPlatform(long statementId) {
|
||||
return detail(bill(statementId).customerId(), statementId);
|
||||
}
|
||||
|
||||
private List<Source> eligibleSources(CreateReconciliationRequest request) {
|
||||
@@ -143,6 +182,14 @@ public class ReconciliationStatementService {
|
||||
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())) {
|
||||
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(',');
|
||||
@@ -155,7 +202,7 @@ public class ReconciliationStatementService {
|
||||
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", "所选费用来源不存在、跨客户或不符合结算条件");
|
||||
throw error("FIN_409", "所选费用来源不存在、跨客户或不符合结算条件");
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
@@ -169,33 +216,33 @@ public class ReconciliationStatementService {
|
||||
|
||||
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", "平台财务主体未配置");
|
||||
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-REC-404", "对账单不存在");
|
||||
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-REC-404", "对账单不存在或不属于客户");
|
||||
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-DATA-500", "对账单期间审计快照缺失");
|
||||
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-DATA-500", "对账单期间审计快照损坏");
|
||||
throw error("FIN_001", "对账单期间审计快照损坏");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,20 +252,31 @@ public class ReconciliationStatementService {
|
||||
bill.customerId(), bill.id(), bill.version(), reason, json(bill), json(lines), operatorId);
|
||||
}
|
||||
|
||||
private void requireNoReceipt(long customerId, long statementId) {
|
||||
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-REC-409", "已有收款记录的对账单不可作废");
|
||||
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.source_fee_id AND l.source_version=f.source_fee_version 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-IDEM-400", "必须提供有效的Idempotency-Key");
|
||||
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-IDEM-409", "Idempotency-Key已用于其他请求");
|
||||
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;
|
||||
@@ -226,7 +284,7 @@ public class ReconciliationStatementService {
|
||||
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", "相同请求正在处理中");
|
||||
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);
|
||||
@@ -236,7 +294,7 @@ public class ReconciliationStatementService {
|
||||
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", "幂等请求完成状态冲突");
|
||||
if (changed != 1) throw error("FIN_409", "幂等请求完成状态冲突");
|
||||
}
|
||||
|
||||
private void audit(long customerId, long statementId, String action, String reason,
|
||||
@@ -248,47 +306,76 @@ public class ReconciliationStatementService {
|
||||
|
||||
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", "对账单客户和期间无效");
|
||||
|| 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-REQ-400", "对账单生成方式无效");
|
||||
if (request.remark() != null && request.remark().length() > 500) throw error("FIN-REQ-400", "备注不能超过500个字符");
|
||||
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-REQ-400", "勾选生成必须选择费用来源");
|
||||
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-REQ-400", "费用来源ID无效或重复");
|
||||
throw error("FIN_400", "费用来源ID无效或重复");
|
||||
}
|
||||
} else if (request.feeSourceIds() != null && !request.feeSourceIds().isEmpty()) {
|
||||
throw error("FIN-REQ-400", "按截止期生成不能同时指定费用来源");
|
||||
throw error("FIN_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 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", "ISSUED");
|
||||
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", "REJECTED", "ISSUED").contains(status)) throw error("FIN-REC-409", "当前状态不允许作废");
|
||||
if (!Set.of("DRAFT", "PENDING_REVIEW", "APPROVED", "ISSUED").contains(status)) throw error("FIN_409", "当前状态不允许作废");
|
||||
yield new Transition(status, "VOIDED");
|
||||
}
|
||||
default -> throw error("FIN-REQ-400", "对账单动作无效");
|
||||
default -> throw error("FIN_400", "对账单动作无效");
|
||||
};
|
||||
}
|
||||
|
||||
private static Transition requireTransition(String actual, String expected, String target) {
|
||||
if (!expected.equals(actual)) throw error("FIN-REC-409", "当前状态不允许执行该动作");
|
||||
if (!expected.equals(actual)) throw error("FIN_409", "当前状态不允许执行该动作");
|
||||
return new Transition(expected, target);
|
||||
}
|
||||
|
||||
private static String reconciliationStatus(String billStatus) {
|
||||
return "ISSUED".equals(billStatus) ? "APPROVED" : 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 {
|
||||
@@ -298,21 +385,37 @@ public class ReconciliationStatementService {
|
||||
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 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"), nullableLong(rs, "inbound_id"),
|
||||
nullableLong(rs, "container_id"), rs.getString("fee_item_name"), rs.getBigDecimal("source_amount"),
|
||||
rs.getBigDecimal("statement_amount"), rs.getString("currency_code"), rs.getString("adjustment_reason"));
|
||||
}
|
||||
|
||||
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.getBigDecimal("applied_amount"), rs.getString("payment_method"), rs.getString("voucher_no"),
|
||||
rs.getLong("created_by"), rs.getTimestamp("created_at").toLocalDateTime(), rs.getString("remark"));
|
||||
}
|
||||
|
||||
private ReconciliationRevision revision(ResultSet rs, int revisionNo) throws SQLException {
|
||||
return new ReconciliationRevision(revisionNo, rs.getString("action"), amountFromJson(rs.getString("before_data")),
|
||||
amountFromJson(rs.getString("after_data")), rs.getString("reason"), rs.getLong("operator_id"),
|
||||
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"),
|
||||
@@ -323,12 +426,12 @@ public class ReconciliationStatementService {
|
||||
|
||||
private String json(Object value) {
|
||||
try { return objectMapper.writeValueAsString(value); }
|
||||
catch (Exception exception) { throw error("FIN-DATA-500", "财务数据序列化失败"); }
|
||||
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-DATA-500", "幂等请求哈希失败"); }
|
||||
catch (Exception exception) { throw error("FIN_001", "幂等请求哈希失败"); }
|
||||
}
|
||||
|
||||
private static LocalDateTime localTime(Object value) {
|
||||
|
||||
Reference in New Issue
Block a user