Browse Source

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

bot_dev2 4 days ago
parent
commit
7447e748a0

+ 62
- 0
wm-revenue/src/main/java/com/water/revenue/controller/RevenueDashboardController.java View File

1
+package com.water.revenue.controller;
2
+
3
+import com.water.common.core.result.R;
4
+import com.water.revenue.service.RevenueDashboardService;
5
+import io.swagger.v3.oas.annotations.Operation;
6
+import io.swagger.v3.oas.annotations.Parameter;
7
+import io.swagger.v3.oas.annotations.tags.Tag;
8
+import lombok.RequiredArgsConstructor;
9
+import org.springframework.web.bind.annotation.*;
10
+
11
+import java.util.List;
12
+import java.util.Map;
13
+
14
+@Tag(name = "营收总览")
15
+@RestController
16
+@RequestMapping("/revenue/dashboard")
17
+@RequiredArgsConstructor
18
+public class RevenueDashboardController {
19
+
20
+    private final RevenueDashboardService dashboardService;
21
+
22
+    @GetMapping("/overview")
23
+    @Operation(summary = "首页总览")
24
+    public R<Map<String, Object>> getOverview() {
25
+        return R.ok(dashboardService.getOverview());
26
+    }
27
+
28
+    @GetMapping("/trend")
29
+    @Operation(summary = "营收趋势")
30
+    public R<List<Map<String, Object>>> getRevenueTrend(
31
+            @Parameter(description = "周期:day|week|month")
32
+            @RequestParam(defaultValue = "month") String period) {
33
+        return R.ok(dashboardService.getRevenueTrend(period));
34
+    }
35
+
36
+    @GetMapping("/area/{area}")
37
+    @Operation(summary = "区域营收")
38
+    public R<Map<String, Object>> getAreaRevenue(
39
+            @Parameter(description = "区域") @PathVariable String area) {
40
+        return R.ok(dashboardService.getAreaRevenue(area));
41
+    }
42
+
43
+    @GetMapping("/payment-channels")
44
+    @Operation(summary = "支付渠道统计")
45
+    public R<List<Map<String, Object>>> getPaymentChannelStats() {
46
+        return R.ok(dashboardService.getPaymentChannelStats());
47
+    }
48
+
49
+    @GetMapping("/customer-types")
50
+    @Operation(summary = "客户类型统计")
51
+    public R<List<Map<String, Object>>> getCustomerTypeStats() {
52
+        return R.ok(dashboardService.getCustomerTypeStats());
53
+    }
54
+
55
+    @GetMapping("/top-overdue")
56
+    @Operation(summary = "欠费大户")
57
+    public R<List<Map<String, Object>>> getTopOverdueCustomers(
58
+            @Parameter(description = "数量限制")
59
+            @RequestParam(defaultValue = "10") Integer limit) {
60
+        return R.ok(dashboardService.getTopOverdueCustomers(limit));
61
+    }
62
+}

+ 110
- 0
wm-revenue/src/main/java/com/water/revenue/controller/RevenueQueryController.java View File

