feat(wm-revenue): #83 首页营收总览+查询统计(Dashboard+多维度报表+导出)

This commit is contained in:
2026-06-15 12:30:33 +08:00
parent 69b0355d6c
commit 7447e748a0
5 changed files with 818 additions and 0 deletions
@@ -0,0 +1,62 @@
package com.water.revenue.controller;
import com.water.common.core.result.R;
import com.water.revenue.service.RevenueDashboardService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@Tag(name = "营收总览")
@RestController
@RequestMapping("/revenue/dashboard")
@RequiredArgsConstructor
public class RevenueDashboardController {
private final RevenueDashboardService dashboardService;
@GetMapping("/overview")
@Operation(summary = "首页总览")
public R<Map<String, Object>> getOverview() {
return R.ok(dashboardService.getOverview());
}
@GetMapping("/trend")
@Operation(summary = "营收趋势")
public R<List<Map<String, Object>>> getRevenueTrend(
@Parameter(description = "周期:day|week|month")
@RequestParam(defaultValue = "month") String period) {
return R.ok(dashboardService.getRevenueTrend(period));
}
@GetMapping("/area/{area}")
@Operation(summary = "区域营收")
public R<Map<String, Object>> getAreaRevenue(
@Parameter(description = "区域") @PathVariable String area) {
return R.ok(dashboardService.getAreaRevenue(area));
}
@GetMapping("/payment-channels")
@Operation(summary = "支付渠道统计")
public R<List<Map<String, Object>>> getPaymentChannelStats() {
return R.ok(dashboardService.getPaymentChannelStats());
}
@GetMapping("/customer-types")
@Operation(summary = "客户类型统计")
public R<List<Map<String, Object>>> getCustomerTypeStats() {
return R.ok(dashboardService.getCustomerTypeStats());
}
@GetMapping("/top-overdue")
@Operation(summary = "欠费大户")
public R<List<Map<String, Object>>> getTopOverdueCustomers(
@Parameter(description = "数量限制")
@RequestParam(defaultValue = "10") Integer limit) {
return R.ok(dashboardService.getTopOverdueCustomers(limit));
}
}
@@ -0,0 +1,110 @@
package com.water.revenue.controller;
import com.water.common.core.result.R;
import com.water.revenue.service.RevenueQueryService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;
@Tag(name = "营收查询统计")
@RestController
@RequestMapping("/revenue/query")
@RequiredArgsConstructor
public class RevenueQueryController {
private final RevenueQueryService queryService;
@GetMapping("/bills")
@Operation(summary = "账单查询")
public R<Map<String, Object>> queryBills(
@Parameter(description = "开始日期") @RequestParam(required = false) String startDate,
@Parameter(description = "结束日期") @RequestParam(required = false) String endDate,
@Parameter(description = "区域") @RequestParam(required = false) String area,
@Parameter(description = "客户类型") @RequestParam(required = false) String customerType,
@Parameter(description = "状态") @RequestParam(required = false) String status,
@Parameter(description = "最小金额") @RequestParam(required = false) BigDecimal minAmount,
@Parameter(description = "最大金额") @RequestParam(required = false) BigDecimal maxAmount,
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Integer page,
@Parameter(description = "每页数量") @RequestParam(defaultValue = "20") Integer size) {
Map<String, Object> filters = new HashMap<>();
if (startDate != null) filters.put("startDate", startDate);
if (endDate != null) filters.put("endDate", endDate);
if (area != null) filters.put("area", area);
if (customerType != null) filters.put("customerType", customerType);
if (status != null) filters.put("status", status);
if (minAmount != null) filters.put("minAmount", minAmount);
if (maxAmount != null) filters.put("maxAmount", maxAmount);
filters.put("page", page);
filters.put("size", size);
return R.ok(queryService.queryBills(filters));
}
@GetMapping("/payments")
@Operation(summary = "缴费查询")
public R<Map<String, Object>> queryPayments(
@Parameter(description = "开始日期") @RequestParam(required = false) String startDate,
@Parameter(description = "结束日期") @RequestParam(required = false) String endDate,
@Parameter(description = "区域") @RequestParam(required = false) String area,
@Parameter(description = "支付方式") @RequestParam(required = false) String payMethod,
@Parameter(description = "支付渠道") @RequestParam(required = false) String payChannel,
@Parameter(description = "最小金额") @RequestParam(required = false) BigDecimal minAmount,
@Parameter(description = "最大金额") @RequestParam(required = false) BigDecimal maxAmount,
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Integer page,
@Parameter(description = "每页数量") @RequestParam(defaultValue = "20") Integer size) {
Map<String, Object> filters = new HashMap<>();
if (startDate != null) filters.put("startDate", startDate);
if (endDate != null) filters.put("endDate", endDate);
if (area != null) filters.put("area", area);
if (payMethod != null) filters.put("payMethod", payMethod);
if (payChannel != null) filters.put("payChannel", payChannel);
if (minAmount != null) filters.put("minAmount", minAmount);
if (maxAmount != null) filters.put("maxAmount", maxAmount);
filters.put("page", page);
filters.put("size", size);
return R.ok(queryService.queryPayments(filters));
}
@GetMapping("/report/monthly")
@Operation(summary = "月度报表")
public R<Map<String, Object>> monthlyReport(
@Parameter(description = "年份") @RequestParam Integer year,
@Parameter(description = "月份") @RequestParam Integer month) {
return R.ok(queryService.monthlyReport(year, month));
}
@GetMapping("/report/yearly")
@Operation(summary = "年度报表")
public R<Map<String, Object>> yearlyReport(
@Parameter(description = "年份") @RequestParam Integer year) {
return R.ok(queryService.yearlyReport(year));
}
@GetMapping("/export/bills")
@Operation(summary = "导出账单CSV")
public R<String> exportBills(
@Parameter(description = "开始日期") @RequestParam(required = false) String startDate,
@Parameter(description = "结束日期") @RequestParam(required = false) String endDate,
@Parameter(description = "区域") @RequestParam(required = false) String area,
@Parameter(description = "客户类型") @RequestParam(required = false) String customerType,
@Parameter(description = "状态") @RequestParam(required = false) String status) {
Map<String, Object> filters = new HashMap<>();
if (startDate != null) filters.put("startDate", startDate);
if (endDate != null) filters.put("endDate", endDate);
if (area != null) filters.put("area", area);
if (customerType != null) filters.put("customerType", customerType);
if (status != null) filters.put("status", status);
return R.ok(queryService.exportBills(filters));
}
}
@@ -0,0 +1,238 @@
package com.water.revenue.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.*;
@Slf4j
@Service
@RequiredArgsConstructor
public class RevenueDashboardService {
private final JdbcTemplate jdbcTemplate;
/**
* 首页总览
*/
public Map<String, Object> getOverview() {
log.info("Getting revenue overview");
// 总营收
BigDecimal totalRevenue = jdbcTemplate.queryForObject(
"SELECT COALESCE(SUM(paid_fee), 0) FROM rev_bill", BigDecimal.class);
// 本月营收
BigDecimal monthRevenue = jdbcTemplate.queryForObject(
"SELECT COALESCE(SUM(paid_fee), 0) FROM rev_bill WHERE bill_period = ?",
BigDecimal.class,
LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM")));
// 今日营收
BigDecimal todayRevenue = jdbcTemplate.queryForObject(
"SELECT COALESCE(SUM(amount), 0) FROM rev_payment WHERE DATE(paid_at) = CURRENT_DATE",
BigDecimal.class);
// 待缴账单数
Integer pendingBills = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM rev_bill WHERE status IN ('pending', 'partial')",
Integer.class);
// 逾期账单数
Integer overdueBills = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM rev_bill WHERE status = 'overdue'",
Integer.class);
// 逾期金额
BigDecimal overdueAmount = jdbcTemplate.queryForObject(
"SELECT COALESCE(SUM(total_fee - paid_fee), 0) FROM rev_bill WHERE status = 'overdue'",
BigDecimal.class);
// 收缴率
BigDecimal collectionRate = calculateCollectionRate();
// 客户总数
Integer customerCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM rev_customer", Integer.class);
Map<String, Object> overview = new HashMap<>();
overview.put("totalRevenue", totalRevenue);
overview.put("monthRevenue", monthRevenue);
overview.put("todayRevenue", todayRevenue);
overview.put("pendingBills", pendingBills);
overview.put("overdueBills", overdueBills);
overview.put("overdueAmount", overdueAmount);
overview.put("collectionRate", collectionRate);
overview.put("customerCount", customerCount);
log.info("Overview retrieved: totalRevenue={}, monthRevenue={}", totalRevenue, monthRevenue);
return overview;
}
/**
* 营收趋势(按日/周/月)
*/
public List<Map<String, Object>> getRevenueTrend(String period) {
log.info("Getting revenue trend for period: {}", period);
String dateFormat;
int days;
switch (period.toLowerCase()) {
case "day":
dateFormat = "DATE(paid_at)";
days = 30;
break;
case "week":
dateFormat = "DATE_TRUNC('week', paid_at)";
days = 84; // 12 weeks
break;
case "month":
default:
dateFormat = "TO_CHAR(paid_at, 'YYYY-MM')";
days = 365;
break;
}
LocalDate startDate = LocalDate.now().minusDays(days);
String sql = String.format(
"SELECT %s as period, COALESCE(SUM(amount), 0) as revenue, COUNT(*) as count " +
"FROM rev_payment " +
"WHERE paid_at >= ? " +
"GROUP BY %s " +
"ORDER BY period",
dateFormat, dateFormat);
List<Map<String, Object>> trends = jdbcTemplate.queryForList(sql, startDate);
log.info("Revenue trend retrieved: {} data points", trends.size());
return trends;
}
/**
* 按区域统计营收
*/
public Map<String, Object> getAreaRevenue(String area) {
log.info("Getting area revenue for: {}", area);
Map<String, Object> result = new HashMap<>();
// 区域总营收
BigDecimal totalRevenue = jdbcTemplate.queryForObject(
"SELECT COALESCE(SUM(b.paid_fee), 0) FROM rev_bill b " +
"JOIN rev_customer c ON b.customer_id = c.id " +
"WHERE c.area = ?",
BigDecimal.class, area);
// 区域账单数
Integer billCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM rev_bill b " +
"JOIN rev_customer c ON b.customer_id = c.id " +
"WHERE c.area = ?",
Integer.class, area);
// 区域欠费
BigDecimal overdueAmount = jdbcTemplate.queryForObject(
"SELECT COALESCE(SUM(b.total_fee - b.paid_fee), 0) FROM rev_bill b " +
"JOIN rev_customer c ON b.customer_id = c.id " +
"WHERE c.area = ? AND b.status IN ('pending', 'partial', 'overdue')",
BigDecimal.class, area);
// 区域客户数
Integer customerCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM rev_customer WHERE area = ?",
Integer.class, area);
result.put("area", area);
result.put("totalRevenue", totalRevenue);
result.put("billCount", billCount);
result.put("overdueAmount", overdueAmount);
result.put("customerCount", customerCount);
log.info("Area revenue for {}: totalRevenue={}, billCount={}", area, totalRevenue, billCount);
return result;
}
/**
* 按支付渠道统计
*/
public List<Map<String, Object>> getPaymentChannelStats() {
log.info("Getting payment channel statistics");
List<Map<String, Object>> stats = jdbcTemplate.queryForList(
"SELECT pay_channel, COUNT(*) as count, COALESCE(SUM(amount), 0) as total " +
"FROM rev_payment " +
"GROUP BY pay_channel " +
"ORDER BY total DESC");
log.info("Payment channel stats retrieved: {} channels", stats.size());
return stats;
}
/**
* 按客户类型统计
*/
public List<Map<String, Object>> getCustomerTypeStats() {
log.info("Getting customer type statistics");
List<Map<String, Object>> stats = jdbcTemplate.queryForList(
"SELECT c.customer_type, COUNT(b.id) as billCount, " +
"COALESCE(SUM(b.total_fee), 0) as totalAmount, " +
"COALESCE(SUM(b.paid_fee), 0) as paidAmount " +
"FROM rev_bill b " +
"JOIN rev_customer c ON b.customer_id = c.id " +
"GROUP BY c.customer_type " +
"ORDER BY totalAmount DESC");
log.info("Customer type stats retrieved: {} types", stats.size());
return stats;
}
/**
* 欠费大户排行
*/
public List<Map<String, Object>> getTopOverdueCustomers(int limit) {
log.info("Getting top overdue customers, limit: {}", limit);
List<Map<String, Object>> customers = jdbcTemplate.queryForList(
"SELECT c.id, c.customer_name, c.customer_no, c.area, " +
"COUNT(b.id) as overdueBillCount, " +
"COALESCE(SUM(b.total_fee - b.paid_fee), 0) as overdueAmount " +
"FROM rev_bill b " +
"JOIN rev_customer c ON b.customer_id = c.id " +
"WHERE b.status IN ('overdue', 'pending', 'partial') " +
"GROUP BY c.id, c.customer_name, c.customer_no, c.area " +
"HAVING SUM(b.total_fee - b.paid_fee) > 0 " +
"ORDER BY overdueAmount DESC " +
"LIMIT ?",
limit);
log.info("Top overdue customers retrieved: {} customers", customers.size());
return customers;
}
/**
* 计算收缴率
*/
private BigDecimal calculateCollectionRate() {
BigDecimal totalBilled = jdbcTemplate.queryForObject(
"SELECT COALESCE(SUM(total_fee), 0) FROM rev_bill", BigDecimal.class);
if (totalBilled.compareTo(BigDecimal.ZERO) == 0) {
return BigDecimal.ZERO;
}
BigDecimal totalPaid = jdbcTemplate.queryForObject(
"SELECT COALESCE(SUM(paid_fee), 0) FROM rev_bill", BigDecimal.class);
return totalPaid.multiply(BigDecimal.valueOf(100))
.divide(totalBilled, 2, RoundingMode.HALF_UP);
}
}
@@ -0,0 +1,391 @@
package com.water.revenue.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.*;
@Slf4j
@Service
@RequiredArgsConstructor
public class RevenueQueryService {
private final JdbcTemplate jdbcTemplate;
/**
* 多维度账单查询
*/
public Map<String, Object> queryBills(Map<String, Object> filters) {
log.info("Querying bills with filters: {}", filters);
StringBuilder sql = new StringBuilder(
"SELECT b.*, c.customer_name, c.customer_no, c.area, c.customer_type, m.meter_no " +
"FROM rev_bill b " +
"JOIN rev_customer c ON b.customer_id = c.id " +
"JOIN rev_meter m ON b.meter_id = m.id " +
"WHERE 1=1");
List<Object> params = new ArrayList<>();
// 时间范围
if (filters.get("startDate") != null) {
sql.append(" AND b.created_at >= ?::timestamp");
params.add(filters.get("startDate"));
}
if (filters.get("endDate") != null) {
sql.append(" AND b.created_at <= ?::timestamp");
params.add(filters.get("endDate"));
}
// 区域
if (filters.get("area") != null && !filters.get("area").toString().isEmpty()) {
sql.append(" AND c.area = ?");
params.add(filters.get("area"));
}
// 客户类型
if (filters.get("customerType") != null && !filters.get("customerType").toString().isEmpty()) {
sql.append(" AND c.customer_type = ?");
params.add(filters.get("customerType"));
}
// 状态
if (filters.get("status") != null && !filters.get("status").toString().isEmpty()) {
sql.append(" AND b.status = ?");
params.add(filters.get("status"));
}
// 金额范围
if (filters.get("minAmount") != null) {
sql.append(" AND b.total_fee >= ?");
params.add(new BigDecimal(filters.get("minAmount").toString()));
}
if (filters.get("maxAmount") != null) {
sql.append(" AND b.total_fee <= ?");
params.add(new BigDecimal(filters.get("maxAmount").toString()));
}
sql.append(" ORDER BY b.created_at DESC");
// 分页
int page = filters.get("page") != null ? Integer.parseInt(filters.get("page").toString()) : 1;
int size = filters.get("size") != null ? Integer.parseInt(filters.get("size").toString()) : 20;
int offset = (page - 1) * size;
sql.append(" LIMIT ? OFFSET ?");
params.add(size);
params.add(offset);
List<Map<String, Object>> bills = jdbcTemplate.queryForList(sql.toString(), params.toArray());
// 统计总数
String countSql = sql.toString().replaceFirst("SELECT .*? FROM", "SELECT COUNT(*) FROM")
.replaceAll(" ORDER BY.*", "");
Integer total = jdbcTemplate.queryForObject(countSql, Integer.class,
params.subList(0, params.size() - 2).toArray());
Map<String, Object> result = new HashMap<>();
result.put("records", bills);
result.put("total", total);
result.put("page", page);
result.put("size", size);
result.put("pages", (total + size - 1) / size);
log.info("Bills queried: {} records, total: {}", bills.size(), total);
return result;
}
/**
* 多维度缴费查询
*/
public Map<String, Object> queryPayments(Map<String, Object> filters) {
log.info("Querying payments with filters: {}", filters);
StringBuilder sql = new StringBuilder(
"SELECT p.*, b.bill_no, b.total_fee, c.customer_name, c.customer_no, c.area " +
"FROM rev_payment p " +
"JOIN rev_bill b ON p.bill_id = b.id " +
"JOIN rev_customer c ON p.customer_id = c.id " +
"WHERE 1=1");
List<Object> params = new ArrayList<>();
// 时间范围
if (filters.get("startDate") != null) {
sql.append(" AND p.paid_at >= ?::timestamp");
params.add(filters.get("startDate"));
}
if (filters.get("endDate") != null) {
sql.append(" AND p.paid_at <= ?::timestamp");
params.add(filters.get("endDate"));
}
// 区域
if (filters.get("area") != null && !filters.get("area").toString().isEmpty()) {
sql.append(" AND c.area = ?");
params.add(filters.get("area"));
}
// 支付方式
if (filters.get("payMethod") != null && !filters.get("payMethod").toString().isEmpty()) {
sql.append(" AND p.pay_method = ?");
params.add(filters.get("payMethod"));
}
// 支付渠道
if (filters.get("payChannel") != null && !filters.get("payChannel").toString().isEmpty()) {
sql.append(" AND p.pay_channel = ?");
params.add(filters.get("payChannel"));
}
// 金额范围
if (filters.get("minAmount") != null) {
sql.append(" AND p.amount >= ?");
params.add(new BigDecimal(filters.get("minAmount").toString()));
}
if (filters.get("maxAmount") != null) {
sql.append(" AND p.amount <= ?");
params.add(new BigDecimal(filters.get("maxAmount").toString()));
}
sql.append(" ORDER BY p.paid_at DESC");
// 分页
int page = filters.get("page") != null ? Integer.parseInt(filters.get("page").toString()) : 1;
int size = filters.get("size") != null ? Integer.parseInt(filters.get("size").toString()) : 20;
int offset = (page - 1) * size;
sql.append(" LIMIT ? OFFSET ?");
params.add(size);
params.add(offset);
List<Map<String, Object>> payments = jdbcTemplate.queryForList(sql.toString(), params.toArray());
// 统计总数
String countSql = sql.toString().replaceFirst("SELECT .*? FROM", "SELECT COUNT(*) FROM")
.replaceAll(" ORDER BY.*", "");
Integer total = jdbcTemplate.queryForObject(countSql, Integer.class,
params.subList(0, params.size() - 2).toArray());
Map<String, Object> result = new HashMap<>();
result.put("records", payments);
result.put("total", total);
result.put("page", page);
result.put("size", size);
result.put("pages", (total + size - 1) / size);
log.info("Payments queried: {} records, total: {}", payments.size(), total);
return result;
}
/**
* 月度营收报表
*/
public Map<String, Object> monthlyReport(int year, int month) {
log.info("Generating monthly report: {}-{}", year, month);
String period = String.format("%04d-%02d", year, month);
Map<String, Object> report = new HashMap<>();
report.put("year", year);
report.put("month", month);
report.put("period", period);
// 账单统计
Map<String, Object> billStats = jdbcTemplate.queryForMap(
"SELECT COUNT(*) as billCount, " +
"COALESCE(SUM(total_fee), 0) as totalAmount, " +
"COALESCE(SUM(paid_fee), 0) as paidAmount, " +
"COALESCE(SUM(total_fee - paid_fee), 0) as unpaidAmount " +
"FROM rev_bill WHERE bill_period = ?",
period);
report.putAll(billStats);
// 缴费统计
Map<String, Object> paymentStats = jdbcTemplate.queryForMap(
"SELECT COUNT(*) as paymentCount, " +
"COALESCE(SUM(amount), 0) as paymentAmount " +
"FROM rev_payment p " +
"JOIN rev_bill b ON p.bill_id = b.id " +
"WHERE b.bill_period = ?",
period);
report.putAll(paymentStats);
// 按区域统计
List<Map<String, Object>> areaStats = jdbcTemplate.queryForList(
"SELECT c.area, COUNT(b.id) as billCount, " +
"COALESCE(SUM(b.total_fee), 0) as totalAmount, " +
"COALESCE(SUM(b.paid_fee), 0) as paidAmount " +
"FROM rev_bill b " +
"JOIN rev_customer c ON b.customer_id = c.id " +
"WHERE b.bill_period = ? " +
"GROUP BY c.area " +
"ORDER BY totalAmount DESC",
period);
report.put("areaStats", areaStats);
// 按支付方式统计
List<Map<String, Object>> paymentMethodStats = jdbcTemplate.queryForList(
"SELECT p.pay_method, COUNT(*) as count, " +
"COALESCE(SUM(p.amount), 0) as amount " +
"FROM rev_payment p " +
"JOIN rev_bill b ON p.bill_id = b.id " +
"WHERE b.bill_period = ? " +
"GROUP BY p.pay_method " +
"ORDER BY amount DESC",
period);
report.put("paymentMethodStats", paymentMethodStats);
log.info("Monthly report generated for {}: totalAmount={}, paidAmount={}",
period, billStats.get("totalAmount"), billStats.get("paidAmount"));
return report;
}
/**
* 年度营收报表
*/
public Map<String, Object> yearlyReport(int year) {
log.info("Generating yearly report: {}", year);
Map<String, Object> report = new HashMap<>();
report.put("year", year);
// 年度总计
Map<String, Object> yearlyStats = jdbcTemplate.queryForMap(
"SELECT COUNT(*) as billCount, " +
"COALESCE(SUM(total_fee), 0) as totalAmount, " +
"COALESCE(SUM(paid_fee), 0) as paidAmount, " +
"COALESCE(SUM(total_fee - paid_fee), 0) as unpaidAmount " +
"FROM rev_bill WHERE EXTRACT(YEAR FROM created_at) = ?",
year);
report.putAll(yearlyStats);
// 年度缴费统计
Map<String, Object> paymentStats = jdbcTemplate.queryForMap(
"SELECT COUNT(*) as paymentCount, " +
"COALESCE(SUM(p.amount), 0) as paymentAmount " +
"FROM rev_payment p " +
"JOIN rev_bill b ON p.bill_id = b.id " +
"WHERE EXTRACT(YEAR FROM b.created_at) = ?",
year);
report.putAll(paymentStats);
// 按月统计
List<Map<String, Object>> monthlyStats = jdbcTemplate.queryForList(
"SELECT bill_period as month, COUNT(*) as billCount, " +
"COALESCE(SUM(total_fee), 0) as totalAmount, " +
"COALESCE(SUM(paid_fee), 0) as paidAmount " +
"FROM rev_bill " +
"WHERE EXTRACT(YEAR FROM created_at) = ? " +
"GROUP BY bill_period " +
"ORDER BY bill_period",
year);
report.put("monthlyStats", monthlyStats);
// 按区域统计
List<Map<String, Object>> areaStats = jdbcTemplate.queryForList(
"SELECT c.area, COUNT(b.id) as billCount, " +
"COALESCE(SUM(b.total_fee), 0) as totalAmount, " +
"COALESCE(SUM(b.paid_fee), 0) as paidAmount " +
"FROM rev_bill b " +
"JOIN rev_customer c ON b.customer_id = c.id " +
"WHERE EXTRACT(YEAR FROM b.created_at) = ? " +
"GROUP BY c.area " +
"ORDER BY totalAmount DESC",
year);
report.put("areaStats", areaStats);
log.info("Yearly report generated for {}: totalAmount={}, paidAmount={}",
year, yearlyStats.get("totalAmount"), yearlyStats.get("paidAmount"));
return report;
}
/**
* 导出账单(CSV 格式)
*/
public String exportBills(Map<String, Object> filters) {
log.info("Exporting bills with filters: {}", filters);
StringBuilder sql = new StringBuilder(
"SELECT b.bill_no, c.customer_name, c.customer_no, c.area, c.customer_type, " +
"m.meter_no, b.bill_period, b.consumption, b.water_fee, b.sewage_fee, " +
"b.total_fee, b.paid_fee, b.status, b.due_date, b.created_at " +
"FROM rev_bill b " +
"JOIN rev_customer c ON b.customer_id = c.id " +
"JOIN rev_meter m ON b.meter_id = m.id " +
"WHERE 1=1");
List<Object> params = new ArrayList<>();
// 应用过滤条件(同 queryBills)
if (filters.get("startDate") != null) {
sql.append(" AND b.created_at >= ?::timestamp");
params.add(filters.get("startDate"));
}
if (filters.get("endDate") != null) {
sql.append(" AND b.created_at <= ?::timestamp");
params.add(filters.get("endDate"));
}
if (filters.get("area") != null && !filters.get("area").toString().isEmpty()) {
sql.append(" AND c.area = ?");
params.add(filters.get("area"));
}
if (filters.get("customerType") != null && !filters.get("customerType").toString().isEmpty()) {
sql.append(" AND c.customer_type = ?");
params.add(filters.get("customerType"));
}
if (filters.get("status") != null && !filters.get("status").toString().isEmpty()) {
sql.append(" AND b.status = ?");
params.add(filters.get("status"));
}
sql.append(" ORDER BY b.created_at DESC LIMIT 10000"); // 限制导出数量
List<Map<String, Object>> bills = jdbcTemplate.queryForList(sql.toString(), params.toArray());
// 生成 CSV
StringBuilder csv = new StringBuilder();
csv.append("账单编号,客户名称,客户编号,区域,客户类型,水表号,账期,用水量,水费,污水处理费,")
.append("总金额,已缴金额,状态,到期日期,创建时间\n");
for (Map<String, Object> bill : bills) {
csv.append(escapeCsv(bill.get("bill_no"))).append(",")
.append(escapeCsv(bill.get("customer_name"))).append(",")
.append(escapeCsv(bill.get("customer_no"))).append(",")
.append(escapeCsv(bill.get("area"))).append(",")
.append(escapeCsv(bill.get("customer_type"))).append(",")
.append(escapeCsv(bill.get("meter_no"))).append(",")
.append(escapeCsv(bill.get("bill_period"))).append(",")
.append(bill.get("consumption")).append(",")
.append(bill.get("water_fee")).append(",")
.append(bill.get("sewage_fee")).append(",")
.append(bill.get("total_fee")).append(",")
.append(bill.get("paid_fee")).append(",")
.append(escapeCsv(bill.get("status"))).append(",")
.append(bill.get("due_date")).append(",")
.append(bill.get("created_at")).append("\n");
}
log.info("Bills exported: {} records", bills.size());
return csv.toString();
}
/**
* CSV 字段转义
*/
private String escapeCsv(Object value) {
if (value == null) {
return "";
}
String str = value.toString();
if (str.contains(",") || str.contains("\"") || str.contains("\n")) {
return "\"" + str.replace("\"", "\"\"") + "\"";
}
return str;
}
}
@@ -0,0 +1,17 @@
-- 营收统计缓存表(定时汇总,加速 Dashboard 查询)
CREATE TABLE IF NOT EXISTS rev_revenue_daily (
id BIGSERIAL PRIMARY KEY,
stat_date DATE NOT NULL,
area VARCHAR(50),
customer_type VARCHAR(20),
total_bills INT DEFAULT 0,
total_amount DECIMAL(14,2) DEFAULT 0,
paid_amount DECIMAL(14,2) DEFAULT 0,
overdue_amount DECIMAL(14,2) DEFAULT 0,
new_customers INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(stat_date, area, customer_type)
);
CREATE INDEX IF NOT EXISTS idx_rev_daily_date ON rev_revenue_daily(stat_date);
CREATE INDEX IF NOT EXISTS idx_rev_daily_area ON rev_revenue_daily(area);