From 7447e748a0d8f33a9e223532e924cc03b9ed49d3 Mon Sep 17 00:00:00 2001 From: bot_dev2 Date: Mon, 15 Jun 2026 12:28:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(wm-revenue):=20#83=20=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E8=90=A5=E6=94=B6=E6=80=BB=E8=A7=88+=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=EF=BC=88Dashboard+=E5=A4=9A=E7=BB=B4?= =?UTF-8?q?=E5=BA=A6=E6=8A=A5=E8=A1=A8+=E5=AF=BC=E5=87=BA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RevenueDashboardController.java | 62 +++ .../controller/RevenueQueryController.java | 110 +++++ .../service/RevenueDashboardService.java | 238 +++++++++++ .../revenue/service/RevenueQueryService.java | 391 ++++++++++++++++++ .../resources/sql/V83__revenue_dashboard.sql | 17 + 5 files changed, 818 insertions(+) create mode 100644 wm-revenue/src/main/java/com/water/revenue/controller/RevenueDashboardController.java create mode 100644 wm-revenue/src/main/java/com/water/revenue/controller/RevenueQueryController.java create mode 100644 wm-revenue/src/main/java/com/water/revenue/service/RevenueDashboardService.java create mode 100644 wm-revenue/src/main/java/com/water/revenue/service/RevenueQueryService.java create mode 100644 wm-revenue/src/main/resources/sql/V83__revenue_dashboard.sql diff --git a/wm-revenue/src/main/java/com/water/revenue/controller/RevenueDashboardController.java b/wm-revenue/src/main/java/com/water/revenue/controller/RevenueDashboardController.java new file mode 100644 index 00000000..f8aaa56f --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/controller/RevenueDashboardController.java @@ -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> getOverview() { + return R.ok(dashboardService.getOverview()); + } + + @GetMapping("/trend") + @Operation(summary = "营收趋势") + public R>> getRevenueTrend( + @Parameter(description = "周期:day|week|month") + @RequestParam(defaultValue = "month") String period) { + return R.ok(dashboardService.getRevenueTrend(period)); + } + + @GetMapping("/area/{area}") + @Operation(summary = "区域营收") + public R> getAreaRevenue( + @Parameter(description = "区域") @PathVariable String area) { + return R.ok(dashboardService.getAreaRevenue(area)); + } + + @GetMapping("/payment-channels") + @Operation(summary = "支付渠道统计") + public R>> getPaymentChannelStats() { + return R.ok(dashboardService.getPaymentChannelStats()); + } + + @GetMapping("/customer-types") + @Operation(summary = "客户类型统计") + public R>> getCustomerTypeStats() { + return R.ok(dashboardService.getCustomerTypeStats()); + } + + @GetMapping("/top-overdue") + @Operation(summary = "欠费大户") + public R>> getTopOverdueCustomers( + @Parameter(description = "数量限制") + @RequestParam(defaultValue = "10") Integer limit) { + return R.ok(dashboardService.getTopOverdueCustomers(limit)); + } +} diff --git a/wm-revenue/src/main/java/com/water/revenue/controller/RevenueQueryController.java b/wm-revenue/src/main/java/com/water/revenue/controller/RevenueQueryController.java new file mode 100644 index 00000000..56d8d97b --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/controller/RevenueQueryController.java @@ -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> 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 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> 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 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> 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> yearlyReport( + @Parameter(description = "年份") @RequestParam Integer year) { + return R.ok(queryService.yearlyReport(year)); + } + + @GetMapping("/export/bills") + @Operation(summary = "导出账单CSV") + public R 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 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)); + } +} diff --git a/wm-revenue/src/main/java/com/water/revenue/service/RevenueDashboardService.java b/wm-revenue/src/main/java/com/water/revenue/service/RevenueDashboardService.java new file mode 100644 index 00000000..ef044571 --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/service/RevenueDashboardService.java @@ -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 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 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> 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> trends = jdbcTemplate.queryForList(sql, startDate); + + log.info("Revenue trend retrieved: {} data points", trends.size()); + return trends; + } + + /** + * 按区域统计营收 + */ + public Map getAreaRevenue(String area) { + log.info("Getting area revenue for: {}", area); + + Map 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> getPaymentChannelStats() { + log.info("Getting payment channel statistics"); + + List> 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> getCustomerTypeStats() { + log.info("Getting customer type statistics"); + + List> 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> getTopOverdueCustomers(int limit) { + log.info("Getting top overdue customers, limit: {}", limit); + + List> 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); + } +} diff --git a/wm-revenue/src/main/java/com/water/revenue/service/RevenueQueryService.java b/wm-revenue/src/main/java/com/water/revenue/service/RevenueQueryService.java new file mode 100644 index 00000000..a72d6736 --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/service/RevenueQueryService.java @@ -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 queryBills(Map 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 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> 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 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 queryPayments(Map 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 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> 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 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 monthlyReport(int year, int month) { + log.info("Generating monthly report: {}-{}", year, month); + + String period = String.format("%04d-%02d", year, month); + + Map report = new HashMap<>(); + report.put("year", year); + report.put("month", month); + report.put("period", period); + + // 账单统计 + Map 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 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> 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> 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 yearlyReport(int year) { + log.info("Generating yearly report: {}", year); + + Map report = new HashMap<>(); + report.put("year", year); + + // 年度总计 + Map 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 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> 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> 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 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 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> bills = jdbcTemplate.queryForList(sql.toString(), params.toArray()); + + // 生成 CSV + StringBuilder csv = new StringBuilder(); + csv.append("账单编号,客户名称,客户编号,区域,客户类型,水表号,账期,用水量,水费,污水处理费,") + .append("总金额,已缴金额,状态,到期日期,创建时间\n"); + + for (Map 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; + } +} diff --git a/wm-revenue/src/main/resources/sql/V83__revenue_dashboard.sql b/wm-revenue/src/main/resources/sql/V83__revenue_dashboard.sql new file mode 100644 index 00000000..86f8178c --- /dev/null +++ b/wm-revenue/src/main/resources/sql/V83__revenue_dashboard.sql @@ -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);