1
+package com.water.revenue.controller;
2
+
3
+import com.water.common.core.result.R;
4
+import com.water.revenue.service.RevenueQueryService;
5
+import io.swagger.v3.oas.annotations.Operation;
6
+import io.swagger.v3.oas.annotations.Parameter;
7
+import io.swagger.v3.oas.annotations.tags.Tag;
8
+import lombok.RequiredArgsConstructor;
9
+import org.springframework.web.bind.annotation.*;
10
+
11
+import java.math.BigDecimal;
12
+import java.util.HashMap;
13
+import java.util.Map;
14
+
15
+@Tag(name = "营收查询统计")
16
+@RestController
17
+@RequestMapping("/revenue/query")
18
+@RequiredArgsConstructor
19
+public class RevenueQueryController {
20
+
21
+    private final RevenueQueryService queryService;
22
+
23
+    @GetMapping("/bills")
24
+    @Operation(summary = "账单查询")
25
+    public R<Map<String, Object>> queryBills(
26
+            @Parameter(description = "开始日期") @RequestParam(required = false) String startDate,
27
+            @Parameter(description = "结束日期") @RequestParam(required = false) String endDate,
28
+            @Parameter(description = "区域") @RequestParam(required = false) String area,
29
+            @Parameter(description = "客户类型") @RequestParam(required = false) String customerType,
30
+            @Parameter(description = "状态") @RequestParam(required = false) String status,
31
+            @Parameter(description = "最小金额") @RequestParam(required = false) BigDecimal minAmount,
32
+            @Parameter(description = "最大金额") @RequestParam(required = false) BigDecimal maxAmount,
33
+            @Parameter(description = "页码") @RequestParam(defaultValue = "1") Integer page,
34
+            @Parameter(description = "每页数量") @RequestParam(defaultValue = "20") Integer size) {
35
+        
36
+        Map<String, Object> filters = new HashMap<>();
37
+        if (startDate != null) filters.put("startDate", startDate);
38
+        if (endDate != null) filters.put("endDate", endDate);
39
+        if (area != null) filters.put("area", area);
40
+        if (customerType != null) filters.put("customerType", customerType);
41
+        if (status != null) filters.put("status", status);
42
+        if (minAmount != null) filters.put("minAmount", minAmount);
43
+        if (maxAmount != null) filters.put("maxAmount", maxAmount);
44
+        filters.put("page", page);
45
+        filters.put("size", size);
46
+        
47
+        return R.ok(queryService.queryBills(filters));
48
+    }
49
+
50
+    @GetMapping("/payments")
51
+    @Operation(summary = "缴费查询")
52
+    public R<Map<String, Object>> queryPayments(
53
+            @Parameter(description = "开始日期") @RequestParam(required = false) String startDate,
54
+            @Parameter(description = "结束日期") @RequestParam(required = false) String endDate,
55
+            @Parameter(description = "区域") @RequestParam(required = false) String area,
56
+            @Parameter(description = "支付方式") @RequestParam(required = false) String payMethod,
57
+            @Parameter(description = "支付渠道") @RequestParam(required = false) String payChannel,
58
+            @Parameter(description = "最小金额") @RequestParam(required = false) BigDecimal minAmount,
59
+            @Parameter(description = "最大金额") @RequestParam(required = false) BigDecimal maxAmount,
60
+            @Parameter(description = "页码") @RequestParam(defaultValue = "1") Integer page,
61
+            @Parameter(description = "每页数量") @RequestParam(defaultValue = "20") Integer size) {
62
+        
63
+        Map<String, Object> filters = new HashMap<>();
64
+        if (startDate != null) filters.put("startDate", startDate);
65
+        if (endDate != null) filters.put("endDate", endDate);
66
+        if (area != null) filters.put("area", area);
67
+        if (payMethod != null) filters.put("payMethod", payMethod);
68
+        if (payChannel != null) filters.put("payChannel", payChannel);
69
+        if (minAmount != null) filters.put("minAmount", minAmount);
70
+        if (maxAmount != null) filters.put("maxAmount", maxAmount);
71
+        filters.put("page", page);
72
+        filters.put("size", size);
73
+        
74
+        return R.ok(queryService.queryPayments(filters));
75
+    }
76
+
77
+    @GetMapping("/report/monthly")
78
+    @Operation(summary = "月度报表")
79
+    public R<Map<String, Object>> monthlyReport(
80
+            @Parameter(description = "年份") @RequestParam Integer year,
81
+            @Parameter(description = "月份") @RequestParam Integer month) {
82
+        return R.ok(queryService.monthlyReport(year, month));
83
+    }
84
+
85
+    @GetMapping("/report/yearly")
86
+    @Operation(summary = "年度报表")
87
+    public R<Map<String, Object>> yearlyReport(
88
+            @Parameter(description = "年份") @RequestParam Integer year) {
89
+        return R.ok(queryService.yearlyReport(year));
90
+    }
91
+
92
+    @GetMapping("/export/bills")
93
+    @Operation(summary = "导出账单CSV")
94
+    public R<String> exportBills(
95
+            @Parameter(description = "开始日期") @RequestParam(required = false) String startDate,
96
+            @Parameter(description = "结束日期") @RequestParam(required = false) String endDate,
97
+            @Parameter(description = "区域") @RequestParam(required = false) String area,
98
+            @Parameter(description = "客户类型") @RequestParam(required = false) String customerType,
99
+            @Parameter(description = "状态") @RequestParam(required = false) String status) {
100
+        
101
+        Map<String, Object> filters = new HashMap<>();
102
+        if (startDate != null) filters.put("startDate", startDate);
103
+        if (endDate != null) filters.put("endDate", endDate);
104
+        if (area != null) filters.put("area", area);
105
+        if (customerType != null) filters.put("customerType", customerType);
106
+        if (status != null) filters.put("status", status);
107
+        
108
+        return R.ok(queryService.exportBills(filters));
109
+    }
110
+}

+ 238
- 0
wm-revenue/src/main/java/com/water/revenue/service/RevenueDashboardService.java View File

