Bladeren bron

feat(wm-revenue): #51 账单生成+多支付渠道收费

bot_dev2 4 dagen geleden
bovenliggende
commit
c155d35371

+ 79
- 0
wm-revenue/src/main/java/com/water/revenue/controller/BillController.java Bestand weergeven

1
+package com.water.revenue.controller;
2
+
3
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
4
+import com.water.common.core.result.R;
5
+import com.water.revenue.entity.WaterBill;
6
+import com.water.revenue.mapper.WaterBillMapper;
7
+import com.water.revenue.service.BillService;
8
+import io.swagger.v3.oas.annotations.Operation;
9
+import io.swagger.v3.oas.annotations.tags.Tag;
10
+import lombok.RequiredArgsConstructor;
11
+import org.springframework.web.bind.annotation.*;
12
+
13
+import java.util.List;
14
+import java.util.Map;
15
+
16
+@Tag(name = "账单管理")
17
+@RestController
18
+@RequestMapping("/api/revenue/bill")
19
+@RequiredArgsConstructor
20
+public class BillController {
21
+
22
+    private final BillService billService;
23
+    private final WaterBillMapper waterBillMapper;
24
+
25
+    @PostMapping("/auto-generate")
26
+    @Operation(summary = "按抄表周期自动生成账单")
27
+    public R<Map<String, Object>> autoGenerateBills(
28
+            @RequestParam(required = false) String billPeriod) {
29
+        return R.ok(billService.autoGenerateBills(billPeriod));
30
+    }
31
+
32
+    @PostMapping("/manual-generate/{readingId}")
33
+    @Operation(summary = "手动为指定抄表记录生成账单")
34
+    public R<WaterBill> manualGenerateBill(@PathVariable Long readingId) {
35
+        return R.ok(billService.manualGenerateBill(readingId));
36
+    }
37
+
38
+    @GetMapping("/list")
39
+    @Operation(summary = "账单分页查询")
40
+    public R<Page<WaterBill>> listBills(
41
+            @RequestParam(required = false) String customerNo,
42
+            @RequestParam(required = false) String billPeriod,
43
+            @RequestParam(required = false) String status,
44
+            @RequestParam(defaultValue = "1") int pageNum,
45
+            @RequestParam(defaultValue = "10") int pageSize) {
46
+        return R.ok(billService.queryBills(customerNo, billPeriod, status, pageNum, pageSize));
47
+    }
48
+
49
+    @GetMapping("/detail/{billId}")
50
+    @Operation(summary = "账单明细(含缴费记录)")
51
+    public R<Map<String, Object>> getBillDetail(@PathVariable Long billId) {
52
+        return R.ok(billService.getBillDetail(billId));
53
+    }
54
+
55
+    @GetMapping("/customer/{customerNo}")
56
+    @Operation(summary = "按客户查询账单列表")
57
+    public R<List<WaterBill>> getBillsByCustomer(@PathVariable String customerNo) {
58
+        return R.ok(billService.getBillsByCustomer(customerNo));
59
+    }
60
+
61
+    @GetMapping("/stats/{billPeriod}")
62
+    @Operation(summary = "按周期统计账单")
63
+    public R<List<Map<String, Object>>> getBillStats(@PathVariable String billPeriod) {
64
+        return R.ok(billService.getBillStatsByPeriod(billPeriod));
65
+    }
66
+
67
+    @PostMapping("/cancel/{billId}")
68
+    @Operation(summary = "作废账单")
69
+    public R<String> cancelBill(@PathVariable Long billId) {
70
+        billService.cancelBill(billId);
71
+        return R.ok("账单已作废");
72
+    }
73
+
74
+    @GetMapping("/info/{billId}")
75
+    @Operation(summary = "获取账单基本信息")
76
+    public R<WaterBill> getBillInfo(@PathVariable Long billId) {
77
+        return R.ok(waterBillMapper.selectById(billId));
78
+    }
79
+}

+ 94
- 0
wm-revenue/src/main/java/com/water/revenue/controller/PaymentController.java Bestand weergeven

1
+package com.water.revenue.controller;
2
+
3
+import com.water.common.core.result.R;
4
+import com.water.revenue.entity.PaymentChannel;
5
+import com.water.revenue.entity.PaymentRecord;
6
+import com.water.revenue.service.PaymentService;
7
+import io.swagger.v3.oas.annotations.Operation;
8
+import io.swagger.v3.oas.annotations.tags.Tag;
9
+import lombok.RequiredArgsConstructor;
10
+import org.springframework.web.bind.annotation.*;
11
+
12
+import java.math.BigDecimal;
13
+import java.util.List;
14
+import java.util.Map;
15
+
16
+@Tag(name = "收费管理")
17
+@RestController
18
+@RequestMapping("/api/revenue/payment")
19
+@RequiredArgsConstructor
20
+public class PaymentController {
21
+
22
+    private final PaymentService paymentService;
23
+
24
+    @PostMapping("/counter")
25
+    @Operation(summary = "柜台现金收费")
26
+    public R<PaymentRecord> payByCounter(@RequestParam Long billId,
27
+                                          @RequestParam BigDecimal amount,
28
+                                          @RequestParam(required = false) Long operatorId,
29
+                                          @RequestParam(required = false) String operatorName) {
30
+        return R.ok(paymentService.payByCounter(billId, amount, operatorId, operatorName));
31
+    }
32
+
33
+    @PostMapping("/pos")
34
+    @Operation(summary = "POS刷卡收费")
35
+    public R<PaymentRecord> payByPos(@RequestParam Long billId,
36
+                                      @RequestParam BigDecimal amount,
37
+                                      @RequestParam(required = false) Long operatorId,
38
+                                      @RequestParam(required = false) String operatorName) {
39
+        return R.ok(paymentService.payByPos(billId, amount, operatorId, operatorName));
40
+    }
41
+
42
+    @PostMapping("/alipay")
43
+    @Operation(summary = "支付宝统一下单")
44
+    public R<Map<String, Object>> payByAlipay(@RequestParam Long billId,
45
+                                               @RequestParam BigDecimal amount) {
46
+        return R.ok(paymentService.payByAlipay(billId, amount));
47
+    }
48
+
49
+    @PostMapping("/wechat")
50
+    @Operation(summary = "微信统一下单")
51
+    public R<Map<String, Object>> payByWechat(@RequestParam Long billId,
52
+                                               @RequestParam BigDecimal amount) {
53
+        return R.ok(paymentService.payByWechat(billId, amount));
54
+    }
55
+
56
+    @PostMapping("/alipay/callback")
57
+    @Operation(summary = "支付宝支付回调")
58
+    public R<String> alipayCallback(@RequestParam String paymentNo,
59
+                                     @RequestParam String tradeNo,
60
+                                     @RequestParam String tradeStatus) {
61
+        return R.ok(paymentService.alipayCallback(paymentNo, tradeNo, tradeStatus));
62
+    }
63
+
64
+    @PostMapping("/wechat/callback")
65
+    @Operation(summary = "微信支付回调")
66
+    public R<String> wechatCallback(@RequestParam String paymentNo,
67
+                                     @RequestParam String transactionId,
68
+                                     @RequestParam String resultCode) {
69
+        return R.ok(paymentService.wechatCallback(paymentNo, transactionId, resultCode));
70
+    }
71
+
72
+    @PostMapping("/refund")
73
+    @Operation(summary = "退费")
74
+    public R<PaymentRecord> refund(@RequestParam Long paymentId,
75
+                                    @RequestParam BigDecimal refundAmount,
76
+                                    @RequestParam(required = false) Long operatorId,
77
+                                    @RequestParam(required = false) String operatorName) {
78
+        return R.ok(paymentService.refund(paymentId, refundAmount, operatorId, operatorName));
79
+    }
80
+
81
+    @GetMapping("/channels")
82
+    @Operation(summary = "查询支付渠道配置")
83
+    public R<List<PaymentChannel>> listChannels() {
84
+        return R.ok(paymentService.listChannels());
85
+    }
86
+
87
+    @PostMapping("/channel/toggle")
88
+    @Operation(summary = "启用/禁用支付渠道")
89
+    public R<String> toggleChannel(@RequestParam Long channelId,
90
+                                    @RequestParam Integer enabled) {
91
+        paymentService.toggleChannel(channelId, enabled);
92
+        return R.ok(enabled == 1 ? "已启用" : "已禁用");
93
+    }
94
+}

+ 94
- 0
wm-revenue/src/main/java/com/water/revenue/controller/PaymentQueryController.java Bestand weergeven

