优化
This commit is contained in:
+17
@@ -56,6 +56,23 @@ public class FinancePhase1OperationController {
|
||||
return service.listDerivedCustomerAccountLedger(customerId, page, pageSize, businessType, startDate, endDate);
|
||||
}
|
||||
|
||||
@GetMapping("/fee-sources")
|
||||
@PreAuthorize("hasAuthority('platform:finance:customer-arap:list')")
|
||||
public Object listFinanceFeeSources(@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "20") int pageSize,
|
||||
@RequestParam(required = false) Long customerId,
|
||||
@RequestParam(required = false) String direction,
|
||||
@RequestParam(required = false) String status,
|
||||
@RequestParam(required = false) String reviewStatus,
|
||||
@RequestParam(required = false) Boolean settlementIncluded,
|
||||
@RequestParam(required = false) Boolean statementGenerated,
|
||||
@RequestParam(required = false) LocalDate businessDateFrom,
|
||||
@RequestParam(required = false) LocalDate businessDateTo,
|
||||
@RequestParam(required = false) String keyword) {
|
||||
return service.listFinanceFeeSources(page, pageSize, customerId, direction, status, reviewStatus,
|
||||
settlementIncluded, statementGenerated, businessDateFrom, businessDateTo, keyword);
|
||||
}
|
||||
|
||||
@GetMapping("/fee-sources/{feeSourceId}")
|
||||
@PreAuthorize("hasAuthority('platform:finance:customer-arap:list')")
|
||||
public ApiResponse<?> getFinanceFeeSource(@PathVariable long feeSourceId) {
|
||||
|
||||
@@ -74,7 +74,7 @@ public class PlatformFinanceController {
|
||||
public ApiResponse<?> statements(
|
||||
@Parameter(description = "可选客户筛选;省略时查询全部客户")
|
||||
@RequestParam(required = false) Long customerId) {
|
||||
return FinanceResponses.success(queryService.statements(customerId, false));
|
||||
return FinanceResponses.success(queryService.reconciliationStatements(customerId));
|
||||
}
|
||||
|
||||
@PostMapping(value = "/vouchers", consumes = "multipart/form-data")
|
||||
|
||||
@@ -37,10 +37,21 @@ public final class FinancePhase1Dtos {
|
||||
BigDecimal outstandingReceivableAfter, String operatorName,
|
||||
String remark) {}
|
||||
|
||||
public record FeeSourceDetail(long feeSourceId, String sourceType, long sourceFeeId,
|
||||
public record FeeSourceSummary(long feeSourceId, String sourceSystem, String sourceType,
|
||||
String sourceBusinessId, long sourceFeeId, int sourceVersion,
|
||||
String direction, long customerId, String customerName,
|
||||
Long supplierId, String supplierName, Long orderId,
|
||||
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) {}
|
||||
|
||||
public record FeeSourceDetail(long feeSourceId, String sourceSystem, String sourceType,
|
||||
String sourceBusinessId, long sourceFeeId,
|
||||
int sourceVersion, String direction, long customerId,
|
||||
String customerName, Long supplierId, String supplierName,
|
||||
Long inboundId, String inboundCode, Long containerId,
|
||||
Long orderId, Long inboundId, String inboundCode, Long containerId,
|
||||
String containerNo, LocalDate businessDate, String feeItemName,
|
||||
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount,
|
||||
String currency, String reviewStatus, boolean settlementIncluded,
|
||||
|
||||
@@ -76,6 +76,37 @@ public final class FinanceReadModels {
|
||||
String updatedByName,
|
||||
LocalDateTime updatedAt) {}
|
||||
|
||||
public record ReconciliationSummary(
|
||||
long statementId,
|
||||
String statementNo,
|
||||
long customerId,
|
||||
String customerName,
|
||||
LocalDate periodStart,
|
||||
LocalDate periodEnd,
|
||||
int creditDays,
|
||||
String currency,
|
||||
int lineCount,
|
||||
int sourceCount,
|
||||
BigDecimal sourceAmount,
|
||||
BigDecimal adjustmentAmount,
|
||||
BigDecimal totalReceivable,
|
||||
BigDecimal receivedAmount,
|
||||
BigDecimal outstandingAmount,
|
||||
LocalDateTime lastReceiptAt,
|
||||
boolean settled,
|
||||
String status,
|
||||
LocalDateTime submittedAt,
|
||||
LocalDateTime reviewedAt,
|
||||
LocalDateTime issuedAt,
|
||||
int version,
|
||||
String remark,
|
||||
long createdBy,
|
||||
String createdByName,
|
||||
LocalDateTime createdAt,
|
||||
long updatedBy,
|
||||
String updatedByName,
|
||||
LocalDateTime updatedAt) {}
|
||||
|
||||
public record StatementDetail(Statement statement, List<StatementLine> lines) {}
|
||||
|
||||
public record StatementLine(
|
||||
|
||||
@@ -22,14 +22,18 @@ public final class FinanceSettlementDtos {
|
||||
|
||||
public record SettlementSummary(long settlementId, String settlementNo, String direction, long customerId,
|
||||
String customerName, Long supplierId, String supplierName,
|
||||
Long reconciliationStatementId, String businessPeriod, String currency,
|
||||
Long reconciliationStatementId, String reconciliationStatementNo,
|
||||
String businessPeriod, String feeItemSummary, String currency,
|
||||
BigDecimal totalAmount, BigDecimal paidAmount, BigDecimal outstandingAmount,
|
||||
LocalDate generatedDate, LocalDate dueDate, String paymentStatus,
|
||||
String status, int version) {}
|
||||
String status, long createdBy, String createdByName,
|
||||
LocalDateTime createdAt, LocalDateTime updatedAt,
|
||||
LocalDateTime lastPaymentAt, 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,
|
||||
String supplierName, Long orderId, String orderNo, 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) {}
|
||||
|
||||
@@ -29,14 +29,18 @@ public final class ReconciliationStatementDtos {
|
||||
List<ReconciliationRevision> revisions) {}
|
||||
|
||||
public record ReconciliationLine(long statementLineId, long feeSourceId, int sourceVersion,
|
||||
String sourceHash, Long inboundId, Long containerId,
|
||||
String sourceHash, String direction, String sourceType,
|
||||
String sourceNo, Long orderId, Long inboundId, Long containerId,
|
||||
String inboundCode, String containerNo, LocalDate businessDate,
|
||||
BigDecimal quantity, BigDecimal unitPrice,
|
||||
String feeItemName, BigDecimal sourceAmount,
|
||||
BigDecimal statementAmount, String currency,
|
||||
String adjustmentReason) {}
|
||||
|
||||
public record PaymentHistory(long transactionId, String transactionType,
|
||||
LocalDate transactionDate, BigDecimal amount,
|
||||
String method, String referenceNo, long createdBy, String createdByName,
|
||||
String method, String referenceNo, List<String> voucherFileIds,
|
||||
long createdBy, String createdByName,
|
||||
LocalDateTime createdAt, String remark) {}
|
||||
|
||||
public record ReconciliationRevision(int revisionNo, String action, BigDecimal beforeAmount,
|
||||
|
||||
+77
-2
@@ -2,6 +2,7 @@ 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.FeeSourceSummary;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.LedgerEntry;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.OperationLog;
|
||||
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
|
||||
@@ -86,6 +87,52 @@ public class FinancePhase1OperationService {
|
||||
return PageEnvelope.success(List.copyOf(projected.subList(from, to)), page, pageSize, projected.size());
|
||||
}
|
||||
|
||||
public PageEnvelope<FeeSourceSummary> listFinanceFeeSources(int page, int pageSize, Long customerId,
|
||||
String direction, String sourceStatus,
|
||||
String reviewStatus, Boolean settlementIncluded,
|
||||
Boolean statementGenerated, LocalDate businessDateFrom,
|
||||
LocalDate businessDateTo, String keyword) {
|
||||
page = page(page); pageSize = pageSize(pageSize);
|
||||
if (customerId != null) customer(customerId);
|
||||
if (businessDateFrom != null && businessDateTo != null && businessDateFrom.isAfter(businessDateTo)) {
|
||||
throw error("FIN-REQ-400", "开始日期不能晚于结束日期");
|
||||
}
|
||||
String normalizedDirection = normalized(direction, List.of("RECEIVABLE", "PAYABLE"), "费用方向无效");
|
||||
String normalizedReview = normalized(reviewStatus, List.of("PENDING", "APPROVED", "REJECTED"), "审核状态无效");
|
||||
String normalizedSourceStatus = normalized(sourceStatus,
|
||||
List.of("AVAILABLE", "LOCKED", "BILLED", "VOIDED", "SUPERSEDED"), "费用来源状态无效");
|
||||
String generatedSql = "EXISTS(SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version)";
|
||||
String effectiveStatusSql = "CASE WHEN f.status='AVAILABLE' AND " + generatedSql + " THEN 'BILLED' ELSE f.status END";
|
||||
String businessDateSql = "STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.businessDate')),'%Y-%m-%d')";
|
||||
StringBuilder where = new StringBuilder(" FROM fin_fee_source f WHERE 1=1");
|
||||
List<Object> args = new ArrayList<>();
|
||||
if (customerId != null) { where.append(" AND f.customer_id=?"); args.add(customerId); }
|
||||
if (normalizedDirection != null) { where.append(" AND f.direction=?"); args.add(normalizedDirection); }
|
||||
if (normalizedReview != null) { where.append(" AND f.review_status=?"); args.add(normalizedReview); }
|
||||
if (normalizedSourceStatus != null) { where.append(" AND ").append(effectiveStatusSql).append("=?"); args.add(normalizedSourceStatus); }
|
||||
if (settlementIncluded != null) { where.append(" AND f.settlement_included=?"); args.add(settlementIncluded); }
|
||||
if (statementGenerated != null) { where.append(" AND ").append(generatedSql).append("=?"); args.add(statementGenerated); }
|
||||
if (businessDateFrom != null) { where.append(" AND ").append(businessDateSql).append(">=?"); args.add(businessDateFrom); }
|
||||
if (businessDateTo != null) { where.append(" AND ").append(businessDateSql).append("<=?"); args.add(businessDateTo); }
|
||||
String normalizedKeyword = blankToNull(keyword);
|
||||
if (normalizedKeyword != null) {
|
||||
where.append(" AND (f.source_no LIKE CONCAT('%',?,'%') OR f.fee_item_name LIKE CONCAT('%',?,'%')")
|
||||
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.customerName')) LIKE CONCAT('%',?,'%')")
|
||||
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.inboundCode')) LIKE CONCAT('%',?,'%')")
|
||||
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.containerNo')) LIKE CONCAT('%',?,'%')")
|
||||
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.partnerName')) LIKE CONCAT('%',?,'%')")
|
||||
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.supplierName')) LIKE CONCAT('%',?,'%'))");
|
||||
for (int i = 0; i < 7; i++) args.add(normalizedKeyword);
|
||||
}
|
||||
Long total = jdbc.queryForObject("SELECT COUNT(*)" + where, Long.class, args.toArray());
|
||||
List<Object> queryArgs = new ArrayList<>(args);
|
||||
queryArgs.add(pageSize); queryArgs.add((page - 1) * pageSize);
|
||||
String select = "SELECT f.*," + generatedSql + " statement_generated," + effectiveStatusSql + " effective_status" +
|
||||
where + " ORDER BY " + businessDateSql + " DESC,f.id DESC LIMIT ? OFFSET ?";
|
||||
List<FeeSourceSummary> rows = jdbc.query(select, (rs, n) -> summary(rs), queryArgs.toArray());
|
||||
return PageEnvelope.success(rows, page, pageSize, total == null ? 0 : total);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -136,11 +183,29 @@ public class FinancePhase1OperationService {
|
||||
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,
|
||||
return new FeeSourceDetail(id, rs.getString("source_system"), rs.getString("source_type"), firstText(rs.getString("source_no"), text(snapshot, "sourceBusinessId")), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"), rs.getString("direction"), rs.getLong("customer_id"), text(snapshot, "customerName"), firstLong(jsonLong(snapshot, "supplierId"), jsonLong(snapshot, "partnerId")), firstText(text(snapshot, "supplierName"), text(snapshot, "partnerName")),
|
||||
nullableLong(rs, "order_id"), 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 FeeSourceSummary summary(ResultSet rs) throws SQLException {
|
||||
JsonNode snapshot = parse(rs.getString("source_snapshot"));
|
||||
boolean generated = rs.getBoolean("statement_generated");
|
||||
BigDecimal quantity = decimal(snapshot, "chargeableQuantity");
|
||||
BigDecimal amount = money(rs, "amount");
|
||||
BigDecimal unitPrice = quantity != null && quantity.signum() > 0
|
||||
? amount.divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null;
|
||||
return new FeeSourceSummary(rs.getLong("id"), rs.getString("source_system"), rs.getString("source_type"),
|
||||
firstText(rs.getString("source_no"), text(snapshot, "sourceBusinessId")), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"),
|
||||
rs.getString("direction"), rs.getLong("customer_id"), text(snapshot, "customerName"),
|
||||
firstLong(jsonLong(snapshot, "supplierId"), jsonLong(snapshot, "partnerId")), firstText(text(snapshot, "supplierName"), text(snapshot, "partnerName")), nullableLong(rs, "order_id"),
|
||||
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"),
|
||||
rs.getString("effective_status"), generated);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -186,6 +251,13 @@ public class FinancePhase1OperationService {
|
||||
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 String normalized(String value, List<String> allowed, String message) {
|
||||
String normalized = blankToNull(value);
|
||||
if (normalized == null) return null;
|
||||
normalized = normalized.toUpperCase(java.util.Locale.ROOT);
|
||||
if (!allowed.contains(normalized)) throw error("FIN-REQ-400", message);
|
||||
return normalized;
|
||||
}
|
||||
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; }
|
||||
@@ -193,6 +265,9 @@ public class FinancePhase1OperationService {
|
||||
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 Long jsonLong(JsonNode node, String field) { JsonNode value = node.path(field); return value.isIntegralNumber() && value.asLong() > 0 ? value.asLong() : null; }
|
||||
private static Long firstLong(Long first, Long second) { return first != null ? first : second; }
|
||||
private static String firstText(String first, String second) { return StringUtils.hasText(first) ? first : second; }
|
||||
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); }
|
||||
|
||||
@@ -15,6 +15,7 @@ import com.baiyue.finance.dto.FinanceReadModels.PayableSettlement;
|
||||
import com.baiyue.finance.dto.FinanceReadModels.PlatformReceipt;
|
||||
import com.baiyue.finance.dto.FinanceReadModels.ReceivableSettlement;
|
||||
import com.baiyue.finance.dto.FinanceReadModels.Statement;
|
||||
import com.baiyue.finance.dto.FinanceReadModels.ReconciliationSummary;
|
||||
import com.baiyue.finance.dto.FinanceReadModels.StatementDetail;
|
||||
import com.baiyue.finance.dto.FinanceReadModels.StatementLine;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
@@ -27,6 +28,7 @@ import java.math.BigDecimal;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
@@ -135,6 +137,34 @@ public class FinanceQueryService {
|
||||
rs.getLong("updated_by"), rs.getString("updated_by_name"), time(rs, "updated_at")), args);
|
||||
}
|
||||
|
||||
public List<ReconciliationSummary> reconciliationStatements(Long customerId) {
|
||||
validateOptionalCustomer(customerId);
|
||||
String sql = "SELECT b.id,b.tenant_id,b.bill_no,b.customer_name_snapshot,b.currency_code,b.status,b.version,b.remark," +
|
||||
"b.total_amount,b.received_amount,b.outstanding_amount,b.issued_at,b.created_by,b.created_by_name,b.created_at,b.updated_by,b.updated_by_name,b.updated_at," +
|
||||
"COALESCE(p.credit_days,30) credit_days," +
|
||||
"CAST(JSON_UNQUOTE(JSON_EXTRACT(c.after_data,'$.periodStart')) AS DATE) period_start," +
|
||||
"CAST(JSON_UNQUOTE(JSON_EXTRACT(c.after_data,'$.periodEnd')) AS DATE) period_end," +
|
||||
"COALESCE(l.line_count,0) line_count,COALESCE(l.source_count,0) source_count,COALESCE(l.source_amount,0) source_amount," +
|
||||
"b.total_amount-COALESCE(l.source_amount,0) adjustment_amount,r.last_receipt_at," +
|
||||
"a.submitted_at,a.reviewed_at " +
|
||||
"FROM fin_bill b LEFT JOIN fin_customer_settlement_profile p ON p.tenant_id=b.tenant_id " +
|
||||
"LEFT JOIN fin_audit_log c ON c.tenant_id=b.tenant_id AND c.resource_type='RECONCILIATION_STATEMENT' AND c.resource_id=b.id AND c.action='RECONCILIATION_CREATED' AND c.id=(SELECT MIN(c2.id) FROM fin_audit_log c2 WHERE c2.tenant_id=b.tenant_id AND c2.resource_type='RECONCILIATION_STATEMENT' AND c2.resource_id=b.id AND c2.action='RECONCILIATION_CREATED') " +
|
||||
"LEFT JOIN (SELECT bl.tenant_id,bl.bill_id,COUNT(*) line_count,COUNT(DISTINCT bl.source_id) source_count,SUM(f.amount) source_amount FROM fin_bill_line bl JOIN fin_fee_source f ON f.id=bl.source_id AND f.customer_id=bl.tenant_id GROUP BY bl.tenant_id,bl.bill_id) l ON l.tenant_id=b.tenant_id AND l.bill_id=b.id " +
|
||||
"LEFT JOIN (SELECT rb.tenant_id,rb.bill_id,MAX(r.received_at) last_receipt_at FROM fin_receipt_bill rb JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id AND r.status='CONFIRMED' GROUP BY rb.tenant_id,rb.bill_id) r ON r.tenant_id=b.tenant_id AND r.bill_id=b.id " +
|
||||
"LEFT JOIN (SELECT tenant_id,resource_id,MAX(CASE WHEN action='RECONCILIATION_SUBMIT' THEN created_at END) submitted_at,MAX(CASE WHEN action IN ('RECONCILIATION_APPROVE','RECONCILIATION_REJECT') THEN created_at END) reviewed_at FROM fin_audit_log WHERE resource_type='RECONCILIATION_STATEMENT' GROUP BY tenant_id,resource_id) a ON a.tenant_id=b.tenant_id AND a.resource_id=b.id WHERE 1=1";
|
||||
Object[] args = new Object[0];
|
||||
if (customerId != null) { sql += " AND b.tenant_id=?"; args = new Object[]{customerId}; }
|
||||
return jdbc.query(sql + " ORDER BY b.bill_date DESC,b.id DESC", (rs, rowNum) -> new ReconciliationSummary(
|
||||
rs.getLong("id"), rs.getString("bill_no"), rs.getLong("tenant_id"), rs.getString("customer_name_snapshot"),
|
||||
date(rs, "period_start"), date(rs, "period_end"), rs.getInt("credit_days"), rs.getString("currency_code"),
|
||||
rs.getInt("line_count"), rs.getInt("source_count"), rs.getBigDecimal("source_amount"), rs.getBigDecimal("adjustment_amount"),
|
||||
rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"), rs.getBigDecimal("outstanding_amount"),
|
||||
time(rs, "last_receipt_at"), rs.getBigDecimal("outstanding_amount").signum() == 0, rs.getString("status"),
|
||||
time(rs, "submitted_at"), time(rs, "reviewed_at"), time(rs, "issued_at"), rs.getInt("version"), rs.getString("remark"),
|
||||
rs.getLong("created_by"), rs.getString("created_by_name"), time(rs, "created_at"),
|
||||
rs.getLong("updated_by"), rs.getString("updated_by_name"), time(rs, "updated_at")), args);
|
||||
}
|
||||
|
||||
public StatementDetail statement(long customerId, long billId, boolean clientVisibleOnly) {
|
||||
requireCustomer(customerId);
|
||||
String visible = clientVisibleOnly ? " AND status IN ('ISSUED','PARTIALLY_PAID','PAID')" : "";
|
||||
@@ -249,6 +279,11 @@ public class FinanceQueryService {
|
||||
return timestamp == null ? null : timestamp.toLocalDateTime();
|
||||
}
|
||||
|
||||
private static LocalDate date(ResultSet rs, String column) throws SQLException {
|
||||
java.sql.Date date = rs.getDate(column);
|
||||
return date == null ? null : date.toLocalDate();
|
||||
}
|
||||
|
||||
private static void requireCustomer(long customerId) {
|
||||
if (customerId <= 0) throw bad("客户范围无效");
|
||||
}
|
||||
|
||||
@@ -43,6 +43,22 @@ import java.util.UUID;
|
||||
public class FinanceSettlementService {
|
||||
private static final String CNY = "CNY";
|
||||
private static final Set<String> PAYMENT_METHODS = Set.of("BANK_TRANSFER", "CASH", "OTHER");
|
||||
private static final String SETTLEMENT_SUMMARY_SELECT =
|
||||
"SELECT s.id,s.tenant_id,s.settlement_no,s.direction,s.customer_name_snapshot,s.supplier_id," +
|
||||
"s.supplier_name_snapshot,s.reconciliation_statement_id,b.bill_no reconciliation_statement_no," +
|
||||
"s.business_period,(SELECT GROUP_CONCAT(DISTINCT fs.fee_item_name ORDER BY fs.fee_item_name SEPARATOR '、') " +
|
||||
"FROM fin_settlement_line sl JOIN fin_fee_source fs ON fs.id=sl.fee_source_id " +
|
||||
"WHERE sl.tenant_id=s.tenant_id AND sl.settlement_id=s.id) fee_item_summary," +
|
||||
"s.currency_code,s.total_amount,s.paid_amount,s.outstanding_amount,s.generated_date,s.due_date," +
|
||||
"s.payment_status,s.status,s.created_by,(SELECT a.operator_name_snapshot FROM fin_audit_log a " +
|
||||
"WHERE a.tenant_id=s.tenant_id AND a.resource_type='FIN_SETTLEMENT' AND a.resource_id=s.id " +
|
||||
"AND a.action='CREATE' ORDER BY a.created_at,a.id LIMIT 1) created_by_name,s.created_at,s.updated_at," +
|
||||
"CASE WHEN s.direction='RECEIVABLE' THEN (SELECT MAX(r.created_at) 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=s.tenant_id AND rb.bill_id=s.reconciliation_statement_id AND r.status='CONFIRMED') " +
|
||||
"ELSE (SELECT MAX(p.created_at) FROM fin_settlement_payment p WHERE p.tenant_id=s.tenant_id " +
|
||||
"AND p.settlement_id=s.id) END last_payment_at,s.version FROM fin_settlement s " +
|
||||
"LEFT JOIN fin_bill b ON b.tenant_id=s.tenant_id AND b.id=s.reconciliation_statement_id ";
|
||||
private final JdbcTemplate jdbc;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final FinanceVoucherValidator voucherValidator;
|
||||
@@ -164,14 +180,12 @@ public class FinanceSettlementService {
|
||||
public SettlementDetail getSettlement(long settlementId) {
|
||||
if (settlementId <= 0) throw error("FIN_404", "结算单不存在");
|
||||
Map<String, Object> row = one(
|
||||
"SELECT id,tenant_id,settlement_no,direction,customer_name_snapshot,supplier_id,supplier_name_snapshot," +
|
||||
"reconciliation_statement_id,business_period,currency_code,total_amount,paid_amount,outstanding_amount," +
|
||||
"generated_date,due_date,payment_status,status,version FROM fin_settlement WHERE id=?",
|
||||
SETTLEMENT_SUMMARY_SELECT + "WHERE s.id=?",
|
||||
"结算单不存在", settlementId);
|
||||
SettlementSummary summary = summary(row);
|
||||
List<SettlementLine> lines = jdbc.queryForList(
|
||||
"SELECT f.id fee_source_id,f.source_type,f.source_fee_id,f.source_fee_version,f.direction,f.customer_id," +
|
||||
"f.inbound_id,f.container_id,f.fee_item_name,f.amount,f.currency_code,f.status,f.source_snapshot " +
|
||||
"f.order_id,f.inbound_id,f.container_id,f.fee_item_name,f.amount,f.currency_code,f.status,f.source_snapshot " +
|
||||
"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();
|
||||
@@ -202,17 +216,14 @@ public class FinanceSettlementService {
|
||||
if (customerId != null && customerId <= 0) {
|
||||
throw error("FIN_400", "客户范围无效");
|
||||
}
|
||||
String sql = "SELECT id,tenant_id,settlement_no,direction,customer_name_snapshot,supplier_id," +
|
||||
"supplier_name_snapshot,reconciliation_statement_id,business_period,currency_code,total_amount," +
|
||||
"paid_amount,outstanding_amount,generated_date,due_date,payment_status,status,version " +
|
||||
"FROM fin_settlement WHERE direction=?";
|
||||
String sql = SETTLEMENT_SUMMARY_SELECT + "WHERE s.direction=?";
|
||||
List<Object> args = new ArrayList<>();
|
||||
args.add(direction);
|
||||
if (customerId != null) {
|
||||
sql += " AND tenant_id=?";
|
||||
sql += " AND s.tenant_id=?";
|
||||
args.add(customerId);
|
||||
}
|
||||
sql += " ORDER BY generated_date DESC,id DESC";
|
||||
sql += " ORDER BY s.generated_date DESC,s.id DESC";
|
||||
return jdbc.queryForList(sql, args.toArray()).stream().map(this::summary).toList();
|
||||
}
|
||||
|
||||
@@ -427,10 +438,13 @@ public class FinanceSettlementService {
|
||||
return new SettlementSummary(number(row, "id").longValue(), text(row, "settlement_no"),
|
||||
text(row, "direction"), number(row, "tenant_id").longValue(), text(row, "customer_name_snapshot"),
|
||||
nullableNumber(row, "supplier_id"), text(row, "supplier_name_snapshot"),
|
||||
nullableNumber(row, "reconciliation_statement_id"), text(row, "business_period"),
|
||||
nullableNumber(row, "reconciliation_statement_id"), text(row, "reconciliation_statement_no"),
|
||||
text(row, "business_period"), text(row, "fee_item_summary"),
|
||||
text(row, "currency_code"), decimal(row, "total_amount"), decimal(row, "paid_amount"),
|
||||
decimal(row, "outstanding_amount"), date(row, "generated_date"), date(row, "due_date"),
|
||||
text(row, "payment_status"), text(row, "status"), number(row, "version").intValue());
|
||||
text(row, "payment_status"), text(row, "status"), number(row, "created_by").longValue(),
|
||||
text(row, "created_by_name"), time(row, "created_at"), time(row, "updated_at"),
|
||||
nullableTime(row.get("last_payment_at")), number(row, "version").intValue());
|
||||
}
|
||||
|
||||
private SettlementLine line(Map<String, Object> row, SettlementSummary settlement) {
|
||||
@@ -446,6 +460,7 @@ public class FinanceSettlementService {
|
||||
text(row, "direction"), number(row, "customer_id").longValue(), jsonText(snapshot, "customerName"),
|
||||
supplierId, firstText(jsonText(snapshot, "supplierName"),
|
||||
firstText(jsonText(snapshot, "partnerName"), settlement.supplierName())),
|
||||
nullableNumber(row, "order_id"), jsonText(snapshot, "orderNo"),
|
||||
nullableNumber(row, "inbound_id"), jsonText(snapshot, "inboundCode"), nullableNumber(row, "container_id"),
|
||||
jsonText(snapshot, "containerNo"), jsonDate(snapshot, "businessDate"), text(row, "fee_item_name"),
|
||||
quantity, unitPrice, amount, text(row, "currency_code"), firstText(jsonText(snapshot, "reviewStatus"), "PENDING"),
|
||||
@@ -561,6 +576,9 @@ public class FinanceSettlementService {
|
||||
if (value instanceof LocalDateTime time) return time;
|
||||
return value == null ? null : LocalDateTime.parse(value.toString());
|
||||
}
|
||||
private static LocalDateTime nullableTime(Object value) {
|
||||
return value == null ? null : localTime(value);
|
||||
}
|
||||
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) {
|
||||
|
||||
+31
-7
@@ -163,10 +163,16 @@ public class ReconciliationStatementService {
|
||||
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_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.id=l.source_id AND f.customer_id=l.tenant_id WHERE l.tenant_id=? AND l.bill_id=? ORDER BY l.id",
|
||||
"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.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",
|
||||
"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 before_data IS NOT NULL ORDER BY created_at,id",
|
||||
@@ -394,21 +400,39 @@ public class ReconciliationStatementService {
|
||||
|
||||
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.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 static PaymentHistory payment(ResultSet rs) throws SQLException {
|
||||
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"),
|
||||
rs.getLong("created_by"), rs.getString("created_by_name"),
|
||||
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"), amountFromJson(rs.getString("before_data")),
|
||||
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());
|
||||
|
||||
@@ -52,12 +52,16 @@ class FinancePhase1OperationBehaviorTest {
|
||||
|
||||
controller.listDerivedCustomerAccounts(1, 20, "柏悦", "ACTIVE", 9L);
|
||||
controller.listDerivedCustomerAccountLedger(9L, 2, 50, "RECEIPT", LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31));
|
||||
controller.listFinanceFeeSources(1, 20, 9L, "RECEIVABLE", "AVAILABLE", "APPROVED", true,
|
||||
false, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9");
|
||||
controller.getFinanceFeeSource(88L);
|
||||
controller.reviewFinanceFeeSource(user, 88L, "review-key", review);
|
||||
controller.updateFeeSourceSettlementInclusion(user, 88L, "include-key", inclusion);
|
||||
|
||||
verify(customerAccountService).list(1, 20, "柏悦", "ACTIVE", 9L);
|
||||
verify(operationService).listDerivedCustomerAccountLedger(eq(9L), eq(2), eq(50), eq("RECEIPT"), any(), any());
|
||||
verify(operationService).listFinanceFeeSources(eq(1), eq(20), eq(9L), eq("RECEIVABLE"),
|
||||
eq("AVAILABLE"), eq("APPROVED"), eq(true), eq(false), any(), any(), eq("IN-9"));
|
||||
verify(operationService).getFinanceFeeSource(88L);
|
||||
verify(operationService).reviewFinanceFeeSource(88L, 71L, "review-key", review);
|
||||
verify(operationService).updateFeeSourceSettlementInclusion(88L, 71L, "include-key", inclusion);
|
||||
@@ -112,6 +116,59 @@ class FinancePhase1OperationBehaviorTest {
|
||||
assertThat(jdbc.ledgerCustomerIds).containsExactly(9L, 9L, 9L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void feeSourceListUsesFormalFieldsAndAllActiveFilters() throws Exception {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source"), eq(Long.class), any(Object[].class)))
|
||||
.thenReturn(1L);
|
||||
when(jdbc.query(startsWith("SELECT f.*"), any(RowMapper.class), any(Object[].class))).thenAnswer(invocation -> {
|
||||
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
|
||||
ResultSet rs = mock(ResultSet.class);
|
||||
when(rs.getLong("id")).thenReturn(88L);
|
||||
when(rs.getLong("source_fee_id")).thenReturn(9001L);
|
||||
when(rs.getInt("source_fee_version")).thenReturn(3);
|
||||
when(rs.getLong("customer_id")).thenReturn(9L);
|
||||
when(rs.getLong("order_id")).thenReturn(19L);
|
||||
when(rs.getLong("inbound_id")).thenReturn(29L);
|
||||
when(rs.getLong("container_id")).thenReturn(39L);
|
||||
when(rs.getString("source_system")).thenReturn("FIRSTLEG");
|
||||
when(rs.getString("source_type")).thenReturn("INBOUND_FEE");
|
||||
when(rs.getString("source_no")).thenReturn("IN-9");
|
||||
when(rs.getString("direction")).thenReturn("RECEIVABLE");
|
||||
when(rs.getString("fee_item_name")).thenReturn("入库操作费");
|
||||
when(rs.getString("currency_code")).thenReturn("CNY");
|
||||
when(rs.getString("review_status")).thenReturn("APPROVED");
|
||||
when(rs.getString("effective_status")).thenReturn("AVAILABLE");
|
||||
when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"partnerId\":79,\"partnerName\":\"供应商A\",\"inboundCode\":\"IN-9\",\"containerNo\":\"CONT-9\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\"}");
|
||||
when(rs.getBigDecimal("amount")).thenReturn(new BigDecimal("120.00"));
|
||||
when(rs.getBoolean("settlement_included")).thenReturn(true);
|
||||
when(rs.getBoolean("statement_generated")).thenReturn(false);
|
||||
return List.of(mapper.mapRow(rs, 0));
|
||||
});
|
||||
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
|
||||
|
||||
var result = service.listFinanceFeeSources(2, 50, 9L, "receivable", "available", "approved",
|
||||
true, false, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9");
|
||||
|
||||
assertThat(result.page()).satisfies(meta -> {
|
||||
assertThat(meta.page()).isEqualTo(2);
|
||||
assertThat(meta.pageSize()).isEqualTo(50);
|
||||
assertThat(meta.total()).isEqualTo(1);
|
||||
});
|
||||
assertThat(result.data()).singleElement().satisfies(source -> {
|
||||
assertThat(source.sourceSystem()).isEqualTo("FIRSTLEG");
|
||||
assertThat(source.sourceBusinessId()).isEqualTo("IN-9");
|
||||
assertThat(source.inboundCode()).isEqualTo("IN-9");
|
||||
assertThat(source.containerNo()).isEqualTo("CONT-9");
|
||||
assertThat(source.supplierName()).isEqualTo("供应商A");
|
||||
assertThat(source.reviewStatus()).isEqualTo("APPROVED");
|
||||
assertThat(source.businessDate()).isEqualTo(LocalDate.of(2026, 8, 11));
|
||||
assertThat(source.settlementIncluded()).isTrue();
|
||||
assertThat(source.statementGenerated()).isFalse();
|
||||
});
|
||||
verify(jdbc).query(startsWith("SELECT f.*"), any(RowMapper.class), any(Object[].class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ledgerReturnsStableBusinessTypeDocumentAndOperatorNameSnapshot() throws Exception {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
@@ -245,9 +302,12 @@ class FinancePhase1OperationBehaviorTest {
|
||||
when(rs.getLong("id")).thenReturn(88L);
|
||||
when(rs.getLong("source_fee_id")).thenReturn(700L);
|
||||
when(rs.getLong("customer_id")).thenReturn(9L);
|
||||
when(rs.getLong("order_id")).thenReturn(90L);
|
||||
when(rs.getLong("inbound_id")).thenReturn(100L);
|
||||
when(rs.getLong("container_id")).thenReturn(200L);
|
||||
when(rs.getInt("source_fee_version")).thenReturn(3);
|
||||
when(rs.getString("source_system")).thenReturn("FIRSTLEG");
|
||||
when(rs.getString("source_no")).thenReturn("IN-100");
|
||||
when(rs.getString("source_type")).thenReturn("INBOUND_FEE");
|
||||
when(rs.getString("direction")).thenReturn("RECEIVABLE");
|
||||
when(rs.getString("fee_item_name")).thenReturn("头程运费");
|
||||
|
||||
@@ -73,20 +73,31 @@ class FinancePlatformListContractTest {
|
||||
|
||||
@Test
|
||||
void financeDetailsKeepOperatorIdsAndExposeNameSnapshots() {
|
||||
assertRecordFields(com.baiyue.finance.dto.FinanceReadModels.ReconciliationSummary.class,
|
||||
"periodStart", "periodEnd", "lineCount", "sourceCount", "sourceAmount", "adjustmentAmount",
|
||||
"lastReceiptAt", "submittedAt", "reviewedAt", "issuedAt");
|
||||
assertRecordFields(com.baiyue.finance.dto.FinanceReadModels.Statement.class,
|
||||
"createdBy", "createdByName", "updatedBy", "updatedByName");
|
||||
assertRecordFields(com.baiyue.finance.dto.FinanceReadModels.BillRevision.class,
|
||||
"createdBy", "createdByName");
|
||||
assertRecordFields(com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail.class,
|
||||
"createdBy", "createdByName", "updatedBy", "updatedByName");
|
||||
assertRecordFields(com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationLine.class,
|
||||
"direction", "sourceType", "sourceNo", "orderId", "inboundCode", "containerNo",
|
||||
"businessDate", "quantity", "unitPrice");
|
||||
assertRecordFields(com.baiyue.finance.dto.ReconciliationStatementDtos.PaymentHistory.class,
|
||||
"createdBy", "createdByName");
|
||||
"voucherFileIds", "createdBy", "createdByName");
|
||||
assertRecordFields(com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision.class,
|
||||
"operatorId", "operatorName");
|
||||
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementTransaction.class,
|
||||
"createdBy", "createdByName");
|
||||
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementRevision.class,
|
||||
"operatorId", "operatorName");
|
||||
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary.class,
|
||||
"reconciliationStatementNo", "feeItemSummary", "createdBy", "createdByName", "createdAt",
|
||||
"updatedAt", "lastPaymentAt");
|
||||
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementLine.class,
|
||||
"orderId", "orderNo", "inboundId", "inboundCode", "containerId", "containerNo");
|
||||
}
|
||||
|
||||
private static void assertRecordFields(Class<?> type, String... names) {
|
||||
|
||||
+5
-2
@@ -20,10 +20,11 @@ class FinancePlatformOptionalCustomerQueryTest {
|
||||
|
||||
service.feePool(null, null);
|
||||
service.statements(null, false);
|
||||
service.reconciliationStatements(null);
|
||||
service.receivableSettlements(null, false);
|
||||
service.payableSettlements(null);
|
||||
|
||||
assertThat(jdbc.calls).hasSize(5).allSatisfy(call -> {
|
||||
assertThat(jdbc.calls).hasSize(6).allSatisfy(call -> {
|
||||
assertThat(call.sql()).doesNotContain("customer_id=?", "tenant_id=?");
|
||||
assertThat(call.args()).isEmpty();
|
||||
});
|
||||
@@ -36,10 +37,11 @@ class FinancePlatformOptionalCustomerQueryTest {
|
||||
|
||||
service.feePool(31L, null);
|
||||
service.statements(31L, false);
|
||||
service.reconciliationStatements(31L);
|
||||
service.receivableSettlements(31L, false);
|
||||
service.payableSettlements(31L);
|
||||
|
||||
assertThat(jdbc.calls).hasSize(5).allSatisfy(call -> {
|
||||
assertThat(jdbc.calls).hasSize(6).allSatisfy(call -> {
|
||||
assertThat(call.sql()).containsAnyOf("customer_id=?", "tenant_id=?");
|
||||
assertThat(call.args()).containsExactly(31L);
|
||||
});
|
||||
@@ -50,6 +52,7 @@ class FinancePlatformOptionalCustomerQueryTest {
|
||||
FinanceQueryService service = new FinanceQueryService(new RecordingJdbc(), new ObjectMapper());
|
||||
|
||||
assertThatThrownBy(() -> service.statements(0L, false)).hasMessageContaining("客户范围无效");
|
||||
assertThatThrownBy(() -> service.reconciliationStatements(0L)).hasMessageContaining("客户范围无效");
|
||||
assertThatThrownBy(() -> service.feePool(-1L, null)).hasMessageContaining("客户范围无效");
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ class FinanceReadApiBehaviorTest {
|
||||
settlementController.payableSettlements(11L);
|
||||
|
||||
verify(queryService).customerAccount(11L);
|
||||
verify(queryService).statements(11L, false);
|
||||
verify(queryService).reconciliationStatements(11L);
|
||||
verify(settlementService).listSettlements("RECEIVABLE", 11L);
|
||||
verify(settlementService).listSettlements("PAYABLE", 11L);
|
||||
}
|
||||
@@ -93,10 +93,10 @@ class FinanceReadApiBehaviorTest {
|
||||
controller.statements(31L);
|
||||
|
||||
verify(queryService).feePool(null, null);
|
||||
verify(queryService).statements(null, false);
|
||||
verify(queryService).reconciliationStatements(null);
|
||||
verify(settlementService).listSettlements("RECEIVABLE", null);
|
||||
verify(settlementService).listSettlements("PAYABLE", null);
|
||||
verify(queryService).statements(31L, false);
|
||||
verify(queryService).reconciliationStatements(31L);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -102,10 +102,10 @@ class FinanceSettlementBehaviorTest {
|
||||
ArgumentCaptor<String> scopedSql = ArgumentCaptor.forClass(String.class);
|
||||
verify(jdbc).queryForList(allSql.capture(), eq("PAYABLE"));
|
||||
verify(jdbc).queryForList(scopedSql.capture(), eq("PAYABLE"), eq(10L));
|
||||
assertThat(allSql.getValue()).contains("FROM fin_settlement WHERE direction=?")
|
||||
.doesNotContain("FROM fin_bill", "FROM fin_cost", "tenant_id=?");
|
||||
assertThat(scopedSql.getValue()).contains("FROM fin_settlement WHERE direction=?", "tenant_id=?")
|
||||
.doesNotContain("FROM fin_bill", "FROM fin_cost");
|
||||
assertThat(allSql.getValue()).contains("FROM fin_settlement s", "WHERE s.direction=?")
|
||||
.doesNotContain("FROM fin_cost", "AND s.tenant_id=?");
|
||||
assertThat(scopedSql.getValue()).contains("FROM fin_settlement s", "WHERE s.direction=?", "AND s.tenant_id=?")
|
||||
.doesNotContain("FROM fin_cost");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -232,7 +232,7 @@ class FinanceSettlementBehaviorTest {
|
||||
when(jdbc.queryForList(startsWith("SELECT id,tenant_id,direction"), eq(88L))).thenReturn(List.of(before));
|
||||
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L),
|
||||
eq("SETTLEMENT_PAYMENT_CREATE"), eq("pay-key"))).thenReturn(List.of());
|
||||
when(jdbc.queryForList(startsWith("SELECT id,tenant_id,settlement_no"), eq(88L))).thenReturn(List.of(after));
|
||||
when(jdbc.queryForList(startsWith("SELECT s.id,s.tenant_id,s.settlement_no"), eq(88L))).thenReturn(List.of(after));
|
||||
when(jdbc.queryForList(startsWith("SELECT f.id fee_source_id"), eq(10L), eq(88L))).thenReturn(List.of());
|
||||
when(jdbc.queryForList(startsWith("SELECT id,transaction_type"), eq(10L), eq(88L))).thenReturn(List.of(Map.ofEntries(
|
||||
Map.entry("id", 501L), Map.entry("transaction_type", "PAYMENT"),
|
||||
@@ -355,7 +355,7 @@ class FinanceSettlementBehaviorTest {
|
||||
@Test
|
||||
void settlementDetailRejectsMissingResource() {
|
||||
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
|
||||
when(jdbc.queryForList(startsWith("SELECT id,tenant_id,settlement_no"), eq(999L))).thenReturn(List.of());
|
||||
when(jdbc.queryForList(startsWith("SELECT s.id,s.tenant_id,s.settlement_no"), eq(999L))).thenReturn(List.of());
|
||||
|
||||
assertCode(() -> service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)).getSettlement(999L), "FIN_404");
|
||||
}
|
||||
@@ -574,7 +574,7 @@ class FinanceSettlementBehaviorTest {
|
||||
}
|
||||
|
||||
private static void stubSettlementDetail(JdbcTemplate jdbc, Map<String, Object> settlement) {
|
||||
when(jdbc.queryForList(startsWith("SELECT id,tenant_id,settlement_no"), eq(88L))).thenReturn(List.of(settlement));
|
||||
when(jdbc.queryForList(startsWith("SELECT s.id,s.tenant_id,s.settlement_no"), eq(88L))).thenReturn(List.of(settlement));
|
||||
long customerId = ((Number) settlement.get("tenant_id")).longValue();
|
||||
when(jdbc.queryForList(startsWith("SELECT f.id fee_source_id"), eq(customerId), eq(88L))).thenReturn(List.of());
|
||||
when(jdbc.queryForList(startsWith("SELECT id,transaction_type"), eq(customerId), eq(88L))).thenReturn(List.of());
|
||||
@@ -594,24 +594,32 @@ class FinanceSettlementBehaviorTest {
|
||||
|
||||
private static Map<String, Object> settlementRow(String direction, String status, BigDecimal paid,
|
||||
BigDecimal outstanding, int version) {
|
||||
return new java.util.HashMap<>(Map.ofEntries(
|
||||
java.util.HashMap<String, Object> row = new java.util.HashMap<>(Map.ofEntries(
|
||||
Map.entry("id", 88L), Map.entry("tenant_id", 10L), Map.entry("settlement_no", "AP-20260811-001"),
|
||||
Map.entry("direction", direction), Map.entry("customer_name_snapshot", "客户10"),
|
||||
Map.entry("supplier_id", 20L), Map.entry("supplier_name_snapshot", "供应商20"),
|
||||
Map.entry("business_period", "2026-08"), Map.entry("currency_code", "CNY"),
|
||||
Map.entry("business_period", "2026-08"), Map.entry("fee_item_summary", "拖车费"),
|
||||
Map.entry("currency_code", "CNY"),
|
||||
Map.entry("total_amount", paid.add(outstanding)), Map.entry("paid_amount", paid),
|
||||
Map.entry("outstanding_amount", outstanding), Map.entry("generated_date", Date.valueOf("2026-08-11")),
|
||||
Map.entry("due_date", Date.valueOf("2026-09-10")),
|
||||
Map.entry("payment_status", paid.signum() == 0 ? "UNPAID" : status), Map.entry("status", status),
|
||||
Map.entry("version", version)));
|
||||
Map.entry("created_by", 7L), Map.entry("created_by_name", "platform_admin"),
|
||||
Map.entry("created_at", Timestamp.valueOf("2026-08-11 09:00:00")),
|
||||
Map.entry("updated_at", Timestamp.valueOf("2026-08-11 09:00:00")), Map.entry("version", version)));
|
||||
row.put("reconciliation_statement_id", null);
|
||||
row.put("reconciliation_statement_no", null);
|
||||
row.put("last_payment_at", null);
|
||||
return row;
|
||||
}
|
||||
|
||||
private static SettlementDetail detail(String direction, String status, BigDecimal paid,
|
||||
BigDecimal outstanding, int version) {
|
||||
return new SettlementDetail(new SettlementSummary(88L, "SET-88", direction, 10L, "客户10", 20L,
|
||||
"供应商20", null, "2026-08", "CNY", paid.add(outstanding), paid, outstanding,
|
||||
"供应商20", null, null, "2026-08", "拖车费", "CNY", paid.add(outstanding), paid, outstanding,
|
||||
LocalDate.of(2026, 8, 11), LocalDate.of(2026, 9, 10), paid.signum() == 0 ? "UNPAID" : status,
|
||||
status, version), List.of(), List.of(), List.of());
|
||||
status, 7L, "platform_admin", LocalDateTime.of(2026, 8, 11, 9, 0),
|
||||
LocalDateTime.of(2026, 8, 11, 9, 0), null, version), List.of(), List.of(), List.of());
|
||||
}
|
||||
|
||||
private static AuthenticatedUser user(Set<String> permissions) {
|
||||
|
||||
+6
-4
@@ -47,16 +47,18 @@ class FinanceExportProviderServiceTest {
|
||||
Statement summary = new Statement(501L, 10001L, "REC-501", "客户A", LocalDate.parse("2026-08-31"),
|
||||
null, new BigDecimal("300"), new BigDecimal("100"), new BigDecimal("200"), "CNY", "ISSUED", 3,
|
||||
7L, "创建人A", LocalDateTime.parse("2026-08-01T09:00:00"), 8L, "更新人B", LocalDateTime.parse("2026-08-20T11:00:00"));
|
||||
ReconciliationLine line = new ReconciliationLine(601L, 81L, 2, "hash", 21L, 31L, "运费",
|
||||
ReconciliationLine line = new ReconciliationLine(601L, 81L, 2, "hash", "RECEIVABLE", "CONTAINER_FEE",
|
||||
"CONT-31", 11L, 21L, 31L, "IN-21", "CONT-31", LocalDate.parse("2026-08-15"), BigDecimal.TEN,
|
||||
new BigDecimal("30"), "运费",
|
||||
new BigDecimal("320"), new BigDecimal("300"), "CNY", "优惠调整");
|
||||
ReconciliationDetail detail = new ReconciliationDetail(501L, "REC-501", 10001L, "客户A",
|
||||
LocalDate.parse("2026-08-01"), LocalDate.parse("2026-08-31"), 30, "CNY", new BigDecimal("300"),
|
||||
new BigDecimal("100"), new BigDecimal("200"), false, "ISSUED", 3, 7L, "创建人A",
|
||||
LocalDateTime.parse("2026-08-01T09:00:00"), 8L, "更新人B", LocalDateTime.parse("2026-08-20T11:00:00"),
|
||||
"八月账单", List.of(line), List.of(new PaymentHistory(701L, "RECEIPT", LocalDate.parse("2026-08-20"),
|
||||
new BigDecimal("100"), "BANK_TRANSFER", "PAY-701", 9L, "财务员", LocalDateTime.parse("2026-08-20T10:00:00"), "")), List.of());
|
||||
FeeSourceDetail source = new FeeSourceDetail(81L, "CONTAINER_FEE", 9001L, 2, "RECEIVABLE", 10001L,
|
||||
"客户A", null, null, 21L, "IN-21", 31L, "CONT-31", LocalDate.parse("2026-08-15"), "运费",
|
||||
new BigDecimal("100"), "BANK_TRANSFER", "PAY-701", List.of("9001"), 9L, "财务员", LocalDateTime.parse("2026-08-20T10:00:00"), "")), List.of());
|
||||
FeeSourceDetail source = new FeeSourceDetail(81L, "FIRSTLEG", "CONTAINER_FEE", "CONT-31", 9001L, 2, "RECEIVABLE", 10001L,
|
||||
"客户A", null, null, null, 21L, "IN-21", 31L, "CONT-31", LocalDate.parse("2026-08-15"), "运费",
|
||||
BigDecimal.TEN, new BigDecimal("30"), new BigDecimal("300"), "CNY", "APPROVED", true,
|
||||
"AVAILABLE", true, null, null, "hash", 2, List.of());
|
||||
when(queryService.statements(10001L, false)).thenReturn(List.of(summary));
|
||||
|
||||
Reference in New Issue
Block a user