1
+package com.water.revenue.service;
2
+
3
+import lombok.RequiredArgsConstructor;
4
+import lombok.extern.slf4j.Slf4j;
5
+import org.springframework.jdbc.core.JdbcTemplate;
6
+import org.springframework.stereotype.Service;
7
+
8
+import java.math.BigDecimal;
9
+import java.math.RoundingMode;
10
+import java.time.LocalDate;
11
+import java.time.format.DateTimeFormatter;
12
+import java.util.*;
13
+
14
+@Slf4j
15
+@Service
16
+@RequiredArgsConstructor
17
+public class RevenueDashboardService {
18
+
19
+    private final JdbcTemplate jdbcTemplate;
20
+
21
+    /**
22
+     * 首页总览
23
+     */
24
+    public Map<String, Object> getOverview() {
25
+        log.info("Getting revenue overview");
26
+        
27
+        // 总营收
28
+        BigDecimal totalRevenue = jdbcTemplate.queryForObject(
29
+            "SELECT COALESCE(SUM(paid_fee), 0) FROM rev_bill", BigDecimal.class);
30
+        
31
+        // 本月营收
32
+        BigDecimal monthRevenue = jdbcTemplate.queryForObject(
33
+            "SELECT COALESCE(SUM(paid_fee), 0) FROM rev_bill WHERE bill_period = ?",
34
+            BigDecimal.class,
35
+            LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM")));
36
+        
37
+        // 今日营收
38
+        BigDecimal todayRevenue = jdbcTemplate.queryForObject(
39
+            "SELECT COALESCE(SUM(amount), 0) FROM rev_payment WHERE DATE(paid_at) = CURRENT_DATE",
40
+            BigDecimal.class);
41
+        
42
+        // 待缴账单数
43
+        Integer pendingBills = jdbcTemplate.queryForObject(
44
+            "SELECT COUNT(*) FROM rev_bill WHERE status IN ('pending', 'partial')",
45
+            Integer.class);
46
+        
47
+        // 逾期账单数
48
+        Integer overdueBills = jdbcTemplate.queryForObject(
49
+            "SELECT COUNT(*) FROM rev_bill WHERE status = 'overdue'",
50
+            Integer.class);
51
+        
52
+        // 逾期金额
53
+        BigDecimal overdueAmount = jdbcTemplate.queryForObject(
54
+            "SELECT COALESCE(SUM(total_fee - paid_fee), 0) FROM rev_bill WHERE status = 'overdue'",
55
+            BigDecimal.class);
56
+        
57
+        // 收缴率
58
+        BigDecimal collectionRate = calculateCollectionRate();
59
+        
60
+        // 客户总数
61
+        Integer customerCount = jdbcTemplate.queryForObject(
62
+            "SELECT COUNT(*) FROM rev_customer", Integer.class);
63
+        
64
+        Map<String, Object> overview = new HashMap<>();
65
+        overview.put("totalRevenue", totalRevenue);
66
+        overview.put("monthRevenue", monthRevenue);
67
+        overview.put("todayRevenue", todayRevenue);
68
+        overview.put("pendingBills", pendingBills);
69
+        overview.put("overdueBills", overdueBills);
70
+        overview.put("overdueAmount", overdueAmount);
71
+        overview.put("collectionRate", collectionRate);
72
+        overview.put("customerCount", customerCount);
73
+        
74
+        log.info("Overview retrieved: totalRevenue={}, monthRevenue={}", totalRevenue, monthRevenue);
75
+        return overview;
76
+    }
77
+
78
+    /**
79
+     * 营收趋势(按日/周/月)
80
+     */
81
+    public List<Map<String, Object>> getRevenueTrend(String period) {
82
+        log.info("Getting revenue trend for period: {}", period);
83
+        
84
+        String dateFormat;
85
+        int days;
86
+        
87
+        switch (period.toLowerCase()) {
88
+            case "day":
89
+                dateFormat = "DATE(paid_at)";
90
+                days = 30;
91
+                break;
92
+            case "week":
93
+                dateFormat = "DATE_TRUNC('week', paid_at)";
94
+                days = 84; // 12 weeks
95
+                break;
96
+            case "month":
97
+            default:
98
+                dateFormat = "TO_CHAR(paid_at, 'YYYY-MM')";
99
+                days = 365;
100
+                break;
101
+        }
102
+        
103
+        LocalDate startDate = LocalDate.now().minusDays(days);
104
+        
105
+        String sql = String.format(
106
+            "SELECT %s as period, COALESCE(SUM(amount), 0) as revenue, COUNT(*) as count " +
107
+            "FROM rev_payment " +
108
+            "WHERE paid_at >= ? " +
109
+            "GROUP BY %s " +
110
+            "ORDER BY period",
111
+            dateFormat, dateFormat);
112
+        
113
+        List<Map<String, Object>> trends = jdbcTemplate.queryForList(sql, startDate);
114
+        
115
+        log.info("Revenue trend retrieved: {} data points", trends.size());
116
+        return trends;
117
+    }
118
+
119
+    /**
120
+     * 按区域统计营收
121
+     */
122
+    public Map<String, Object> getAreaRevenue(String area) {
123
+        log.info("Getting area revenue for: {}", area);
124
+        
125
+        Map<String, Object> result = new HashMap<>();
126
+        
127
+        // 区域总营收
128
+        BigDecimal totalRevenue = jdbcTemplate.queryForObject(
129
+            "SELECT COALESCE(SUM(b.paid_fee), 0) FROM rev_bill b " +
130
+            "JOIN rev_customer c ON b.customer_id = c.id " +
131
+            "WHERE c.area = ?",
132
+            BigDecimal.class, area);
133
+        
134
+        // 区域账单数
135
+        Integer billCount = jdbcTemplate.queryForObject(
136
+            "SELECT COUNT(*) FROM rev_bill b " +
137
+            "JOIN rev_customer c ON b.customer_id = c.id " +
138
+            "WHERE c.area = ?",
139
+            Integer.class, area);
140
+        
141
+        // 区域欠费
142
+        BigDecimal overdueAmount = jdbcTemplate.queryForObject(
143
+            "SELECT COALESCE(SUM(b.total_fee - b.paid_fee), 0) FROM rev_bill b " +
144
+            "JOIN rev_customer c ON b.customer_id = c.id " +
145
+            "WHERE c.area = ? AND b.status IN ('pending', 'partial', 'overdue')",
146
+            BigDecimal.class, area);
147
+        
148
+        // 区域客户数
149
+        Integer customerCount = jdbcTemplate.queryForObject(
150
+            "SELECT COUNT(*) FROM rev_customer WHERE area = ?",
151
+            Integer.class, area);
152
+        
153
+        result.put("area", area);
154
+        result.put("totalRevenue", totalRevenue);
155
+        result.put("billCount", billCount);
156
+        result.put("overdueAmount", overdueAmount);
157
+        result.put("customerCount", customerCount);
158
+        
159
+        log.info("Area revenue for {}: totalRevenue={}, billCount={}", area, totalRevenue, billCount);
160
+        return result;
161
+    }
162
+
163
+    /**
164
+     * 按支付渠道统计
165
+     */
166
+    public List<Map<String, Object>> getPaymentChannelStats() {
167
+        log.info("Getting payment channel statistics");
168
+        
169
+        List<Map<String, Object>> stats = jdbcTemplate.queryForList(
170
+            "SELECT pay_channel, COUNT(*) as count, COALESCE(SUM(amount), 0) as total " +
171
+            "FROM rev_payment " +
172
+            "GROUP BY pay_channel " +
173
+            "ORDER BY total DESC");
174
+        
175
+        log.info("Payment channel stats retrieved: {} channels", stats.size());
176
+        return stats;
177
+    }
178
+
179
+    /**
180
+     * 按客户类型统计
181
+     */
182
+    public List<Map<String, Object>> getCustomerTypeStats() {
183
+        log.info("Getting customer type statistics");
184
+        
185
+        List<Map<String, Object>> stats = jdbcTemplate.queryForList(
186
+            "SELECT c.customer_type, COUNT(b.id) as billCount, " +
187
+            "COALESCE(SUM(b.total_fee), 0) as totalAmount, " +
188
+            "COALESCE(SUM(b.paid_fee), 0) as paidAmount " +
189
+            "FROM rev_bill b " +
190
+            "JOIN rev_customer c ON b.customer_id = c.id " +
191
+            "GROUP BY c.customer_type " +
192
+            "ORDER BY totalAmount DESC");
193
+        
194
+        log.info("Customer type stats retrieved: {} types", stats.size());
195
+        return stats;
196
+    }
197
+
198
+    /**
199
+     * 欠费大户排行
200
+     */
201
+    public List<Map<String, Object>> getTopOverdueCustomers(int limit) {
202
+        log.info("Getting top overdue customers, limit: {}", limit);
203
+        
204
+        List<Map<String, Object>> customers = jdbcTemplate.queryForList(
205
+            "SELECT c.id, c.customer_name, c.customer_no, c.area, " +
206
+            "COUNT(b.id) as overdueBillCount, " +
207
+            "COALESCE(SUM(b.total_fee - b.paid_fee), 0) as overdueAmount " +
208
+            "FROM rev_bill b " +
209
+            "JOIN rev_customer c ON b.customer_id = c.id " +
210
+            "WHERE b.status IN ('overdue', 'pending', 'partial') " +
211
+            "GROUP BY c.id, c.customer_name, c.customer_no, c.area " +
212
+            "HAVING SUM(b.total_fee - b.paid_fee) > 0 " +
213
+            "ORDER BY overdueAmount DESC " +
214
+            "LIMIT ?",
215
+            limit);
216
+        
217
+        log.info("Top overdue customers retrieved: {} customers", customers.size());
218
+        return customers;
219
+    }
220
+
221
+    /**
222
+     * 计算收缴率
223
+     */
224
+    private BigDecimal calculateCollectionRate() {
225
+        BigDecimal totalBilled = jdbcTemplate.queryForObject(
226
+            "SELECT COALESCE(SUM(total_fee), 0) FROM rev_bill", BigDecimal.class);
227
+        
228
+        if (totalBilled.compareTo(BigDecimal.ZERO) == 0) {
229
+            return BigDecimal.ZERO;
230
+        }
231
+        
232
+        BigDecimal totalPaid = jdbcTemplate.queryForObject(
233
+            "SELECT COALESCE(SUM(paid_fee), 0) FROM rev_bill", BigDecimal.class);
234
+        
235
+        return totalPaid.multiply(BigDecimal.valueOf(100))
236
+            .divide(totalBilled, 2, RoundingMode.HALF_UP);
237
+    }
238
+}

+ 391
- 0
wm-revenue/src/main/java/com/water/revenue/service/RevenueQueryService.java View File

1
+package com.water.revenue.service;
2
+
3
+import lombok.RequiredArgsConstructor;
4
+import lombok.extern.slf4j.Slf4j;
5
+import org.springframework.jdbc.core.JdbcTemplate;
6
+import org.springframework.stereotype.Service;
7
+
8
+import java.math.BigDecimal;
9
+import java.time.LocalDate;
10
+import java.time.format.DateTimeFormatter;
11
+import java.util.*;
12
+
13
+@Slf4j
14
+@Service
15
+@RequiredArgsConstructor
16
+public class RevenueQueryService {
17
+
18
+    private final JdbcTemplate jdbcTemplate;
19
+
20
+    /**
21
+     * 多维度账单查询
22
+     */
23
+    public Map<String, Object> queryBills(Map<String, Object> filters) {
24
+        log.info("Querying bills with filters: {}", filters);
25
+        
26
+        StringBuilder sql = new StringBuilder(
27
+            "SELECT b.*, c.customer_name, c.customer_no, c.area, c.customer_type, m.meter_no " +
28
+            "FROM rev_bill b " +
29
+            "JOIN rev_customer c ON b.customer_id = c.id " +
30
+            "JOIN rev_meter m ON b.meter_id = m.id " +
31
+            "WHERE 1=1");
32
+        
33
+        List<Object> params = new ArrayList<>();
34
+        
35
+        // 时间范围
36
+        if (filters.get("startDate") != null) {
37
+            sql.append(" AND b.created_at >= ?::timestamp");
38
+            params.add(filters.get("startDate"));
39
+        }
40
+        if (filters.get("endDate") != null) {
41
+            sql.append(" AND b.created_at <= ?::timestamp");
42
+            params.add(filters.get("endDate"));
43
+        }
44
+        
45
+        // 区域
46
+        if (filters.get("area") != null && !filters.get("area").toString().isEmpty()) {
47
+            sql.append(" AND c.area = ?");
48
+            params.add(filters.get("area"));
49
+        }
50
+        
51
+        // 客户类型
52
+        if (filters.get("customerType") != null && !filters.get("customerType").toString().isEmpty()) {
53
+            sql.append(" AND c.customer_type = ?");
54
+            params.add(filters.get("customerType"));
55
+        }
56
+        
57
+        // 状态
58
+        if (filters.get("status") != null && !filters.get("status").toString().isEmpty()) {
59
+            sql.append(" AND b.status = ?");
60
+            params.add(filters.get("status"));
61
+        }
62
+        
63
+        // 金额范围
64
+        if (filters.get("minAmount") != null) {
65
+            sql.append(" AND b.total_fee >= ?");
66
+            params.add(new BigDecimal(filters.get("minAmount").toString()));
67
+        }
68
+        if (filters.get("maxAmount") != null) {
69
+            sql.append(" AND b.total_fee <= ?");
70
+            params.add(new BigDecimal(filters.get("maxAmount").toString()));
71
+        }
72
+        
73
+        sql.append(" ORDER BY b.created_at DESC");
74
+        
75
+        // 分页
76
+        int page = filters.get("page") != null ? Integer.parseInt(filters.get("page").toString()) : 1;
77
+        int size = filters.get("size") != null ? Integer.parseInt(filters.get("size").toString()) : 20;
78
+        int offset = (page - 1) * size;
79
+        
80
+        sql.append(" LIMIT ? OFFSET ?");
81
+        params.add(size);
82
+        params.add(offset);
83
+        
84
+        List<Map<String, Object>> bills = jdbcTemplate.queryForList(sql.toString(), params.toArray());
85
+        
86
+        // 统计总数
87
+        String countSql = sql.toString().replaceFirst("SELECT .*? FROM", "SELECT COUNT(*) FROM")
88
+            .replaceAll(" ORDER BY.*", "");
89
+        Integer total = jdbcTemplate.queryForObject(countSql, Integer.class, 
90
+            params.subList(0, params.size() - 2).toArray());
91
+        
92
+        Map<String, Object> result = new HashMap<>();
93
+        result.put("records", bills);
94
+        result.put("total", total);
95
+        result.put("page", page);
96
+        result.put("size", size);
97
+        result.put("pages", (total + size - 1) / size);
98
+        
99
+        log.info("Bills queried: {} records, total: {}", bills.size(), total);
100
+        return result;
101
+    }
102
+
103
+    /**
104
+     * 多维度缴费查询
105
+     */
106
+    public Map<String, Object> queryPayments(Map<String, Object> filters) {
107
+        log.info("Querying payments with filters: {}", filters);
108
+        
109
+        StringBuilder sql = new StringBuilder(
110
+            "SELECT p.*, b.bill_no, b.total_fee, c.customer_name, c.customer_no, c.area " +
111
+            "FROM rev_payment p " +
112
+            "JOIN rev_bill b ON p.bill_id = b.id " +
113
+            "JOIN rev_customer c ON p.customer_id = c.id " +
114
+            "WHERE 1=1");
115
+        
116
+        List<Object> params = new ArrayList<>();
117
+        
118
+        // 时间范围
119
+        if (filters.get("startDate") != null) {
120
+            sql.append(" AND p.paid_at >= ?::timestamp");
121
+            params.add(filters.get("startDate"));
122
+        }
123
+        if (filters.get("endDate") != null) {
124
+            sql.append(" AND p.paid_at <= ?::timestamp");
125
+            params.add(filters.get("endDate"));
126
+        }
127
+        
128
+        // 区域
129
+        if (filters.get("area") != null && !filters.get("area").toString().isEmpty()) {
130
+            sql.append(" AND c.area = ?");
131
+            params.add(filters.get("area"));
132
+        }
133
+        
134
+        // 支付方式
135
+        if (filters.get("payMethod") != null && !filters.get("payMethod").toString().isEmpty()) {
136
+            sql.append(" AND p.pay_method = ?");
137
+            params.add(filters.get("payMethod"));
138
+        }
139
+        
140
+        // 支付渠道
141
+        if (filters.get("payChannel") != null && !filters.get("payChannel").toString().isEmpty()) {
142
+            sql.append(" AND p.pay_channel = ?");
143
+            params.add(filters.get("payChannel"));
144
+        }
145
+        
146
+        // 金额范围
147
+        if (filters.get("minAmount") != null) {
148
+            sql.append(" AND p.amount >= ?");
149
+            params.add(new BigDecimal(filters.get("minAmount").toString()));
150
+        }
151
+        if (filters.get("maxAmount") != null) {
152
+            sql.append(" AND p.amount <= ?");
153
+            params.add(new BigDecimal(filters.get("maxAmount").toString()));
154
+        }
155
+        
156
+        sql.append(" ORDER BY p.paid_at DESC");
157
+        
158
+        // 分页
159
+        int page = filters.get("page") != null ? Integer.parseInt(filters.get("page").toString()) : 1;
160
+        int size = filters.get("size") != null ? Integer.parseInt(filters.get("size").toString()) : 20;
161
+        int offset = (page - 1) * size;
162
+        
163
+        sql.append(" LIMIT ? OFFSET ?");
164
+        params.add(size);
165
+        params.add(offset);
166
+        
167
+        List<Map<String, Object>> payments = jdbcTemplate.queryForList(sql.toString(), params.toArray());
168
+        
169
+        // 统计总数
170
+        String countSql = sql.toString().replaceFirst("SELECT .*? FROM", "SELECT COUNT(*) FROM")
171
+            .replaceAll(" ORDER BY.*", "");
172
+        Integer total = jdbcTemplate.queryForObject(countSql, Integer.class, 
173
+            params.subList(0, params.size() - 2).toArray());
174
+        
175
+        Map<String, Object> result = new HashMap<>();
176
+        result.put("records", payments);
177
+        result.put("total", total);
178
+        result.put("page", page);
179
+        result.put("size", size);
180
+        result.put("pages", (total + size - 1) / size);
181
+        
182
+        log.info("Payments queried: {} records, total: {}", payments.size(), total);
183
+        return result;
184
+    }
185
+
186
+    /**
187
+     * 月度营收报表
188
+     */
189
+    public Map<String, Object> monthlyReport(int year, int month) {
190
+        log.info("Generating monthly report: {}-{}", year, month);
191
+        
192
+        String period = String.format("%04d-%02d", year, month);
193
+        
194
+        Map<String, Object> report = new HashMap<>();
195
+        report.put("year", year);
196
+        report.put("month", month);
197
+        report.put("period", period);
198
+        
199
+        // 账单统计
200
+        Map<String, Object> billStats = jdbcTemplate.queryForMap(
201
+            "SELECT COUNT(*) as billCount, " +
202
+            "COALESCE(SUM(total_fee), 0) as totalAmount, " +
203
+            "COALESCE(SUM(paid_fee), 0) as paidAmount, " +
204
+            "COALESCE(SUM(total_fee - paid_fee), 0) as unpaidAmount " +
205
+            "FROM rev_bill WHERE bill_period = ?",
206
+            period);
207
+        report.putAll(billStats);
208
+        
209
+        // 缴费统计
210
+        Map<String, Object> paymentStats = jdbcTemplate.queryForMap(
211
+            "SELECT COUNT(*) as paymentCount, " +
212
+            "COALESCE(SUM(amount), 0) as paymentAmount " +
213
+            "FROM rev_payment p " +
214
+            "JOIN rev_bill b ON p.bill_id = b.id " +
215
+            "WHERE b.bill_period = ?",
216
+            period);
217
+        report.putAll(paymentStats);
218
+        
219
+        // 按区域统计
220
+        List<Map<String, Object>> areaStats = jdbcTemplate.queryForList(
221
+            "SELECT c.area, COUNT(b.id) as billCount, " +
222
+            "COALESCE(SUM(b.total_fee), 0) as totalAmount, " +
223
+            "COALESCE(SUM(b.paid_fee), 0) as paidAmount " +
224
+            "FROM rev_bill b " +
225
+            "JOIN rev_customer c ON b.customer_id = c.id " +
226
+            "WHERE b.bill_period = ? " +
227
+            "GROUP BY c.area " +
228
+            "ORDER BY totalAmount DESC",
229
+            period);
230
+        report.put("areaStats", areaStats);
231
+        
232
+        // 按支付方式统计
233
+        List<Map<String, Object>> paymentMethodStats = jdbcTemplate.queryForList(
234
+            "SELECT p.pay_method, COUNT(*) as count, " +
235
+            "COALESCE(SUM(p.amount), 0) as amount " +
236
+            "FROM rev_payment p " +
237
+            "JOIN rev_bill b ON p.bill_id = b.id " +
238
+            "WHERE b.bill_period = ? " +
239
+            "GROUP BY p.pay_method " +
240
+            "ORDER BY amount DESC",
241
+            period);
242
+        report.put("paymentMethodStats", paymentMethodStats);
243
+        
244
+        log.info("Monthly report generated for {}: totalAmount={}, paidAmount={}", 
245
+            period, billStats.get("totalAmount"), billStats.get("paidAmount"));
246
+        return report;
247
+    }
248
+
249
+    /**
250
+     * 年度营收报表
251
+     */
252
+    public Map<String, Object> yearlyReport(int year) {
253
+        log.info("Generating yearly report: {}", year);
254
+        
255
+        Map<String, Object> report = new HashMap<>();
256
+        report.put("year", year);
257
+        
258
+        // 年度总计
259
+        Map<String, Object> yearlyStats = jdbcTemplate.queryForMap(
260
+            "SELECT COUNT(*) as billCount, " +
261
+            "COALESCE(SUM(total_fee), 0) as totalAmount, " +
262
+            "COALESCE(SUM(paid_fee), 0) as paidAmount, " +
263
+            "COALESCE(SUM(total_fee - paid_fee), 0) as unpaidAmount " +
264
+            "FROM rev_bill WHERE EXTRACT(YEAR FROM created_at) = ?",
265
+            year);
266
+        report.putAll(yearlyStats);
267
+        
268
+        // 年度缴费统计
269
+        Map<String, Object> paymentStats = jdbcTemplate.queryForMap(
270
+            "SELECT COUNT(*) as paymentCount, " +
271
+            "COALESCE(SUM(p.amount), 0) as paymentAmount " +
272
+            "FROM rev_payment p " +
273
+            "JOIN rev_bill b ON p.bill_id = b.id " +
274
+            "WHERE EXTRACT(YEAR FROM b.created_at) = ?",
275
+            year);
276
+        report.putAll(paymentStats);
277
+        
278
+        // 按月统计
279
+        List<Map<String, Object>> monthlyStats = jdbcTemplate.queryForList(
280
+            "SELECT bill_period as month, COUNT(*) as billCount, " +
281
+            "COALESCE(SUM(total_fee), 0) as totalAmount, " +
282
+            "COALESCE(SUM(paid_fee), 0) as paidAmount " +
283
+            "FROM rev_bill " +
284
+            "WHERE EXTRACT(YEAR FROM created_at) = ? " +
285
+            "GROUP BY bill_period " +
286
+            "ORDER BY bill_period",
287
+            year);
288
+        report.put("monthlyStats", monthlyStats);
289
+        
290
+        // 按区域统计
291
+        List<Map<String, Object>> areaStats = jdbcTemplate.queryForList(
292
+            "SELECT c.area, COUNT(b.id) as billCount, " +
293
+            "COALESCE(SUM(b.total_fee), 0) as totalAmount, " +
294
+            "COALESCE(SUM(b.paid_fee), 0) as paidAmount " +
295
+            "FROM rev_bill b " +
296
+            "JOIN rev_customer c ON b.customer_id = c.id " +
297
+            "WHERE EXTRACT(YEAR FROM b.created_at) = ? " +
298
+            "GROUP BY c.area " +
299
+            "ORDER BY totalAmount DESC",
300
+            year);
301
+        report.put("areaStats", areaStats);
302
+        
303
+        log.info("Yearly report generated for {}: totalAmount={}, paidAmount={}", 
304
+            year, yearlyStats.get("totalAmount"), yearlyStats.get("paidAmount"));
305
+        return report;
306
+    }
307
+
308
+    /**
309
+     * 导出账单(CSV 格式)
310
+     */
311
+    public String exportBills(Map<String, Object> filters) {
312
+        log.info("Exporting bills with filters: {}", filters);
313
+        
314
+        StringBuilder sql = new StringBuilder(
315
+            "SELECT b.bill_no, c.customer_name, c.customer_no, c.area, c.customer_type, " +
316
+            "m.meter_no, b.bill_period, b.consumption, b.water_fee, b.sewage_fee, " +
317
+            "b.total_fee, b.paid_fee, b.status, b.due_date, b.created_at " +
318
+            "FROM rev_bill b " +
319
+            "JOIN rev_customer c ON b.customer_id = c.id " +
320
+            "JOIN rev_meter m ON b.meter_id = m.id " +
321
+            "WHERE 1=1");
322
+        
323
+        List<Object> params = new ArrayList<>();
324
+        
325
+        // 应用过滤条件(同 queryBills)
326
+        if (filters.get("startDate") != null) {
327
+            sql.append(" AND b.created_at >= ?::timestamp");
328
+            params.add(filters.get("startDate"));
329
+        }
330
+        if (filters.get("endDate") != null) {
331
+            sql.append(" AND b.created_at <= ?::timestamp");
332
+            params.add(filters.get("endDate"));
333
+        }
334
+        if (filters.get("area") != null && !filters.get("area").toString().isEmpty()) {
335
+            sql.append(" AND c.area = ?");
336
+            params.add(filters.get("area"));
337
+        }
338
+        if (filters.get("customerType") != null && !filters.get("customerType").toString().isEmpty()) {
339
+            sql.append(" AND c.customer_type = ?");
340
+            params.add(filters.get("customerType"));
341
+        }
342
+        if (filters.get("status") != null && !filters.get("status").toString().isEmpty()) {
343
+            sql.append(" AND b.status = ?");
344
+            params.add(filters.get("status"));
345
+        }
346
+        
347
+        sql.append(" ORDER BY b.created_at DESC LIMIT 10000"); // 限制导出数量
348
+        
349
+        List<Map<String, Object>> bills = jdbcTemplate.queryForList(sql.toString(), params.toArray());
350
+        
351
+        // 生成 CSV
352
+        StringBuilder csv = new StringBuilder();
353
+        csv.append("账单编号,客户名称,客户编号,区域,客户类型,水表号,账期,用水量,水费,污水处理费,")
354
+           .append("总金额,已缴金额,状态,到期日期,创建时间\n");
355
+        
356
+        for (Map<String, Object> bill : bills) {
357
+            csv.append(escapeCsv(bill.get("bill_no"))).append(",")
358
+               .append(escapeCsv(bill.get("customer_name"))).append(",")
359
+               .append(escapeCsv(bill.get("customer_no"))).append(",")
360
+               .append(escapeCsv(bill.get("area"))).append(",")
361
+               .append(escapeCsv(bill.get("customer_type"))).append(",")
362
+               .append(escapeCsv(bill.get("meter_no"))).append(",")
363
+               .append(escapeCsv(bill.get("bill_period"))).append(",")
364
+               .append(bill.get("consumption")).append(",")
365
+               .append(bill.get("water_fee")).append(",")
366
+               .append(bill.get("sewage_fee")).append(",")
367
+               .append(bill.get("total_fee")).append(",")
368
+               .append(bill.get("paid_fee")).append(",")
369
+               .append(escapeCsv(bill.get("status"))).append(",")
370
+               .append(bill.get("due_date")).append(",")
371
+               .append(bill.get("created_at")).append("\n");
372
+        }
373
+        
374
+        log.info("Bills exported: {} records", bills.size());
375
+        return csv.toString();
376
+    }
377
+
378
+    /**
379
+     * CSV 字段转义
380
+     */
381
+    private String escapeCsv(Object value) {
382
+        if (value == null) {
383
+            return "";
384
+        }
385
+        String str = value.toString();
386
+        if (str.contains(",") || str.contains("\"") || str.contains("\n")) {
387
+            return "\"" + str.replace("\"", "\"\"") + "\"";
388
+        }
389
+        return str;
390
+    }
391
+}

+ 17
- 0
wm-revenue/src/main/resources/sql/V83__revenue_dashboard.sql View File

1
+-- 营收统计缓存表(定时汇总,加速 Dashboard 查询)
2
+CREATE TABLE IF NOT EXISTS rev_revenue_daily (
3
+    id              BIGSERIAL PRIMARY KEY,
4
+    stat_date       DATE NOT NULL,
5
+    area            VARCHAR(50),
6
+    customer_type   VARCHAR(20),
7
+    total_bills     INT DEFAULT 0,
8
+    total_amount    DECIMAL(14,2) DEFAULT 0,
9
+    paid_amount     DECIMAL(14,2) DEFAULT 0,
10
+    overdue_amount  DECIMAL(14,2) DEFAULT 0,
11
+    new_customers   INT DEFAULT 0,
12
+    created_at      TIMESTAMPTZ DEFAULT NOW(),
13
+    UNIQUE(stat_date, area, customer_type)
14
+);
15
+
16
+CREATE INDEX IF NOT EXISTS idx_rev_daily_date ON rev_revenue_daily(stat_date);
17
+CREATE INDEX IF NOT EXISTS idx_rev_daily_area ON rev_revenue_daily(area);