1
+package com.water.revenue.controller;
2
+
3
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
4
+import com.water.common.core.result.R;
5
+import com.water.revenue.entity.PaymentRecord;
6
+import com.water.revenue.service.PaymentQueryService;
7
+import io.swagger.v3.oas.annotations.Operation;
8
+import io.swagger.v3.oas.annotations.tags.Tag;
9
+import lombok.RequiredArgsConstructor;
10
+import org.springframework.format.annotation.DateTimeFormat;
11
+import org.springframework.web.bind.annotation.*;
12
+
13
+import java.time.LocalDate;
14
+import java.util.List;
15
+import java.util.Map;
16
+
17
+@Tag(name = "缴费记录查询")
18
+@RestController
19
+@RequestMapping("/api/revenue/payment-query")
20
+@RequiredArgsConstructor
21
+public class PaymentQueryController {
22
+
23
+    private final PaymentQueryService paymentQueryService;
24
+
25
+    @GetMapping("/by-customer")
26
+    @Operation(summary = "按客户查询缴费记录")
27
+    public R<Page<PaymentRecord>> queryByCustomer(@RequestParam String customerNo,
28
+                                                    @RequestParam(defaultValue = "1") int pageNum,
29
+                                                    @RequestParam(defaultValue = "10") int pageSize) {
30
+        return R.ok(paymentQueryService.queryByCustomer(customerNo, pageNum, pageSize));
31
+    }
32
+
33
+    @GetMapping("/by-time")
34
+    @Operation(summary = "按时间范围查询")
35
+    public R<Page<PaymentRecord>> queryByTimeRange(
36
+            @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate startDate,
37
+            @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate endDate,
38
+            @RequestParam(defaultValue = "1") int pageNum,
39
+            @RequestParam(defaultValue = "10") int pageSize) {
40
+        return R.ok(paymentQueryService.queryByTimeRange(startDate, endDate, pageNum, pageSize));
41
+    }
42
+
43
+    @GetMapping("/by-channel")
44
+    @Operation(summary = "按渠道查询")
45
+    public R<Page<PaymentRecord>> queryByChannel(@RequestParam String channel,
46
+                                                   @RequestParam(defaultValue = "1") int pageNum,
47
+                                                   @RequestParam(defaultValue = "10") int pageSize) {
48
+        return R.ok(paymentQueryService.queryByChannel(channel, pageNum, pageSize));
49
+    }
50
+
51
+    @GetMapping("/by-status")
52
+    @Operation(summary = "按状态查询")
53
+    public R<Page<PaymentRecord>> queryByStatus(@RequestParam String status,
54
+                                                  @RequestParam(defaultValue = "1") int pageNum,
55
+                                                  @RequestParam(defaultValue = "10") int pageSize) {
56
+        return R.ok(paymentQueryService.queryByStatus(status, pageNum, pageSize));
57
+    }
58
+
59
+    @GetMapping("/list")
60
+    @Operation(summary = "综合条件查询缴费记录")
61
+    public R<Page<PaymentRecord>> queryPayments(
62
+            @RequestParam(required = false) String customerNo,
63
+            @RequestParam(required = false) String channel,
64
+            @RequestParam(required = false) String status,
65
+            @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate startDate,
66
+            @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate endDate,
67
+            @RequestParam(defaultValue = "1") int pageNum,
68
+            @RequestParam(defaultValue = "10") int pageSize) {
69
+        return R.ok(paymentQueryService.queryPayments(customerNo, channel, status,
70
+                startDate, endDate, pageNum, pageSize));
71
+    }
72
+
73
+    @GetMapping("/stats/channel")
74
+    @Operation(summary = "按渠道统计")
75
+    public R<List<Map<String, Object>>> statsByChannel() {
76
+        return R.ok(paymentQueryService.statsByChannel());
77
+    }
78
+
79
+    @GetMapping("/stats/date")
80
+    @Operation(summary = "按日统计")
81
+    public R<List<Map<String, Object>>> statsByDate(
82
+            @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate startDate,
83
+            @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate endDate) {
84
+        return R.ok(paymentQueryService.statsByDate(startDate, endDate));
85
+    }
86
+
87
+    @GetMapping("/summary")
88
+    @Operation(summary = "收费汇总")
89
+    public R<Map<String, Object>> summary(
90
+            @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate startDate,
91
+            @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate endDate) {
92
+        return R.ok(paymentQueryService.summary(startDate, endDate));
93
+    }
94
+}

+ 37
- 0
wm-revenue/src/main/java/com/water/revenue/entity/PaymentChannel.java Bestand weergeven

1
+package com.water.revenue.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+
6
+import java.math.BigDecimal;
7
+import java.time.LocalDateTime;
8
+
9
+@Data
10
+@TableName("wm_payment_channel")
11
+public class PaymentChannel {
12
+
13
+    @TableId(type = IdType.AUTO)
14
+    private Long id;
15
+
16
+    /** 渠道编码: counter/pos/alipay/wechat */
17
+    private String channelCode;
18
+
19
+    /** 渠道名称 */
20
+    private String channelName;
21
+
22
+    /** 是否启用: 1=启用 0=禁用 */
23
+    private Integer enabled;
24
+
25
+    /** 手续费率(百分比,如 0.6 表示 0.6%) */
26
+    private BigDecimal feeRate;
27
+
28
+    /** 渠道描述 */
29
+    private String description;
30
+
31
+    /** 排序 */
32
+    private Integer sortOrder;
33
+
34
+    private LocalDateTime createdAt;
35
+
36
+    private LocalDateTime updatedAt;
37
+}

+ 67
- 0
wm-revenue/src/main/java/com/water/revenue/entity/PaymentRecord.java Bestand weergeven

1
+package com.water.revenue.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+
6
+import java.math.BigDecimal;
7
+import java.time.LocalDateTime;
8
+
9
+@Data
10
+@TableName("wm_payment_record")
11
+public class PaymentRecord {
12
+
13
+    @TableId(type = IdType.AUTO)
14
+    private Long id;
15
+
16
+    /** 支付流水号 */
17
+    private String paymentNo;
18
+
19
+    /** 账单ID */
20
+    private Long billId;
21
+
22
+    /** 账单编号(冗余) */
23
+    private String billNo;
24
+
25
+    /** 客户编号 */
26
+    private String customerNo;
27
+
28
+    /** 客户名称 */
29
+    private String customerName;
30
+
31
+    /** 支付金额 */
32
+    private BigDecimal amount;
33
+
34
+    /** 支付渠道编码: counter/pos/alipay/wechat */
35
+    private String channel;
36
+
37
+    /** 支付渠道名称 */
38
+    private String channelName;
39
+
40
+    /** 支付状态: pending/success/failed/refunded */
41
+    private String status;
42
+
43
+    /** 操作员ID */
44
+    private Long operatorId;
45
+
46
+    /** 操作员姓名 */
47
+    private String operatorName;
48
+
49
+    /** 支付时间 */
50
+    private LocalDateTime payTime;
51
+
52
+    /** 第三方支付交易号 */
53
+    private String tradeNo;
54
+
55
+    /** 退款单号 */
56
+    private String refundNo;
57
+
58
+    /** 退款金额 */
59
+    private BigDecimal refundAmount;
60
+
61
+    /** 备注 */
62
+    private String remark;
63
+
64
+    private LocalDateTime createdAt;
65
+
66
+    private LocalDateTime updatedAt;
67
+}

+ 65
- 0
wm-revenue/src/main/java/com/water/revenue/entity/WaterBill.java Bestand weergeven

1
+package com.water.revenue.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+
6
+import java.math.BigDecimal;
7
+import java.time.LocalDate;
8
+import java.time.LocalDateTime;
9
+
10
+@Data
11
+@TableName("wm_water_bill")
12
+public class WaterBill {
13
+
14
+    @TableId(type = IdType.AUTO)
15
+    private Long id;
16
+
17
+    /** 账单编号 */
18
+    private String billNo;
19
+
20
+    /** 客户编号 */
21
+    private String customerNo;
22
+
23
+    /** 客户名称 */
24
+    private String customerName;
25
+
26
+    /** 账单周期(如 2026-06) */
27
+    private String billPeriod;
28
+
29
+    /** 用水量(吨) */
30
+    private BigDecimal waterUsage;
31
+
32
+    /** 单价(元/吨) */
33
+    private BigDecimal unitPrice;
34
+
35
+    /** 总金额 */
36
+    private BigDecimal totalAmount;
37
+
38
+    /** 已付金额 */
39
+    private BigDecimal paidAmount;
40
+
41
+    /** 状态: pending/paid/partial/overdue/cancelled */
42
+    private String status;
43
+
44
+    /** 出账日期 */
45
+    private LocalDate issueDate;
46
+
47
+    /** 到期日期 */
48
+    private LocalDate dueDate;
49
+
50
+    /** 水表编号 */
51
+    private String meterNo;
52
+
53
+    /** 上次读数 */
54
+    private BigDecimal prevReading;
55
+
56
+    /** 本次读数 */
57
+    private BigDecimal currReading;
58
+
59
+    /** 备注 */
60
+    private String remark;
61
+
62
+    private LocalDateTime createdAt;
63
+
64
+    private LocalDateTime updatedAt;
65
+}

+ 9
- 0
wm-revenue/src/main/java/com/water/revenue/mapper/PaymentChannelMapper.java Bestand weergeven

1
+package com.water.revenue.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.revenue.entity.PaymentChannel;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+@Mapper
8
+public interface PaymentChannelMapper extends BaseMapper<PaymentChannel> {
9
+}

+ 9
- 0
wm-revenue/src/main/java/com/water/revenue/mapper/PaymentRecordMapper.java Bestand weergeven

1
+package com.water.revenue.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.revenue.entity.PaymentRecord;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+@Mapper
8
+public interface PaymentRecordMapper extends BaseMapper<PaymentRecord> {
9
+}

+ 9
- 0
wm-revenue/src/main/java/com/water/revenue/mapper/WaterBillMapper.java Bestand weergeven

1
+package com.water.revenue.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.revenue.entity.WaterBill;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+@Mapper
8
+public interface WaterBillMapper extends BaseMapper<WaterBill> {
9
+}

+ 234
- 0
wm-revenue/src/main/java/com/water/revenue/service/BillService.java Bestand weergeven

1
+package com.water.revenue.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
5
+import com.water.revenue.entity.WaterBill;
6
+import com.water.revenue.mapper.WaterBillMapper;
7
+import lombok.RequiredArgsConstructor;
8
+import lombok.extern.slf4j.Slf4j;
9
+import org.springframework.jdbc.core.JdbcTemplate;
10
+import org.springframework.stereotype.Service;
11
+import org.springframework.transaction.annotation.Transactional;
12
+import org.springframework.util.StringUtils;
13
+
14
+import java.math.BigDecimal;
15
+import java.time.LocalDate;
16
+import java.time.LocalDateTime;
17
+import java.time.YearMonth;
18
+import java.time.format.DateTimeFormatter;
19
+import java.util.*;
20
+
21
+@Slf4j
22
+@Service
23
+@RequiredArgsConstructor
24
+public class BillService {
25
+
26
+    private final WaterBillMapper waterBillMapper;
27
+    private final JdbcTemplate jdbcTemplate;
28
+
29
+    /**
30
+     * 按抄表周期自动生成账单
31
+     * 查询已审核但未生成账单的抄表记录,按周期批量生成
32
+     */
33
+    @Transactional
34
+    public Map<String, Object> autoGenerateBills(String billPeriod) {
35
+        String period = StringUtils.hasText(billPeriod)
36
+                ? billPeriod
37
+                : YearMonth.now().format(DateTimeFormatter.ofPattern("yyyy-MM"));
38
+
39
+        log.info("Auto generating bills for period: {}", period);
40
+
41
+        // 查询该周期已审核但未生成账单的抄表记录
42
+        List<Map<String, Object>> readings = jdbcTemplate.queryForList(
43
+                "SELECT r.id AS reading_id, r.consumption, r.prev_reading, r.curr_reading, " +
44
+                "r.reading_period, m.meter_no, m.customer_id, " +
45
+                "c.customer_no, c.customer_name, c.customer_type " +
46
+                "FROM rev_reading r " +
47
+                "JOIN rev_meter m ON r.meter_id = m.id " +
48
+                "JOIN rev_customer c ON m.customer_id = c.id " +
49
+                "WHERE r.verified = 1 AND r.reading_period = ? " +
50
+                "AND NOT EXISTS (SELECT 1 FROM wm_water_bill b WHERE b.meter_no = m.meter_no AND b.bill_period = ?)",
51
+                period, period);
52
+
53
+        int generated = 0;
54
+        List<String> errors = new ArrayList<>();
55
+
56
+        for (Map<String, Object> reading : readings) {
57
+            try {
58
+                generateSingleBill(reading, period);
59
+                generated++;
60
+            } catch (Exception e) {
61
+                log.error("Failed to generate bill for reading {}: {}", reading.get("reading_id"), e.getMessage());
62
+                errors.add("Reading#" + reading.get("reading_id") + ": " + e.getMessage());
63
+            }
64
+        }
65
+
66
+        log.info("Auto bill generation completed: period={}, generated={}, errors={}", period, generated, errors.size());
67
+        Map<String, Object> result = new LinkedHashMap<>();
68
+        result.put("period", period);
69
+        result.put("generatedCount", generated);
70
+        result.put("totalReadings", readings.size());
71
+        result.put("errors", errors);
72
+        return result;
73
+    }
74
+
75
+    /**
76
+     * 手动为指定抄表记录生成账单
77
+     */
78
+    @Transactional
79
+    public WaterBill manualGenerateBill(Long readingId) {
80
+        Map<String, Object> reading = jdbcTemplate.queryForMap(
81
+                "SELECT r.id AS reading_id, r.consumption, r.prev_reading, r.curr_reading, " +
82
+                "r.reading_period, m.meter_no, m.customer_id, " +
83
+                "c.customer_no, c.customer_name, c.customer_type " +
84
+                "FROM rev_reading r " +
85
+                "JOIN rev_meter m ON r.meter_id = m.id " +
86
+                "JOIN rev_customer c ON m.customer_id = c.id " +
87
+                "WHERE r.id = ?", readingId);
88
+
89
+        String period = (String) reading.get("reading_period");
90
+
91
+        // 检查是否已存在
92
+        Long existCount = waterBillMapper.selectCount(
93
+                new LambdaQueryWrapper<WaterBill>()
94
+                        .eq(WaterBill::getMeterNo, reading.get("meter_no"))
95
+                        .eq(WaterBill::getBillPeriod, period));
96
+        if (existCount > 0) {
97
+            throw new RuntimeException("该抄表记录已生成账单,周期: " + period);
98
+        }
99
+
100
+        return generateSingleBill(reading, period);
101
+    }
102
+
103
+    /**
104
+     * 账单分页查询
105
+     */
106
+    public Page<WaterBill> queryBills(String customerNo, String billPeriod, String status,
107
+                                       int pageNum, int pageSize) {
108
+        LambdaQueryWrapper<WaterBill> wrapper = new LambdaQueryWrapper<>();
109
+        if (StringUtils.hasText(customerNo)) {
110
+            wrapper.like(WaterBill::getCustomerNo, customerNo);
111
+        }
112
+        if (StringUtils.hasText(billPeriod)) {
113
+            wrapper.eq(WaterBill::getBillPeriod, billPeriod);
114
+        }
115
+        if (StringUtils.hasText(status)) {
116
+            wrapper.eq(WaterBill::getStatus, status);
117
+        }
118
+        wrapper.orderByDesc(WaterBill::getCreatedAt);
119
+
120
+        return waterBillMapper.selectPage(new Page<>(pageNum, pageSize), wrapper);
121
+    }
122
+
123
+    /**
124
+     * 账单明细
125
+     */
126
+    public Map<String, Object> getBillDetail(Long billId) {
127
+        WaterBill bill = waterBillMapper.selectById(billId);
128
+        if (bill == null) {
129
+            throw new RuntimeException("账单不存在: " + billId);
130
+        }
131
+
132
+        // 查询该账单的缴费记录
133
+        List<Map<String, Object>> payments = jdbcTemplate.queryForList(
134
+                "SELECT payment_no, amount, channel, channel_name, status, pay_time, operator_name " +
135
+                "FROM wm_payment_record WHERE bill_id = ? ORDER BY pay_time DESC", billId);
136
+
137
+        Map<String, Object> detail = new LinkedHashMap<>();
138
+        detail.put("bill", bill);
139
+        detail.put("payments", payments);
140
+        detail.put("remainingAmount", bill.getTotalAmount().subtract(bill.getPaidAmount()));
141
+        return detail;
142
+    }
143
+
144
+    /**
145
+     * 按客户查询账单列表
146
+     */
147
+    public List<WaterBill> getBillsByCustomer(String customerNo) {
148
+        return waterBillMapper.selectList(
149
+                new LambdaQueryWrapper<WaterBill>()
150
+                        .eq(WaterBill::getCustomerNo, customerNo)
151
+                        .orderByDesc(WaterBill::getBillPeriod));
152
+    }
153
+
154
+    /**
155
+     * 按周期统计账单
156
+     */
157
+    public List<Map<String, Object>> getBillStatsByPeriod(String billPeriod) {
158
+        return jdbcTemplate.queryForList(
159
+                "SELECT status, COUNT(*) AS count, " +
160
+                "COALESCE(SUM(total_amount), 0) AS total_amount, " +
161
+                "COALESCE(SUM(paid_amount), 0) AS paid_amount " +
162
+                "FROM wm_water_bill WHERE bill_period = ? GROUP BY status",
163
+                billPeriod);
164
+    }
165
+
166
+    /**
167
+     * 作废账单
168
+     */
169
+    @Transactional
170
+    public void cancelBill(Long billId) {
171
+        WaterBill bill = waterBillMapper.selectById(billId);
172
+        if (bill == null) {
173
+            throw new RuntimeException("账单不存在: " + billId);
174
+        }
175
+        if (!"pending".equals(bill.getStatus())) {
176
+            throw new RuntimeException("只能作废 pending 状态的账单,当前状态: " + bill.getStatus());
177
+        }
178
+        bill.setStatus("cancelled");
179
+        bill.setUpdatedAt(LocalDateTime.now());
180
+        waterBillMapper.updateById(bill);
181
+        log.info("Bill cancelled: {}", bill.getBillNo());
182
+    }
183
+
184
+    // ---- private helpers ----
185
+
186
+    private WaterBill generateSingleBill(Map<String, Object> reading, String period) {
187
+        BigDecimal consumption = reading.get("consumption") != null
188
+                ? new BigDecimal(reading.get("consumption").toString()) : BigDecimal.ZERO;
189
+
190
+        // 查询水价(取该客户类型的第一条有效价格)
191
+        BigDecimal unitPrice = BigDecimal.valueOf(3.5); // 默认单价
192
+        try {
193
+            String customerType = (String) reading.get("customer_type");
194
+            if (customerType != null) {
195
+                BigDecimal dbPrice = jdbcTemplate.queryForObject(
196
+                        "SELECT water_price FROM rev_water_price WHERE customer_type = ? " +
197
+                        "AND effective_date <= CURRENT_DATE ORDER BY tier_no LIMIT 1",
198
+                        BigDecimal.class, customerType);
199
+                if (dbPrice != null) {
200
+                    unitPrice = dbPrice;
201
+                }
202
+            }
203
+        } catch (Exception e) {
204
+            log.warn("Failed to query water price, using default: {}", unitPrice);
205
+        }
206
+
207
+        BigDecimal totalAmount = consumption.multiply(unitPrice);
208
+        String billNo = "BILL-" + System.currentTimeMillis() + "-" + new Random().nextInt(1000);
209
+
210
+        WaterBill bill = new WaterBill();
211
+        bill.setBillNo(billNo);
212
+        bill.setCustomerNo((String) reading.get("customer_no"));
213
+        bill.setCustomerName((String) reading.get("customer_name"));
214
+        bill.setBillPeriod(period);
215
+        bill.setWaterUsage(consumption);
216
+        bill.setUnitPrice(unitPrice);
217
+        bill.setTotalAmount(totalAmount);
218
+        bill.setPaidAmount(BigDecimal.ZERO);
219
+        bill.setStatus("pending");
220
+        bill.setIssueDate(LocalDate.now());
221
+        bill.setDueDate(LocalDate.now().plusDays(30));
222
+        bill.setMeterNo((String) reading.get("meter_no"));
223
+        bill.setPrevReading(reading.get("prev_reading") != null
224
+                ? new BigDecimal(reading.get("prev_reading").toString()) : BigDecimal.ZERO);
225
+        bill.setCurrReading(reading.get("curr_reading") != null
226
+                ? new BigDecimal(reading.get("curr_reading").toString()) : BigDecimal.ZERO);
227
+        bill.setCreatedAt(LocalDateTime.now());
228
+        bill.setUpdatedAt(LocalDateTime.now());
229
+
230
+        waterBillMapper.insert(bill);
231
+        log.info("Bill generated: {} customer={} amount={}", billNo, bill.getCustomerNo(), totalAmount);
232
+        return bill;
233
+    }
234
+}

+ 138
- 0
wm-revenue/src/main/java/com/water/revenue/service/PaymentQueryService.java Bestand weergeven

1
+package com.water.revenue.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
5
+import com.water.revenue.entity.PaymentRecord;
6
+import com.water.revenue.mapper.PaymentRecordMapper;
7
+import lombok.RequiredArgsConstructor;
8
+import lombok.extern.slf4j.Slf4j;
9
+import org.springframework.jdbc.core.JdbcTemplate;
10
+import org.springframework.stereotype.Service;
11
+import org.springframework.util.StringUtils;
12
+
13
+import java.time.LocalDate;
14
+import java.time.LocalDateTime;
15
+import java.time.LocalTime;
16
+import java.util.*;
17
+
18
+@Slf4j
19
+@Service
20
+@RequiredArgsConstructor
21
+public class PaymentQueryService {
22
+
23
+    private final PaymentRecordMapper paymentRecordMapper;
24
+    private final JdbcTemplate jdbcTemplate;
25
+
26
+    /**
27
+     * 按客户查询缴费记录
28
+     */
29
+    public Page<PaymentRecord> queryByCustomer(String customerNo, int pageNum, int pageSize) {
30
+        LambdaQueryWrapper<PaymentRecord> wrapper = new LambdaQueryWrapper<>();
31
+        wrapper.eq(PaymentRecord::getCustomerNo, customerNo)
32
+               .orderByDesc(PaymentRecord::getPayTime);
33
+        return paymentRecordMapper.selectPage(new Page<>(pageNum, pageSize), wrapper);
34
+    }
35
+
36
+    /**
37
+     * 按时间范围查询
38
+     */
39
+    public Page<PaymentRecord> queryByTimeRange(LocalDate startDate, LocalDate endDate,
40
+                                                  int pageNum, int pageSize) {
41
+        LambdaQueryWrapper<PaymentRecord> wrapper = new LambdaQueryWrapper<>();
42
+        if (startDate != null) {
43
+            wrapper.ge(PaymentRecord::getPayTime, startDate.atStartOfDay());
44
+        }
45
+        if (endDate != null) {
46
+            wrapper.le(PaymentRecord::getPayTime, endDate.atTime(LocalTime.MAX));
47
+        }
48
+        wrapper.orderByDesc(PaymentRecord::getPayTime);
49
+        return paymentRecordMapper.selectPage(new Page<>(pageNum, pageSize), wrapper);
50
+    }
51
+
52
+    /**
53
+     * 按渠道查询
54
+     */
55
+    public Page<PaymentRecord> queryByChannel(String channel, int pageNum, int pageSize) {
56
+        LambdaQueryWrapper<PaymentRecord> wrapper = new LambdaQueryWrapper<>();
57
+        wrapper.eq(PaymentRecord::getChannel, channel)
58
+               .orderByDesc(PaymentRecord::getPayTime);
59
+        return paymentRecordMapper.selectPage(new Page<>(pageNum, pageSize), wrapper);
60
+    }
61
+
62
+    /**
63
+     * 按状态查询
64
+     */
65
+    public Page<PaymentRecord> queryByStatus(String status, int pageNum, int pageSize) {
66
+        LambdaQueryWrapper<PaymentRecord> wrapper = new LambdaQueryWrapper<>();
67
+        wrapper.eq(PaymentRecord::getStatus, status)
68
+               .orderByDesc(PaymentRecord::getPayTime);
69
+        return paymentRecordMapper.selectPage(new Page<>(pageNum, pageSize), wrapper);
70
+    }
71
+
72
+    /**
73
+     * 综合条件查询
74
+     */
75
+    public Page<PaymentRecord> queryPayments(String customerNo, String channel, String status,
76
+                                              LocalDate startDate, LocalDate endDate,
77
+                                              int pageNum, int pageSize) {
78
+        LambdaQueryWrapper<PaymentRecord> wrapper = new LambdaQueryWrapper<>();
79
+        if (StringUtils.hasText(customerNo)) {
80
+            wrapper.eq(PaymentRecord::getCustomerNo, customerNo);
81
+        }
82
+        if (StringUtils.hasText(channel)) {
83
+            wrapper.eq(PaymentRecord::getChannel, channel);
84
+        }
85
+        if (StringUtils.hasText(status)) {
86
+            wrapper.eq(PaymentRecord::getStatus, status);
87
+        }
88
+        if (startDate != null) {
89
+            wrapper.ge(PaymentRecord::getPayTime, startDate.atStartOfDay());
90
+        }
91
+        if (endDate != null) {
92
+            wrapper.le(PaymentRecord::getPayTime, endDate.atTime(LocalTime.MAX));
93
+        }
94
+        wrapper.orderByDesc(PaymentRecord::getPayTime);
95
+        return paymentRecordMapper.selectPage(new Page<>(pageNum, pageSize), wrapper);
96
+    }
97
+
98
+    /**
99
+     * 按渠道统计
100
+     */
101
+    public List<Map<String, Object>> statsByChannel() {
102
+        return jdbcTemplate.queryForList(
103
+                "SELECT channel, channel_name, COUNT(*) AS count, " +
104
+                "COALESCE(SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END), 0) AS total_amount, " +
105
+                "COALESCE(SUM(CASE WHEN amount < 0 THEN ABS(amount) ELSE 0 END), 0) AS refund_amount " +
106
+                "FROM wm_payment_record GROUP BY channel, channel_name ORDER BY count DESC");
107
+    }
108
+
109
+    /**
110
+     * 按日统计
111
+     */
112
+    public List<Map<String, Object>> statsByDate(LocalDate startDate, LocalDate endDate) {
113
+        return jdbcTemplate.queryForList(
114
+                "SELECT DATE(pay_time) AS pay_date, COUNT(*) AS count, " +
115
+                "COALESCE(SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END), 0) AS total_amount " +
116
+                "FROM wm_payment_record " +
117
+                "WHERE pay_time >= ? AND pay_time <= ? " +
118
+                "GROUP BY DATE(pay_time) ORDER BY pay_date",
119
+                startDate.atStartOfDay(), endDate.atTime(LocalTime.MAX));
120
+    }
121
+
122
+    /**
123
+     * 收费汇总
124
+     */
125
+    public Map<String, Object> summary(LocalDate startDate, LocalDate endDate) {
126
+        Map<String, Object> result = jdbcTemplate.queryForMap(
127
+                "SELECT COUNT(*) AS total_count, " +
128
+                "COALESCE(SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END), 0) AS total_income, " +
129
+                "COALESCE(SUM(CASE WHEN amount < 0 THEN ABS(amount) ELSE 0 END), 0) AS total_refund, " +
130
+                "COUNT(DISTINCT customer_no) AS customer_count " +
131
+                "FROM wm_payment_record WHERE pay_time >= ? AND pay_time <= ?",
132
+                startDate.atStartOfDay(), endDate.atTime(LocalTime.MAX));
133
+
134
+        result.put("startDate", startDate.toString());
135
+        result.put("endDate", endDate.toString());
136
+        return result;
137
+    }
138
+}

+ 273
- 0
wm-revenue/src/main/java/com/water/revenue/service/PaymentService.java Bestand weergeven

1
+package com.water.revenue.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.water.revenue.entity.PaymentChannel;
5
+import com.water.revenue.entity.PaymentRecord;
6
+import com.water.revenue.entity.WaterBill;
7
+import com.water.revenue.mapper.PaymentChannelMapper;
8
+import com.water.revenue.mapper.PaymentRecordMapper;
9
+import com.water.revenue.mapper.WaterBillMapper;
10
+import lombok.RequiredArgsConstructor;
11
+import lombok.extern.slf4j.Slf4j;
12
+import org.springframework.stereotype.Service;
13
+import org.springframework.transaction.annotation.Transactional;
14
+
15
+import java.math.BigDecimal;
16
+import java.math.RoundingMode;
17
+import java.time.LocalDateTime;
18
+import java.util.*;
19
+
20
+@Slf4j
21
+@Service
22
+@RequiredArgsConstructor
23
+public class PaymentService {
24
+
25
+    private final PaymentRecordMapper paymentRecordMapper;
26
+    private final PaymentChannelMapper paymentChannelMapper;
27
+    private final WaterBillMapper waterBillMapper;
28
+
29
+    /**
30
+     * 柜台现金收费
31
+     */
32
+    @Transactional
33
+    public PaymentRecord payByCounter(Long billId, BigDecimal amount, Long operatorId, String operatorName) {
34
+        return doPay(billId, amount, "counter", "柜台现金", operatorId, operatorName);
35
+    }
36
+
37
+    /**
38
+     * POS 刷卡收费
39
+     */
40
+    @Transactional
41
+    public PaymentRecord payByPos(Long billId, BigDecimal amount, Long operatorId, String operatorName) {
42
+        return doPay(billId, amount, "pos", "POS刷卡", operatorId, operatorName);
43
+    }
44
+
45
+    /**
46
+     * 支付宝统一下单
47
+     */
48
+    @Transactional
49
+    public Map<String, Object> payByAlipay(Long billId, BigDecimal amount) {
50
+        PaymentRecord record = doPay(billId, amount, "alipay", "支付宝", null, null);
51
+        // 模拟支付宝下单返回
52
+        Map<String, Object> result = new LinkedHashMap<>();
53
+        result.put("paymentRecord", record);
54
+        result.put("payForm", "模拟支付宝收银台表单 - paymentNo=" + record.getPaymentNo());
55
+        result.put("tradeNo", "ALI" + System.currentTimeMillis());
56
+        return result;
57
+    }
58
+
59
+    /**
60
+     * 微信统一下单
61
+     */
62
+    @Transactional
63
+    public Map<String, Object> payByWechat(Long billId, BigDecimal amount) {
64
+        PaymentRecord record = doPay(billId, amount, "wechat", "微信支付", null, null);
65
+        // 模拟微信下单返回
66
+        Map<String, Object> result = new LinkedHashMap<>();
67
+        result.put("paymentRecord", record);
68
+        result.put("prepayId", "WX" + System.currentTimeMillis());
69
+        result.put("codeUrl", "weixin://wxpay/bizpayurl?pr=" + record.getPaymentNo());
70
+        return result;
71
+    }
72
+
73
+    /**
74
+     * 支付宝回调
75
+     */
76
+    @Transactional
77
+    public String alipayCallback(String paymentNo, String tradeNo, String tradeStatus) {
78
+        PaymentRecord record = paymentRecordMapper.selectOne(
79
+                new LambdaQueryWrapper<PaymentRecord>().eq(PaymentRecord::getPaymentNo, paymentNo));
80
+        if (record == null) {
81
+            log.warn("Alipay callback: payment not found: {}", paymentNo);
82
+            return "fail";
83
+        }
84
+
85
+        if ("TRADE_SUCCESS".equals(tradeStatus) || "TRADE_FINISHED".equals(tradeStatus)) {
86
+            record.setStatus("success");
87
+            record.setTradeNo(tradeNo);
88
+            record.setPayTime(LocalDateTime.now());
89
+            paymentRecordMapper.updateById(record);
90
+            updateBillPaidAmount(record.getBillId(), record.getAmount());
91
+            log.info("Alipay callback success: paymentNo={}, tradeNo={}", paymentNo, tradeNo);
92
+            return "success";
93
+        }
94
+
95
+        record.setStatus("failed");
96
+        paymentRecordMapper.updateById(record);
97
+        return "fail";
98
+    }
99
+
100
+    /**
101
+     * 微信回调
102
+     */
103
+    @Transactional
104
+    public String wechatCallback(String paymentNo, String transactionId, String resultCode) {
105
+        PaymentRecord record = paymentRecordMapper.selectOne(
106
+                new LambdaQueryWrapper<PaymentRecord>().eq(PaymentRecord::getPaymentNo, paymentNo));
107
+        if (record == null) {
108
+            log.warn("Wechat callback: payment not found: {}", paymentNo);
109
+            return "FAIL";
110
+        }
111
+
112
+        if ("SUCCESS".equals(resultCode)) {
113
+            record.setStatus("success");
114
+            record.setTradeNo(transactionId);
115
+            record.setPayTime(LocalDateTime.now());
116
+            paymentRecordMapper.updateById(record);
117
+            updateBillPaidAmount(record.getBillId(), record.getAmount());
118
+            log.info("Wechat callback success: paymentNo={}, transactionId={}", paymentNo, transactionId);
119
+            return "SUCCESS";
120
+        }
121
+
122
+        record.setStatus("failed");
123
+        paymentRecordMapper.updateById(record);
124
+        return "FAIL";
125
+    }
126
+
127
+    /**
128
+     * 退费
129
+     */
130
+    @Transactional
131
+    public PaymentRecord refund(Long paymentId, BigDecimal refundAmount, Long operatorId, String operatorName) {
132
+        PaymentRecord original = paymentRecordMapper.selectById(paymentId);
133
+        if (original == null) {
134
+            throw new RuntimeException("支付记录不存在: " + paymentId);
135
+        }
136
+        if (!"success".equals(original.getStatus())) {
137
+            throw new RuntimeException("只能对成功状态的支付进行退费");
138
+        }
139
+        if (refundAmount.compareTo(original.getAmount()) > 0) {
140
+            throw new RuntimeException("退款金额不能大于原支付金额");
141
+        }
142
+
143
+        // 创建退费记录
144
+        PaymentRecord refundRecord = new PaymentRecord();
145
+        refundRecord.setPaymentNo("REF-" + System.currentTimeMillis());
146
+        refundRecord.setBillId(original.getBillId());
147
+        refundRecord.setBillNo(original.getBillNo());
148
+        refundRecord.setCustomerNo(original.getCustomerNo());
149
+        refundRecord.setCustomerName(original.getCustomerName());
150
+        refundRecord.setAmount(refundAmount.negate()); // 负数表示退费
151
+        refundRecord.setChannel(original.getChannel());
152
+        refundRecord.setChannelName(original.getChannelName());
153
+        refundRecord.setStatus("refunded");
154
+        refundRecord.setOperatorId(operatorId);
155
+        refundRecord.setOperatorName(operatorName);
156
+        refundRecord.setPayTime(LocalDateTime.now());
157
+        refundRecord.setRefundNo("RF" + System.currentTimeMillis());
158
+        refundRecord.setRefundAmount(refundAmount);
159
+        refundRecord.setRemark("退费 - 原支付流水: " + original.getPaymentNo());
160
+        refundRecord.setCreatedAt(LocalDateTime.now());
161
+        refundRecord.setUpdatedAt(LocalDateTime.now());
162
+
163
+        paymentRecordMapper.insert(refundRecord);
164
+
165
+        // 更新原支付记录状态
166
+        original.setStatus("refunded");
167
+        original.setRefundNo(refundRecord.getRefundNo());
168
+        original.setRefundAmount(refundAmount);
169
+        original.setUpdatedAt(LocalDateTime.now());
170
+        paymentRecordMapper.updateById(original);
171
+
172
+        // 更新账单已付金额(减少)
173
+        updateBillPaidAmountOnRefund(original.getBillId(), refundAmount);
174
+
175
+        log.info("Refund processed: refundNo={}, originalPaymentNo={}, amount={}",
176
+                refundRecord.getRefundNo(), original.getPaymentNo(), refundAmount);
177
+        return refundRecord;
178
+    }
179
+
180
+    /**
181
+     * 查询所有支付渠道配置
182
+     */
183
+    public List<PaymentChannel> listChannels() {
184
+        return paymentChannelMapper.selectList(
185
+                new LambdaQueryWrapper<PaymentChannel>().orderByAsc(PaymentChannel::getSortOrder));
186
+    }
187
+
188
+    /**
189
+     * 启用/禁用支付渠道
190
+     */
191
+    public void toggleChannel(Long channelId, Integer enabled) {
192
+        PaymentChannel channel = paymentChannelMapper.selectById(channelId);
193
+        if (channel == null) {
194
+            throw new RuntimeException("支付渠道不存在: " + channelId);
195
+        }
196
+        channel.setEnabled(enabled);
197
+        channel.setUpdatedAt(LocalDateTime.now());
198
+        paymentChannelMapper.updateById(channel);
199
+    }
200
+
201
+    // ---- private helpers ----
202
+
203
+    private PaymentRecord doPay(Long billId, BigDecimal amount, String channel, String channelName,
204
+                                 Long operatorId, String operatorName) {
205
+        WaterBill bill = waterBillMapper.selectById(billId);
206
+        if (bill == null) {
207
+            throw new RuntimeException("账单不存在: " + billId);
208
+        }
209
+        if ("paid".equals(bill.getStatus()) || "cancelled".equals(bill.getStatus())) {
210
+            throw new RuntimeException("账单状态不允许缴费: " + bill.getStatus());
211
+        }
212
+
213
+        BigDecimal remaining = bill.getTotalAmount().subtract(bill.getPaidAmount());
214
+        if (amount.compareTo(remaining) > 0) {
215
+            throw new RuntimeException("支付金额超出应缴余额,剩余: " + remaining);
216
+        }
217
+
218
+        PaymentRecord record = new PaymentRecord();
219
+        record.setPaymentNo("PAY-" + System.currentTimeMillis() + "-" + new Random().nextInt(1000));
220
+        record.setBillId(billId);
221
+        record.setBillNo(bill.getBillNo());
222
+        record.setCustomerNo(bill.getCustomerNo());
223
+        record.setCustomerName(bill.getCustomerName());
224
+        record.setAmount(amount);
225
+        record.setChannel(channel);
226
+        record.setChannelName(channelName);
227
+        record.setStatus("success");
228
+        record.setOperatorId(operatorId);
229
+        record.setOperatorName(operatorName);
230
+        record.setPayTime(LocalDateTime.now());
231
+        record.setCreatedAt(LocalDateTime.now());
232
+        record.setUpdatedAt(LocalDateTime.now());
233
+
234
+        paymentRecordMapper.insert(record);
235
+
236
+        // 更新账单已付金额
237
+        updateBillPaidAmount(billId, amount);
238
+
239
+        log.info("Payment processed: paymentNo={}, billId={}, amount={}, channel={}",
240
+                record.getPaymentNo(), billId, amount, channel);
241
+        return record;
242
+    }
243
+
244
+    private void updateBillPaidAmount(Long billId, BigDecimal amount) {
245
+        WaterBill bill = waterBillMapper.selectById(billId);
246
+        if (bill == null) return;
247
+
248
+        BigDecimal newPaid = bill.getPaidAmount().add(amount);
249
+        bill.setPaidAmount(newPaid);
250
+
251
+        if (newPaid.compareTo(bill.getTotalAmount()) >= 0) {
252
+            bill.setStatus("paid");
253
+        } else if (newPaid.compareTo(BigDecimal.ZERO) > 0) {
254
+            bill.setStatus("partial");
255
+        }
256
+        bill.setUpdatedAt(LocalDateTime.now());
257
+        waterBillMapper.updateById(bill);
258
+    }
259
+
260
+    private void updateBillPaidAmountOnRefund(Long billId, BigDecimal refundAmount) {
261
+        WaterBill bill = waterBillMapper.selectById(billId);
262
+        if (bill == null) return;
263
+
264
+        BigDecimal newPaid = bill.getPaidAmount().subtract(refundAmount);
265
+        if (newPaid.compareTo(BigDecimal.ZERO) < 0) {
266
+            newPaid = BigDecimal.ZERO;
267
+        }
268
+        bill.setPaidAmount(newPaid);
269
+        bill.setStatus(newPaid.compareTo(BigDecimal.ZERO) > 0 ? "partial" : "pending");
270
+        bill.setUpdatedAt(LocalDateTime.now());
271
+        waterBillMapper.updateById(bill);
272
+    }
273
+}

+ 126
- 0
wm-revenue/src/main/resources/db/V_bill_payment.sql Bestand weergeven

1
+-- =============================================
2
+-- V_bill_payment.sql
3
+-- 账单与收费模块 DDL
4
+-- =============================================
5
+
6
+-- 1. 水费账单表
7
+CREATE TABLE IF NOT EXISTS wm_water_bill (
8
+    id              BIGSERIAL PRIMARY KEY,
9
+    bill_no         VARCHAR(64)    NOT NULL,
10
+    customer_no     VARCHAR(64),
11
+    customer_name   VARCHAR(128),
12
+    bill_period     VARCHAR(16)    NOT NULL,
13
+    water_usage     NUMERIC(12,2)  DEFAULT 0,
14
+    unit_price      NUMERIC(8,4)   DEFAULT 0,
15
+    total_amount    NUMERIC(12,2)  NOT NULL DEFAULT 0,
16
+    paid_amount     NUMERIC(12,2)  NOT NULL DEFAULT 0,
17
+    status          VARCHAR(16)    NOT NULL DEFAULT 'pending',
18
+    issue_date      DATE,
19
+    due_date        DATE,
20
+    meter_no        VARCHAR(64),
21
+    prev_reading    NUMERIC(12,2)  DEFAULT 0,
22
+    curr_reading    NUMERIC(12,2)  DEFAULT 0,
23
+    remark          VARCHAR(512),
24
+    created_at      TIMESTAMP      NOT NULL DEFAULT CURRENT_TIMESTAMP,
25
+    updated_at      TIMESTAMP      NOT NULL DEFAULT CURRENT_TIMESTAMP
26
+);
27
+
28
+COMMENT ON TABLE wm_water_bill IS '水费账单表';
29
+COMMENT ON COLUMN wm_water_bill.bill_no IS '账单编号';
30
+COMMENT ON COLUMN wm_water_bill.customer_no IS '客户编号';
31
+COMMENT ON COLUMN wm_water_bill.customer_name IS '客户名称';
32
+COMMENT ON COLUMN wm_water_bill.bill_period IS '账单周期(如 2026-06)';
33
+COMMENT ON COLUMN wm_water_bill.water_usage IS '用水量(吨)';
34
+COMMENT ON COLUMN wm_water_bill.unit_price IS '单价(元/吨)';
35
+COMMENT ON COLUMN wm_water_bill.total_amount IS '总金额';
36
+COMMENT ON COLUMN wm_water_bill.paid_amount IS '已付金额';
37
+COMMENT ON COLUMN wm_water_bill.status IS '状态: pending/paid/partial/overdue/cancelled';
38
+COMMENT ON COLUMN wm_water_bill.issue_date IS '出账日期';
39
+COMMENT ON COLUMN wm_water_bill.due_date IS '到期日期';
40
+COMMENT ON COLUMN wm_water_bill.meter_no IS '水表编号';
41
+
42
+-- 2. 缴费记录表
43
+CREATE TABLE IF NOT EXISTS wm_payment_record (
44
+    id              BIGSERIAL PRIMARY KEY,
45
+    payment_no      VARCHAR(64)    NOT NULL,
46
+    bill_id         BIGINT,
47
+    bill_no         VARCHAR(64),
48
+    customer_no     VARCHAR(64),
49
+    customer_name   VARCHAR(128),
50
+    amount          NUMERIC(12,2)  NOT NULL,
51
+    channel         VARCHAR(32)    NOT NULL,
52
+    channel_name    VARCHAR(64),
53
+    status          VARCHAR(16)    NOT NULL DEFAULT 'pending',
54
+    operator_id     BIGINT,
55
+    operator_name   VARCHAR(64),
56
+    pay_time        TIMESTAMP,
57
+    trade_no        VARCHAR(128),
58
+    refund_no       VARCHAR(64),
59
+    refund_amount   NUMERIC(12,2),
60
+    remark          VARCHAR(512),
61
+    created_at      TIMESTAMP      NOT NULL DEFAULT CURRENT_TIMESTAMP,
62
+    updated_at      TIMESTAMP      NOT NULL DEFAULT CURRENT_TIMESTAMP
63
+);
64
+
65
+COMMENT ON TABLE wm_payment_record IS '缴费记录表';
66
+COMMENT ON COLUMN wm_payment_record.payment_no IS '支付流水号';
67
+COMMENT ON COLUMN wm_payment_record.bill_id IS '账单ID';
68
+COMMENT ON COLUMN wm_payment_record.amount IS '支付金额(负数表示退费)';
69
+COMMENT ON COLUMN wm_payment_record.channel IS '支付渠道编码: counter/pos/alipay/wechat';
70
+COMMENT ON COLUMN wm_payment_record.status IS '支付状态: pending/success/failed/refunded';
71
+COMMENT ON COLUMN wm_payment_record.trade_no IS '第三方支付交易号';
72
+
73
+-- 3. 支付渠道配置表
74
+CREATE TABLE IF NOT EXISTS wm_payment_channel (
75
+    id              BIGSERIAL PRIMARY KEY,
76
+    channel_code    VARCHAR(32)    NOT NULL,
77
+    channel_name    VARCHAR(64)    NOT NULL,
78
+    enabled         INTEGER        NOT NULL DEFAULT 1,
79
+    fee_rate        NUMERIC(6,4)   DEFAULT 0,
80
+    description     VARCHAR(256),
81
+    sort_order      INTEGER        DEFAULT 0,
82
+    created_at      TIMESTAMP      NOT NULL DEFAULT CURRENT_TIMESTAMP,
83
+    updated_at      TIMESTAMP      NOT NULL DEFAULT CURRENT_TIMESTAMP
84
+);
85
+
86
+COMMENT ON TABLE wm_payment_channel IS '支付渠道配置表';
87
+COMMENT ON COLUMN wm_payment_channel.channel_code IS '渠道编码';
88
+COMMENT ON COLUMN wm_payment_channel.channel_name IS '渠道名称';
89
+COMMENT ON COLUMN wm_payment_channel.enabled IS '是否启用: 1=启用 0=禁用';
90
+COMMENT ON COLUMN wm_payment_channel.fee_rate IS '手续费率(百分比)';
91
+
92
+-- =============================================
93
+-- 索引
94
+-- =============================================
95
+
96
+-- 账单表索引
97
+CREATE INDEX IF NOT EXISTS idx_water_bill_bill_no ON wm_water_bill (bill_no);
98
+CREATE INDEX IF NOT EXISTS idx_water_bill_customer_no ON wm_water_bill (customer_no);
99
+CREATE INDEX IF NOT EXISTS idx_water_bill_period ON wm_water_bill (bill_period);
100
+CREATE INDEX IF NOT EXISTS idx_water_bill_status ON wm_water_bill (status);
101
+CREATE INDEX IF NOT EXISTS idx_water_bill_due_date ON wm_water_bill (due_date);
102
+CREATE UNIQUE INDEX IF NOT EXISTS uk_water_bill_bill_no ON wm_water_bill (bill_no);
103
+
104
+-- 缴费记录表索引
105
+CREATE INDEX IF NOT EXISTS idx_payment_record_payment_no ON wm_payment_record (payment_no);
106
+CREATE INDEX IF NOT EXISTS idx_payment_record_bill_id ON wm_payment_record (bill_id);
107
+CREATE INDEX IF NOT EXISTS idx_payment_record_customer_no ON wm_payment_record (customer_no);
108
+CREATE INDEX IF NOT EXISTS idx_payment_record_channel ON wm_payment_record (channel);
109
+CREATE INDEX IF NOT EXISTS idx_payment_record_status ON wm_payment_record (status);
110
+CREATE INDEX IF NOT EXISTS idx_payment_record_pay_time ON wm_payment_record (pay_time);
111
+CREATE UNIQUE INDEX IF NOT EXISTS uk_payment_record_payment_no ON wm_payment_record (payment_no);
112
+
113
+-- 支付渠道表索引
114
+CREATE UNIQUE INDEX IF NOT EXISTS uk_payment_channel_code ON wm_payment_channel (channel_code);
115
+
116
+-- =============================================
117
+-- 默认支付渠道数据
118
+-- =============================================
119
+
120
+INSERT INTO wm_payment_channel (channel_code, channel_name, enabled, fee_rate, description, sort_order)
121
+VALUES
122
+    ('counter', '柜台现金', 1, 0.0000, '营业厅柜台现金支付', 1),
123
+    ('pos', 'POS刷卡', 1, 0.6000, 'POS机刷卡支付(手续费0.6%)', 2),
124
+    ('alipay', '支付宝', 1, 0.6000, '支付宝APP/网页/二维码支付', 3),
125
+    ('wechat', '微信支付', 1, 0.6000, '微信APP/小程序/二维码支付', 4)
126
+ON CONFLICT (channel_code) DO NOTHING;

+ 389
- 0
wm-revenue/src/test/java/com/water/revenue/BillPaymentTest.java Bestand weergeven

1
+package com.water.revenue;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
5
+import com.water.revenue.entity.PaymentChannel;
6
+import com.water.revenue.entity.PaymentRecord;
7
+import com.water.revenue.entity.WaterBill;
8
+import com.water.revenue.mapper.PaymentChannelMapper;
9
+import com.water.revenue.mapper.PaymentRecordMapper;
10
+import com.water.revenue.mapper.WaterBillMapper;
11
+import com.water.revenue.service.BillService;
12
+import com.water.revenue.service.PaymentQueryService;
13
+import com.water.revenue.service.PaymentService;
14
+import org.junit.jupiter.api.BeforeEach;
15
+import org.junit.jupiter.api.DisplayName;
16
+import org.junit.jupiter.api.Nested;
17
+import org.junit.jupiter.api.Test;
18
+import org.junit.jupiter.api.extension.ExtendWith;
19
+import org.mockito.ArgumentCaptor;
20
+import org.mockito.Mock;
21
+import org.mockito.junit.jupiter.MockitoExtension;
22
+import org.springframework.jdbc.core.JdbcTemplate;
23
+
24
+import java.math.BigDecimal;
25
+import java.time.LocalDate;
26
+import java.time.LocalDateTime;
27
+import java.util.*;
28
+
29
+import static org.junit.jupiter.api.Assertions.*;
30
+import static org.mockito.ArgumentMatchers.*;
31
+import static org.mockito.Mockito.*;
32
+
33
+@ExtendWith(MockitoExtension.class)
34
+@DisplayName("账单与收费模块测试")
35
+class BillPaymentTest {
36
+
37
+    @Mock
38
+    private WaterBillMapper waterBillMapper;
39
+    @Mock
40
+    private PaymentRecordMapper paymentRecordMapper;
41
+    @Mock
42
+    private PaymentChannelMapper paymentChannelMapper;
43
+    @Mock
44
+    private JdbcTemplate jdbcTemplate;
45
+
46
+    private BillService billService;
47
+    private PaymentService paymentService;
48
+    private PaymentQueryService paymentQueryService;
49
+
50
+    @BeforeEach
51
+    void setUp() {
52
+        billService = new BillService(waterBillMapper, jdbcTemplate);
53
+        paymentService = new PaymentService(paymentRecordMapper, paymentChannelMapper, waterBillMapper);
54
+        paymentQueryService = new PaymentQueryService(paymentRecordMapper, jdbcTemplate);
55
+    }
56
+
57
+    // ========== 账单服务测试 ==========
58
+
59
+    @Nested
60
+    @DisplayName("BillService - 账单生成测试")
61
+    class BillGenerationTests {
62
+
63
+        @Test
64
+        @DisplayName("自动生成账单 - 正常周期")
65
+        void autoGenerateBills_normalPeriod() {
66
+            // 模拟抄表记录
67
+            Map<String, Object> reading = new HashMap<>();
68
+            reading.put("reading_id", 1L);
69
+            reading.put("consumption", new BigDecimal("15.5"));
70
+            reading.put("prev_reading", new BigDecimal("100"));
71
+            reading.put("curr_reading", new BigDecimal("115.5"));
72
+            reading.put("reading_period", "2026-06");
73
+            reading.put("meter_no", "M001");
74
+            reading.put("customer_id", 100L);
75
+            reading.put("customer_no", "C001");
76
+            reading.put("customer_name", "张三");
77
+            reading.put("customer_type", "residential");
78
+
79
+            when(jdbcTemplate.queryForList(anyString(), eq("2026-06"), eq("2026-06")))
80
+                    .thenReturn(List.of(reading));
81
+            when(jdbcTemplate.queryForObject(contains("water_price"), eq(BigDecimal.class), eq("residential")))
82
+                    .thenReturn(new BigDecimal("3.50"));
83
+            when(waterBillMapper.insert(any(WaterBill.class))).thenReturn(1);
84
+
85
+            Map<String, Object> result = billService.autoGenerateBills("2026-06");
86
+
87
+            assertNotNull(result);
88
+            assertEquals("2026-06", result.get("period"));
89
+            assertEquals(1, result.get("generatedCount"));
90
+            verify(waterBillMapper, times(1)).insert(any(WaterBill.class));
91
+        }
92
+
93
+        @Test
94
+        @DisplayName("手动生成账单 - 已存在时抛异常")
95
+        void manualGenerateBill_alreadyExists() {
96
+            when(jdbcTemplate.queryForMap(anyString(), eq(1L))).thenReturn(mockReadingMap());
97
+            when(waterBillMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(1L);
98
+
99
+            assertThrows(RuntimeException.class, () -> billService.manualGenerateBill(1L));
100
+        }
101
+
102
+        @Test
103
+        @DisplayName("手动生成账单 - 正常生成")
104
+        void manualGenerateBill_success() {
105
+            when(jdbcTemplate.queryForMap(anyString(), eq(1L))).thenReturn(mockReadingMap());
106
+            when(waterBillMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(0L);
107
+            when(jdbcTemplate.queryForObject(anyString(), eq(BigDecimal.class), anyString()))
108
+                    .thenReturn(new BigDecimal("4.20"));
109
+            when(waterBillMapper.insert(any(WaterBill.class))).thenAnswer(inv -> {
110
+                WaterBill bill = inv.getArgument(0);
111
+                bill.setId(1L);
112
+                return 1;
113
+            });
114
+
115
+            WaterBill result = billService.manualGenerateBill(1L);
116
+            assertNotNull(result);
117
+            assertEquals("C001", result.getCustomerNo());
118
+            assertTrue(result.getTotalAmount().compareTo(BigDecimal.ZERO) > 0);
119
+        }
120
+
121
+        @Test
122
+        @DisplayName("账单明细查询 - 包含缴费记录")
123
+        void getBillDetail_withPayments() {
124
+            WaterBill bill = createMockBill(1L, "BILL-001", "C001", new BigDecimal("100"), new BigDecimal("50"));
125
+            when(waterBillMapper.selectById(1L)).thenReturn(bill);
126
+
127
+            List<Map<String, Object>> payments = List.of(
128
+                    Map.of("payment_no", "PAY-001", "amount", new BigDecimal("50"), "channel", "counter")
129
+            );
130
+            when(jdbcTemplate.queryForList(anyString(), eq(1L))).thenReturn(payments);
131
+
132
+            Map<String, Object> detail = billService.getBillDetail(1L);
133
+            assertNotNull(detail);
134
+            assertEquals(bill, detail.get("bill"));
135
+            assertEquals(1, ((List<?>) detail.get("payments")).size());
136
+            assertEquals(new BigDecimal("50"), detail.get("remainingAmount"));
137
+        }
138
+
139
+        @Test
140
+        @DisplayName("作废账单 - pending状态可作废")
141
+        void cancelBill_pending_success() {
142
+            WaterBill bill = createMockBill(1L, "BILL-001", "C001", new BigDecimal("100"), BigDecimal.ZERO);
143
+            bill.setStatus("pending");
144
+            when(waterBillMapper.selectById(1L)).thenReturn(bill);
145
+            when(waterBillMapper.updateById(any(WaterBill.class))).thenReturn(1);
146
+
147
+            billService.cancelBill(1L);
148
+
149
+            ArgumentCaptor<WaterBill> captor = ArgumentCaptor.forClass(WaterBill.class);
150
+            verify(waterBillMapper).updateById(captor.capture());
151
+            assertEquals("cancelled", captor.getValue().getStatus());
152
+        }
153
+
154
+        @Test
155
+        @DisplayName("作废账单 - 非pending状态抛异常")
156
+        void cancelBill_notPending_throwsException() {
157
+            WaterBill bill = createMockBill(1L, "BILL-001", "C001", new BigDecimal("100"), new BigDecimal("100"));
158
+            bill.setStatus("paid");
159
+            when(waterBillMapper.selectById(1L)).thenReturn(bill);
160
+
161
+            assertThrows(RuntimeException.class, () -> billService.cancelBill(1L));
162
+        }
163
+    }
164
+
165
+    // ========== 支付服务测试 ==========
166
+
167
+    @Nested
168
+    @DisplayName("PaymentService - 收费测试")
169
+    class PaymentTests {
170
+
171
+        @Test
172
+        @DisplayName("柜台收费 - 全额支付")
173
+        void payByCounter_fullPayment() {
174
+            WaterBill bill = createMockBill(1L, "BILL-001", "C001", new BigDecimal("100"), BigDecimal.ZERO);
175
+            when(waterBillMapper.selectById(1L)).thenReturn(bill);
176
+            when(paymentRecordMapper.insert(any(PaymentRecord.class))).thenAnswer(inv -> {
177
+                PaymentRecord r = inv.getArgument(0);
178
+                r.setId(1L);
179
+                return 1;
180
+            });
181
+            when(waterBillMapper.updateById(any(WaterBill.class))).thenReturn(1);
182
+
183
+            PaymentRecord result = paymentService.payByCounter(1L, new BigDecimal("100"), 10L, "操作员A");
184
+
185
+            assertNotNull(result);
186
+            assertEquals("counter", result.getChannel());
187
+            assertEquals("success", result.getStatus());
188
+            assertEquals(new BigDecimal("100"), result.getAmount());
189
+        }
190
+
191
+        @Test
192
+        @DisplayName("柜台收费 - 部分支付")
193
+        void payByCounter_partialPayment() {
194
+            WaterBill bill = createMockBill(1L, "BILL-001", "C001", new BigDecimal("100"), BigDecimal.ZERO);
195
+            when(waterBillMapper.selectById(1L)).thenReturn(bill);
196
+            when(paymentRecordMapper.insert(any(PaymentRecord.class))).thenReturn(1);
197
+            when(waterBillMapper.updateById(any(WaterBill.class))).thenReturn(1);
198
+
199
+            PaymentRecord result = paymentService.payByCounter(1L, new BigDecimal("50"), 10L, "操作员A");
200
+
201
+            assertNotNull(result);
202
+            ArgumentCaptor<WaterBill> captor = ArgumentCaptor.forClass(WaterBill.class);
203
+            verify(waterBillMapper).updateById(captor.capture());
204
+            assertEquals("partial", captor.getValue().getStatus());
205
+        }
206
+
207
+        @Test
208
+        @DisplayName("超额支付 - 抛出异常")
209
+        void payByCounter_overAmount_throwsException() {
210
+            WaterBill bill = createMockBill(1L, "BILL-001", "C001", new BigDecimal("100"), BigDecimal.ZERO);
211
+            when(waterBillMapper.selectById(1L)).thenReturn(bill);
212
+
213
+            assertThrows(RuntimeException.class,
214
+                    () -> paymentService.payByCounter(1L, new BigDecimal("200"), 10L, "操作员A"));
215
+        }
216
+
217
+        @Test
218
+        @DisplayName("支付宝下单 - 返回支付信息")
219
+        void payByAlipay_returnsPayInfo() {
220
+            WaterBill bill = createMockBill(1L, "BILL-001", "C001", new BigDecimal("100"), BigDecimal.ZERO);
221
+            when(waterBillMapper.selectById(1L)).thenReturn(bill);
222
+            when(paymentRecordMapper.insert(any(PaymentRecord.class))).thenReturn(1);
223
+            when(waterBillMapper.updateById(any(WaterBill.class))).thenReturn(1);
224
+
225
+            Map<String, Object> result = paymentService.payByAlipay(1L, new BigDecimal("100"));
226
+
227
+            assertNotNull(result);
228
+            assertNotNull(result.get("payForm"));
229
+            assertNotNull(result.get("tradeNo"));
230
+        }
231
+
232
+        @Test
233
+        @DisplayName("微信下单 - 返回prepay信息")
234
+        void payByWechat_returnsPrepayInfo() {
235
+            WaterBill bill = createMockBill(1L, "BILL-001", "C001", new BigDecimal("100"), BigDecimal.ZERO);
236
+            when(waterBillMapper.selectById(1L)).thenReturn(bill);
237
+            when(paymentRecordMapper.insert(any(PaymentRecord.class))).thenReturn(1);
238
+            when(waterBillMapper.updateById(any(WaterBill.class))).thenReturn(1);
239
+
240
+            Map<String, Object> result = paymentService.payByWechat(1L, new BigDecimal("100"));
241
+
242
+            assertNotNull(result);
243
+            assertNotNull(result.get("prepayId"));
244
+            assertNotNull(result.get("codeUrl"));
245
+        }
246
+
247
+        @Test
248
+        @DisplayName("支付宝回调 - 成功")
249
+        void alipayCallback_success() {
250
+            PaymentRecord record = new PaymentRecord();
251
+            record.setId(1L);
252
+            record.setPaymentNo("PAY-001");
253
+            record.setBillId(1L);
254
+            record.setAmount(new BigDecimal("100"));
255
+            record.setStatus("pending");
256
+
257
+            when(paymentRecordMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(record);
258
+            when(paymentRecordMapper.updateById(any(PaymentRecord.class))).thenReturn(1);
259
+
260
+            WaterBill bill = createMockBill(1L, "BILL-001", "C001", new BigDecimal("100"), BigDecimal.ZERO);
261
+            when(waterBillMapper.selectById(1L)).thenReturn(bill);
262
+            when(waterBillMapper.updateById(any(WaterBill.class))).thenReturn(1);
263
+
264
+            String result = paymentService.alipayCallback("PAY-001", "ALI123", "TRADE_SUCCESS");
265
+            assertEquals("success", result);
266
+        }
267
+
268
+        @Test
269
+        @DisplayName("退费 - 正常退费")
270
+        void refund_success() {
271
+            PaymentRecord original = new PaymentRecord();
272
+            original.setId(1L);
273
+            original.setPaymentNo("PAY-001");
274
+            original.setBillId(1L);
275
+            original.setBillNo("BILL-001");
276
+            original.setCustomerNo("C001");
277
+            original.setCustomerName("张三");
278
+            original.setAmount(new BigDecimal("100"));
279
+            original.setChannel("counter");
280
+            original.setChannelName("柜台现金");
281
+            original.setStatus("success");
282
+
283
+            when(paymentRecordMapper.selectById(1L)).thenReturn(original);
284
+            when(paymentRecordMapper.insert(any(PaymentRecord.class))).thenReturn(1);
285
+            when(paymentRecordMapper.updateById(any(PaymentRecord.class))).thenReturn(1);
286
+
287
+            WaterBill bill = createMockBill(1L, "BILL-001", "C001", new BigDecimal("100"), new BigDecimal("100"));
288
+            when(waterBillMapper.selectById(1L)).thenReturn(bill);
289
+            when(waterBillMapper.updateById(any(WaterBill.class))).thenReturn(1);
290
+
291
+            PaymentRecord result = paymentService.refund(1L, new BigDecimal("50"), 10L, "操作员B");
292
+
293
+            assertNotNull(result);
294
+            assertEquals("refunded", result.getStatus());
295
+            assertEquals(new BigDecimal("-50"), result.getAmount());
296
+        }
297
+
298
+        @Test
299
+        @DisplayName("退费 - 金额超限抛异常")
300
+        void refund_overAmount_throwsException() {
301
+            PaymentRecord original = new PaymentRecord();
302
+            original.setId(1L);
303
+            original.setAmount(new BigDecimal("100"));
304
+            original.setStatus("success");
305
+
306
+            when(paymentRecordMapper.selectById(1L)).thenReturn(original);
307
+
308
+            assertThrows(RuntimeException.class,
309
+                    () -> paymentService.refund(1L, new BigDecimal("200"), 10L, "操作员B"));
310
+        }
311
+    }
312
+
313
+    // ========== 支付查询服务测试 ==========
314
+
315
+    @Nested
316
+    @DisplayName("PaymentQueryService - 查询测试")
317
+    class PaymentQueryTests {
318
+
319
+        @Test
320
+        @DisplayName("按渠道统计")
321
+        void statsByChannel_returnsStats() {
322
+            List<Map<String, Object>> mockStats = List.of(
323
+                    Map.of("channel", "counter", "count", 10L, "total_amount", new BigDecimal("5000")),
324
+                    Map.of("channel", "alipay", "count", 20L, "total_amount", new BigDecimal("8000"))
325
+            );
326
+            when(jdbcTemplate.queryForList(anyString())).thenReturn(mockStats);
327
+
328
+            List<Map<String, Object>> result = paymentQueryService.statsByChannel();
329
+            assertEquals(2, result.size());
330
+        }
331
+
332
+        @Test
333
+        @DisplayName("收费汇总")
334
+        void summary_returnsCorrectData() {
335
+            Map<String, Object> mockSummary = new HashMap<>();
336
+            mockSummary.put("total_count", 30L);
337
+            mockSummary.put("total_income", new BigDecimal("13000"));
338
+            mockSummary.put("total_refund", new BigDecimal("500"));
339
+            mockSummary.put("customer_count", 15L);
340
+
341
+            when(jdbcTemplate.queryForMap(anyString(), any(), any())).thenReturn(mockSummary);
342
+
343
+            Map<String, Object> result = paymentQueryService.summary(
344
+                    LocalDate.of(2026, 6, 1), LocalDate.of(2026, 6, 30));
345
+
346
+            assertNotNull(result);
347
+            assertEquals(30L, result.get("total_count"));
348
+            assertEquals("2026-06-01", result.get("startDate"));
349
+            assertEquals("2026-06-30", result.get("endDate"));
350
+        }
351
+    }
352
+
353
+    // ========== Helper Methods ==========
354
+
355
+    private WaterBill createMockBill(Long id, String billNo, String customerNo,
356
+                                      BigDecimal totalAmount, BigDecimal paidAmount) {
357
+        WaterBill bill = new WaterBill();
358
+        bill.setId(id);
359
+        bill.setBillNo(billNo);
360
+        bill.setCustomerNo(customerNo);
361
+        bill.setCustomerName("测试客户");
362
+        bill.setBillPeriod("2026-06");
363
+        bill.setWaterUsage(new BigDecimal("30"));
364
+        bill.setUnitPrice(new BigDecimal("3.50"));
365
+        bill.setTotalAmount(totalAmount);
366
+        bill.setPaidAmount(paidAmount);
367
+        bill.setStatus("pending");
368
+        bill.setIssueDate(LocalDate.now());
369
+        bill.setDueDate(LocalDate.now().plusDays(30));
370
+        bill.setCreatedAt(LocalDateTime.now());
371
+        bill.setUpdatedAt(LocalDateTime.now());
372
+        return bill;
373
+    }
374
+
375
+    private Map<String, Object> mockReadingMap() {
376
+        Map<String, Object> reading = new HashMap<>();
377
+        reading.put("reading_id", 1L);
378
+        reading.put("consumption", new BigDecimal("20"));
379
+        reading.put("prev_reading", new BigDecimal("100"));
380
+        reading.put("curr_reading", new BigDecimal("120"));
381
+        reading.put("reading_period", "2026-06");
382
+        reading.put("meter_no", "M001");
383
+        reading.put("customer_id", 100L);
384
+        reading.put("customer_no", "C001");
385
+        reading.put("customer_name", "张三");
386
+        reading.put("customer_type", "residential");
387
+        return reading;
388
+    }
389
+}