Sfoglia il codice sorgente

feat(wm-revenue): #52 电子发票管理+应收应付账务+欠费催缴

- Entity: Invoice, Receivable, Payable, Reconciliation, CollectionRecord
- Mapper: 5个MyBatis-Plus Mapper接口
- Service: InvoiceService(开具/查询/推送/红冲), AccountService(应收/应付/对账/结转), CollectionService(催缴/通知/统计)
- Controller: InvoiceController(8端点), AccountController(12端点), CollectionController(8端点)
- DDL: V_invoice_account.sql(5张表+索引)
- Test: InvoiceAccountTest(16个测试用例)
bot_dev2 5 giorni fa
parent
commit
8383a63541
19 ha cambiato i file con 2470 aggiunte e 0 eliminazioni
  1. 155
    0
      db/postgresql/V_invoice_account.sql
  2. 161
    0
      wm-revenue/src/main/java/com/water/revenue/controller/AccountController.java
  3. 106
    0
      wm-revenue/src/main/java/com/water/revenue/controller/CollectionController.java
  4. 103
    0
      wm-revenue/src/main/java/com/water/revenue/controller/InvoiceController.java
  5. 77
    0
      wm-revenue/src/main/java/com/water/revenue/entity/CollectionRecord.java
  6. 92
    0
      wm-revenue/src/main/java/com/water/revenue/entity/Invoice.java
  7. 68
    0
      wm-revenue/src/main/java/com/water/revenue/entity/Payable.java
  8. 71
    0
      wm-revenue/src/main/java/com/water/revenue/entity/Receivable.java
  9. 83
    0
      wm-revenue/src/main/java/com/water/revenue/entity/Reconciliation.java
  10. 27
    0
      wm-revenue/src/main/java/com/water/revenue/mapper/CollectionRecordMapper.java
  11. 26
    0
      wm-revenue/src/main/java/com/water/revenue/mapper/InvoiceMapper.java
  12. 24
    0
      wm-revenue/src/main/java/com/water/revenue/mapper/PayableMapper.java
  13. 27
    0
      wm-revenue/src/main/java/com/water/revenue/mapper/ReceivableMapper.java
  14. 24
    0
      wm-revenue/src/main/java/com/water/revenue/mapper/ReconciliationMapper.java
  15. 397
    0
      wm-revenue/src/main/java/com/water/revenue/service/AccountService.java
  16. 262
    0
      wm-revenue/src/main/java/com/water/revenue/service/CollectionService.java
  17. 232
    0
      wm-revenue/src/main/java/com/water/revenue/service/InvoiceService.java
  18. 155
    0
      wm-revenue/src/main/resources/db/V_invoice_account.sql
  19. 380
    0
      wm-revenue/src/test/java/com/water/revenue/InvoiceAccountTest.java

+ 155
- 0
db/postgresql/V_invoice_account.sql Vedi File

@@ -0,0 +1,155 @@
1
+-- =============================================
2
+-- 智慧水务管理系统 - 电子发票 + 应收应付 + 催缴 DDL
3
+-- 版本: V_invoice_account
4
+-- =============================================
5
+
6
+-- 电子发票表
7
+CREATE TABLE IF NOT EXISTS rev_invoice (
8
+    id BIGSERIAL PRIMARY KEY,
9
+    invoice_no VARCHAR(30) UNIQUE NOT NULL,
10
+    invoice_type VARCHAR(20) NOT NULL DEFAULT 'electronic',  -- normal/special/electronic
11
+    customer_id BIGINT NOT NULL,
12
+    customer_name VARCHAR(100),
13
+    customer_tax_no VARCHAR(30),
14
+    amount DECIMAL(12,2) NOT NULL,
15
+    tax_rate DECIMAL(5,2) DEFAULT 6.00,
16
+    tax_amount DECIMAL(12,2) NOT NULL,
17
+    total_amount DECIMAL(12,2) NOT NULL,
18
+    content VARCHAR(500),
19
+    bill_id BIGINT,
20
+    status VARCHAR(20) NOT NULL DEFAULT 'draft',             -- draft/issued/sent/cancelled/red
21
+    issue_date DATE,
22
+    push_status VARCHAR(20) DEFAULT 'none',                  -- none/email/sms/both
23
+    push_time TIMESTAMP,
24
+    email VARCHAR(100),
25
+    phone VARCHAR(20),
26
+    cancel_reason VARCHAR(500),
27
+    original_invoice_id BIGINT,                              -- 红冲关联的原发票
28
+    operator_id BIGINT,
29
+    operator_name VARCHAR(50),
30
+    remark VARCHAR(500),
31
+    deleted SMALLINT DEFAULT 0,
32
+    created_at TIMESTAMP DEFAULT NOW(),
33
+    updated_at TIMESTAMP DEFAULT NOW()
34
+);
35
+COMMENT ON TABLE rev_invoice IS '电子发票表';
36
+CREATE INDEX IF NOT EXISTS idx_invoice_customer ON rev_invoice(customer_id);
37
+CREATE INDEX IF NOT EXISTS idx_invoice_bill ON rev_invoice(bill_id);
38
+CREATE INDEX IF NOT EXISTS idx_invoice_status ON rev_invoice(status);
39
+CREATE INDEX IF NOT EXISTS idx_invoice_issue_date ON rev_invoice(issue_date);
40
+
41
+-- 应收账款表
42
+CREATE TABLE IF NOT EXISTS rev_receivable (
43
+    id BIGSERIAL PRIMARY KEY,
44
+    receivable_no VARCHAR(30) UNIQUE NOT NULL,
45
+    bill_period VARCHAR(10) NOT NULL,                        -- 2026-06
46
+    customer_id BIGINT NOT NULL,
47
+    customer_name VARCHAR(100),
48
+    amount DECIMAL(12,2) NOT NULL,
49
+    received_amount DECIMAL(12,2) DEFAULT 0,
50
+    unreceived_amount DECIMAL(12,2) NOT NULL,
51
+    status VARCHAR(20) NOT NULL DEFAULT 'pending',           -- pending/partial/completed/overdue/cancelled
52
+    bill_id BIGINT,
53
+    due_date DATE,
54
+    overdue_days INT DEFAULT 0,
55
+    collection_count INT DEFAULT 0,
56
+    last_collection_time TIMESTAMP,
57
+    operator_id BIGINT,
58
+    remark VARCHAR(500),
59
+    deleted SMALLINT DEFAULT 0,
60
+    created_at TIMESTAMP DEFAULT NOW(),
61
+    updated_at TIMESTAMP DEFAULT NOW()
62
+);
63
+COMMENT ON TABLE rev_receivable IS '应收账款表';
64
+CREATE INDEX IF NOT EXISTS idx_receivable_customer ON rev_receivable(customer_id);
65
+CREATE INDEX IF NOT EXISTS idx_receivable_period ON rev_receivable(bill_period);
66
+CREATE INDEX IF NOT EXISTS idx_receivable_status ON rev_receivable(status);
67
+CREATE INDEX IF NOT EXISTS idx_receivable_due_date ON rev_receivable(due_date);
68
+
69
+-- 应付账款表
70
+CREATE TABLE IF NOT EXISTS rev_payable (
71
+    id BIGSERIAL PRIMARY KEY,
72
+    payable_no VARCHAR(30) UNIQUE NOT NULL,
73
+    bill_period VARCHAR(10) NOT NULL,                        -- 2026-06
74
+    supplier_id BIGINT NOT NULL,
75
+    supplier_name VARCHAR(100),
76
+    amount DECIMAL(12,2) NOT NULL,
77
+    paid_amount DECIMAL(12,2) DEFAULT 0,
78
+    unpaid_amount DECIMAL(12,2) NOT NULL,
79
+    status VARCHAR(20) NOT NULL DEFAULT 'pending',           -- pending/partial/completed/cancelled
80
+    expense_type VARCHAR(30),                                -- electricity/chemical/maintenance/equipment/other
81
+    due_date DATE,
82
+    invoice_no VARCHAR(50),
83
+    contract_no VARCHAR(50),
84
+    operator_id BIGINT,
85
+    remark VARCHAR(500),
86
+    deleted SMALLINT DEFAULT 0,
87
+    created_at TIMESTAMP DEFAULT NOW(),
88
+    updated_at TIMESTAMP DEFAULT NOW()
89
+);
90
+COMMENT ON TABLE rev_payable IS '应付账款表';
91
+CREATE INDEX IF NOT EXISTS idx_payable_supplier ON rev_payable(supplier_id);
92
+CREATE INDEX IF NOT EXISTS idx_payable_period ON rev_payable(bill_period);
93
+CREATE INDEX IF NOT EXISTS idx_payable_status ON rev_payable(status);
94
+CREATE INDEX IF NOT EXISTS idx_payable_due_date ON rev_payable(due_date);
95
+
96
+-- 对账记录表
97
+CREATE TABLE IF NOT EXISTS rev_reconciliation (
98
+    id BIGSERIAL PRIMARY KEY,
99
+    reconciliation_no VARCHAR(30) UNIQUE NOT NULL,
100
+    bill_period VARCHAR(10) NOT NULL,
101
+    reconciliation_type VARCHAR(20) DEFAULT 'all',           -- receivable/payable/all
102
+    total_receivable DECIMAL(14,2) DEFAULT 0,
103
+    total_received DECIMAL(14,2) DEFAULT 0,
104
+    total_payable DECIMAL(14,2) DEFAULT 0,
105
+    total_paid DECIMAL(14,2) DEFAULT 0,
106
+    difference DECIMAL(14,2) DEFAULT 0,
107
+    status VARCHAR(20) NOT NULL DEFAULT 'draft',             -- draft/pending/approved/rejected/completed
108
+    start_date DATE,
109
+    end_date DATE,
110
+    exception_count INT DEFAULT 0,
111
+    exception_note TEXT,
112
+    auditor_id BIGINT,
113
+    auditor_name VARCHAR(50),
114
+    audit_time TIMESTAMP,
115
+    operator_id BIGINT,
116
+    operator_name VARCHAR(50),
117
+    remark VARCHAR(500),
118
+    deleted SMALLINT DEFAULT 0,
119
+    created_at TIMESTAMP DEFAULT NOW(),
120
+    updated_at TIMESTAMP DEFAULT NOW()
121
+);
122
+COMMENT ON TABLE rev_reconciliation IS '对账记录表';
123
+CREATE INDEX IF NOT EXISTS idx_reconciliation_period ON rev_reconciliation(bill_period);
124
+CREATE INDEX IF NOT EXISTS idx_reconciliation_status ON rev_reconciliation(status);
125
+
126
+-- 催缴记录表
127
+CREATE TABLE IF NOT EXISTS rev_collection_record (
128
+    id BIGSERIAL PRIMARY KEY,
129
+    collection_no VARCHAR(30) UNIQUE NOT NULL,
130
+    customer_id BIGINT NOT NULL,
131
+    customer_name VARCHAR(100),
132
+    overdue_amount DECIMAL(12,2) NOT NULL,
133
+    overdue_days INT DEFAULT 0,
134
+    collection_type VARCHAR(20) NOT NULL,                    -- sms/phone/visit/letter
135
+    collection_result VARCHAR(20),                           -- success/promised/failed/unreachable/refused
136
+    contact_person VARCHAR(50),
137
+    contact_phone VARCHAR(20),
138
+    contact_email VARCHAR(100),
139
+    collection_time TIMESTAMP,
140
+    promised_date DATE,
141
+    collector_id BIGINT,
142
+    collector_name VARCHAR(50),
143
+    receivable_id BIGINT,
144
+    remark VARCHAR(500),
145
+    status VARCHAR(20) DEFAULT 'pending',                    -- pending/in_progress/completed/cancelled
146
+    deleted SMALLINT DEFAULT 0,
147
+    created_at TIMESTAMP DEFAULT NOW(),
148
+    updated_at TIMESTAMP DEFAULT NOW()
149
+);
150
+COMMENT ON TABLE rev_collection_record IS '催缴记录表';
151
+CREATE INDEX IF NOT EXISTS idx_collection_customer ON rev_collection_record(customer_id);
152
+CREATE INDEX IF NOT EXISTS idx_collection_receivable ON rev_collection_record(receivable_id);
153
+CREATE INDEX IF NOT EXISTS idx_collection_status ON rev_collection_record(status);
154
+CREATE INDEX IF NOT EXISTS idx_collection_time ON rev_collection_record(collection_time);
155
+CREATE INDEX IF NOT EXISTS idx_collection_type ON rev_collection_record(collection_type);

+ 161
- 0
wm-revenue/src/main/java/com/water/revenue/controller/AccountController.java Vedi File

@@ -0,0 +1,161 @@
1
+package com.water.revenue.controller;
2
+
3
+import com.water.common.core.result.R;
4
+import com.water.revenue.entity.Payable;
5
+import com.water.revenue.entity.Receivable;
6
+import com.water.revenue.entity.Reconciliation;
7
+import com.water.revenue.service.AccountService;
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.math.BigDecimal;
14
+import java.time.LocalDate;
15
+import java.util.List;
16
+import java.util.Map;
17
+
18
+/**
19
+ * 应收应付账务管理接口
20
+ */
21
+@Tag(name = "应收应付账务管理")
22
+@RestController
23
+@RequestMapping("/api/revenue/account")
24
+@RequiredArgsConstructor
25
+public class AccountController {
26
+
27
+    private final AccountService accountService;
28
+
29
+    // ==================== 应收账款 ====================
30
+
31
+    @Operation(summary = "创建应收账款")
32
+    @PostMapping("/receivable")
33
+    public R<Receivable> createReceivable(@RequestBody Map<String, Object> req) {
34
+        Receivable receivable = accountService.createReceivable(
35
+                (String) req.get("billPeriod"),
36
+                Long.parseLong(String.valueOf(req.get("customerId"))),
37
+                (String) req.get("customerName"),
38
+                new BigDecimal(String.valueOf(req.get("amount"))),
39
+                req.get("billId") != null ? Long.parseLong(String.valueOf(req.get("billId"))) : null,
40
+                req.get("dueDate") != null ? LocalDate.parse((String) req.get("dueDate")) : null,
41
+                req.get("operatorId") != null ? Long.parseLong(String.valueOf(req.get("operatorId"))) : 1L,
42
+                (String) req.get("remark")
43
+        );
44
+        return R.ok(receivable);
45
+    }
46
+
47
+    @Operation(summary = "应收收款登记")
48
+    @PostMapping("/receivable/{id}/receive")
49
+    public R<Receivable> receivePayment(
50
+            @PathVariable Long id,
51
+            @RequestBody Map<String, Object> req) {
52
+        return R.ok(accountService.receivePayment(id, new BigDecimal(String.valueOf(req.get("amount")))));
53
+    }
54
+
55
+    @Operation(summary = "查询客户应收列表")
56
+    @GetMapping("/receivable/customer/{customerId}")
57
+    public R<List<Receivable>> listReceivableByCustomer(@PathVariable Long customerId) {
58
+        return R.ok(accountService.listReceivableByCustomer(customerId));
59
+    }
60
+
61
+    @Operation(summary = "按账期查询应收")
62
+    @GetMapping("/receivable/period/{billPeriod}")
63
+    public R<List<Receivable>> listReceivableByPeriod(@PathVariable String billPeriod) {
64
+        return R.ok(accountService.listReceivableByPeriod(billPeriod));
65
+    }
66
+
67
+    @Operation(summary = "查询逾期应收")
68
+    @GetMapping("/receivable/overdue")
69
+    public R<List<Receivable>> listOverdueReceivables() {
70
+        return R.ok(accountService.listOverdueReceivables());
71
+    }
72
+
73
+    // ==================== 应付账款 ====================
74
+
75
+    @Operation(summary = "创建应付账款")
76
+    @PostMapping("/payable")
77
+    public R<Payable> createPayable(@RequestBody Map<String, Object> req) {
78
+        Payable payable = accountService.createPayable(
79
+                (String) req.get("billPeriod"),
80
+                Long.parseLong(String.valueOf(req.get("supplierId"))),
81
+                (String) req.get("supplierName"),
82
+                new BigDecimal(String.valueOf(req.get("amount"))),
83
+                (String) req.get("expenseType"),
84
+                req.get("dueDate") != null ? LocalDate.parse((String) req.get("dueDate")) : null,
85
+                (String) req.get("invoiceNo"),
86
+                (String) req.get("contractNo"),
87
+                req.get("operatorId") != null ? Long.parseLong(String.valueOf(req.get("operatorId"))) : 1L,
88
+                (String) req.get("remark")
89
+        );
90
+        return R.ok(payable);
91
+    }
92
+
93
+    @Operation(summary = "应付付款登记")
94
+    @PostMapping("/payable/{id}/pay")
95
+    public R<Payable> makePayment(
96
+            @PathVariable Long id,
97
+            @RequestBody Map<String, Object> req) {
98
+        return R.ok(accountService.makePayment(id, new BigDecimal(String.valueOf(req.get("amount")))));
99
+    }
100
+
101
+    @Operation(summary = "按供应商查询应付")
102
+    @GetMapping("/payable/supplier/{supplierId}")
103
+    public R<List<Payable>> listPayableBySupplier(@PathVariable Long supplierId) {
104
+        return R.ok(accountService.listPayableBySupplier(supplierId));
105
+    }
106
+
107
+    @Operation(summary = "按账期查询应付")
108
+    @GetMapping("/payable/period/{billPeriod}")
109
+    public R<List<Payable>> listPayableByPeriod(@PathVariable String billPeriod) {
110
+        return R.ok(accountService.listPayableByPeriod(billPeriod));
111
+    }
112
+
113
+    // ==================== 对账与结转 ====================
114
+
115
+    @Operation(summary = "创建月度对账")
116
+    @PostMapping("/reconciliation")
117
+    public R<Reconciliation> createReconciliation(@RequestBody Map<String, Object> req) {
118
+        return R.ok(accountService.createReconciliation(
119
+                (String) req.get("billPeriod"),
120
+                (String) req.get("reconciliationType"),
121
+                req.get("operatorId") != null ? Long.parseLong(String.valueOf(req.get("operatorId"))) : 1L,
122
+                (String) req.getOrDefault("operatorName", "系统")
123
+        ));
124
+    }
125
+
126
+    @Operation(summary = "审核对账单")
127
+    @PostMapping("/reconciliation/{id}/audit")
128
+    public R<Reconciliation> auditReconciliation(
129
+            @PathVariable Long id,
130
+            @RequestBody Map<String, Object> req) {
131
+        return R.ok(accountService.auditReconciliation(
132
+                id,
133
+                Boolean.parseBoolean(String.valueOf(req.get("approved"))),
134
+                req.get("auditorId") != null ? Long.parseLong(String.valueOf(req.get("auditorId"))) : 1L,
135
+                (String) req.getOrDefault("auditorName", "审核员"),
136
+                (String) req.get("remark")
137
+        ));
138
+    }
139
+
140
+    @Operation(summary = "查询对账单列表")
141
+    @GetMapping("/reconciliation/list")
142
+    public R<List<Reconciliation>> listReconciliations(
143
+            @RequestParam(required = false) String billPeriod) {
144
+        return R.ok(accountService.listReconciliations(billPeriod));
145
+    }
146
+
147
+    @Operation(summary = "结转处理")
148
+    @PostMapping("/carry-forward")
149
+    public R<Map<String, Object>> carryForward(@RequestBody Map<String, Object> req) {
150
+        return R.ok(accountService.carryForward(
151
+                (String) req.get("billPeriod"),
152
+                req.get("operatorId") != null ? Long.parseLong(String.valueOf(req.get("operatorId"))) : 1L
153
+        ));
154
+    }
155
+
156
+    @Operation(summary = "应收应付汇总统计")
157
+    @GetMapping("/summary")
158
+    public R<Map<String, Object>> summary(@RequestParam String billPeriod) {
159
+        return R.ok(accountService.summary(billPeriod));
160
+    }
161
+}

+ 106
- 0
wm-revenue/src/main/java/com/water/revenue/controller/CollectionController.java Vedi File

@@ -0,0 +1,106 @@
1
+package com.water.revenue.controller;
2
+
3
+import com.water.common.core.result.R;
4
+import com.water.revenue.entity.CollectionRecord;
5
+import com.water.revenue.entity.Receivable;
6
+import com.water.revenue.service.CollectionService;
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.time.LocalDate;
14
+import java.util.List;
15
+import java.util.Map;
16
+
17
+/**
18
+ * 欠费催缴管理接口
19
+ */
20
+@Tag(name = "欠费催缴管理")
21
+@RestController
22
+@RequestMapping("/api/revenue/collection")
23
+@RequiredArgsConstructor
24
+public class CollectionController {
25
+
26
+    private final CollectionService collectionService;
27
+
28
+    @Operation(summary = "查询欠费列表")
29
+    @GetMapping("/overdue")
30
+    public R<List<Receivable>> queryOverdueAccounts() {
31
+        return R.ok(collectionService.queryOverdueAccounts());
32
+    }
33
+
34
+    @Operation(summary = "查询客户欠费")
35
+    @GetMapping("/overdue/customer/{customerId}")
36
+    public R<List<Receivable>> queryCustomerOverdue(@PathVariable Long customerId) {
37
+        return R.ok(collectionService.queryCustomerOverdue(customerId));
38
+    }
39
+
40
+    @Operation(summary = "创建催缴记录")
41
+    @PostMapping("/record")
42
+    public R<CollectionRecord> createCollection(@RequestBody Map<String, Object> req) {
43
+        CollectionRecord record = collectionService.createCollection(
44
+                Long.parseLong(String.valueOf(req.get("customerId"))),
45
+                (String) req.get("customerName"),
46
+                new BigDecimal(String.valueOf(req.get("overdueAmount"))),
47
+                req.get("overdueDays") != null ? Integer.parseInt(String.valueOf(req.get("overdueDays"))) : null,
48
+                (String) req.get("collectionType"),
49
+                (String) req.get("contactPerson"),
50
+                (String) req.get("contactPhone"),
51
+                (String) req.get("contactEmail"),
52
+                req.get("receivableId") != null ? Long.parseLong(String.valueOf(req.get("receivableId"))) : null,
53
+                req.get("collectorId") != null ? Long.parseLong(String.valueOf(req.get("collectorId"))) : 1L,
54
+                (String) req.getOrDefault("collectorName", "系统"),
55
+                (String) req.get("remark")
56
+        );
57
+        return R.ok(record);
58
+    }
59
+
60
+    @Operation(summary = "发送催缴通知")
61
+    @PostMapping("/notify/{collectionId}")
62
+    public R<Map<String, Object>> sendNotification(@PathVariable Long collectionId) {
63
+        return R.ok(collectionService.sendNotification(collectionId));
64
+    }
65
+
66
+    @Operation(summary = "更新催缴结果")
67
+    @PutMapping("/record/{id}/result")
68
+    public R<CollectionRecord> updateResult(
69
+            @PathVariable Long id,
70
+            @RequestBody Map<String, Object> req) {
71
+        return R.ok(collectionService.updateResult(
72
+                id,
73
+                (String) req.get("collectionResult"),
74
+                req.get("promisedDate") != null ? LocalDate.parse((String) req.get("promisedDate")) : null,
75
+                (String) req.get("remark")
76
+        ));
77
+    }
78
+
79
+    @Operation(summary = "查询客户催缴记录")
80
+    @GetMapping("/record/customer/{customerId}")
81
+    public R<List<CollectionRecord>> listByCustomer(@PathVariable Long customerId) {
82
+        return R.ok(collectionService.listByCustomer(customerId));
83
+    }
84
+
85
+    @Operation(summary = "查询应收催缴记录")
86
+    @GetMapping("/record/receivable/{receivableId}")
87
+    public R<List<CollectionRecord>> listByReceivable(@PathVariable Long receivableId) {
88
+        return R.ok(collectionService.listByReceivable(receivableId));
89
+    }
90
+
91
+    @Operation(summary = "分页查询催缴记录")
92
+    @GetMapping("/record/list")
93
+    public R<List<CollectionRecord>> listRecords(
94
+            @RequestParam(required = false) String status,
95
+            @RequestParam(required = false) String collectionType,
96
+            @RequestParam(defaultValue = "1") int page,
97
+            @RequestParam(defaultValue = "20") int size) {
98
+        return R.ok(collectionService.listRecords(status, collectionType, page, size));
99
+    }
100
+
101
+    @Operation(summary = "催缴统计")
102
+    @GetMapping("/statistics")
103
+    public R<Map<String, Object>> statistics(@RequestParam(defaultValue = "30") Integer days) {
104
+        return R.ok(collectionService.statistics(days));
105
+    }
106
+}

+ 103
- 0
wm-revenue/src/main/java/com/water/revenue/controller/InvoiceController.java Vedi File

@@ -0,0 +1,103 @@
1
+package com.water.revenue.controller;
2
+
3
+import com.water.common.core.result.R;
4
+import com.water.revenue.entity.Invoice;
5
+import com.water.revenue.service.InvoiceService;
6
+import io.swagger.v3.oas.annotations.Operation;
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.List;
13
+import java.util.Map;
14
+
15
+/**
16
+ * 电子发票管理接口
17
+ */
18
+@Tag(name = "电子发票管理")
19
+@RestController
20
+@RequestMapping("/api/revenue/invoice")
21
+@RequiredArgsConstructor
22
+public class InvoiceController {
23
+
24
+    private final InvoiceService invoiceService;
25
+
26
+    @Operation(summary = "开具发票")
27
+    @PostMapping("/issue")
28
+    public R<Invoice> issueInvoice(@RequestBody Map<String, Object> req) {
29
+        Invoice invoice = invoiceService.issueInvoice(
30
+                Long.parseLong(String.valueOf(req.get("customerId"))),
31
+                (String) req.get("customerName"),
32
+                (String) req.get("customerTaxNo"),
33
+                new BigDecimal(String.valueOf(req.get("amount"))),
34
+                req.get("taxRate") != null ? new BigDecimal(String.valueOf(req.get("taxRate"))) : BigDecimal.valueOf(6),
35
+                (String) req.get("invoiceType"),
36
+                (String) req.get("content"),
37
+                req.get("billId") != null ? Long.parseLong(String.valueOf(req.get("billId"))) : null,
38
+                (String) req.get("email"),
39
+                (String) req.get("phone"),
40
+                req.get("operatorId") != null ? Long.parseLong(String.valueOf(req.get("operatorId"))) : 1L,
41
+                (String) req.getOrDefault("operatorName", "系统")
42
+        );
43
+        return R.ok(invoice);
44
+    }
45
+
46
+    @Operation(summary = "按发票号查询")
47
+    @GetMapping("/{invoiceNo}")
48
+    public R<Invoice> getByInvoiceNo(@PathVariable String invoiceNo) {
49
+        return R.ok(invoiceService.getByInvoiceNo(invoiceNo));
50
+    }
51
+
52
+    @Operation(summary = "查询客户发票列表")
53
+    @GetMapping("/customer/{customerId}")
54
+    public R<List<Invoice>> listByCustomer(@PathVariable Long customerId) {
55
+        return R.ok(invoiceService.listByCustomerId(customerId));
56
+    }
57
+
58
+    @Operation(summary = "按账单ID查询发票")
59
+    @GetMapping("/bill/{billId}")
60
+    public R<List<Invoice>> listByBill(@PathVariable Long billId) {
61
+        return R.ok(invoiceService.listByBillId(billId));
62
+    }
63
+
64
+    @Operation(summary = "分页查询发票")
65
+    @GetMapping("/list")
66
+    public R<List<Invoice>> list(
67
+            @RequestParam(required = false) String status,
68
+            @RequestParam(required = false) String invoiceType,
69
+            @RequestParam(defaultValue = "1") int page,
70
+            @RequestParam(defaultValue = "20") int size) {
71
+        return R.ok(invoiceService.listInvoices(status, invoiceType, page, size));
72
+    }
73
+
74
+    @Operation(summary = "推送发票")
75
+    @PostMapping("/push/{invoiceId}")
76
+    public R<Map<String, Object>> pushInvoice(
77
+            @PathVariable Long invoiceId,
78
+            @RequestParam(defaultValue = "email") String pushType) {
79
+        return R.ok(invoiceService.pushInvoice(invoiceId, pushType));
80
+    }
81
+
82
+    @Operation(summary = "红冲/作废发票")
83
+    @PostMapping("/red-flush/{invoiceId}")
84
+    public R<Invoice> redFlush(
85
+            @PathVariable Long invoiceId,
86
+            @RequestBody Map<String, Object> req) {
87
+        Invoice redInvoice = invoiceService.redFlush(
88
+                invoiceId,
89
+                (String) req.get("reason"),
90
+                req.get("operatorId") != null ? Long.parseLong(String.valueOf(req.get("operatorId"))) : 1L,
91
+                (String) req.getOrDefault("operatorName", "系统")
92
+        );
93
+        return R.ok(redInvoice);
94
+    }
95
+
96
+    @Operation(summary = "发票统计")
97
+    @GetMapping("/statistics")
98
+    public R<Map<String, Object>> statistics(
99
+            @RequestParam(required = false) String startDate,
100
+            @RequestParam(required = false) String endDate) {
101
+        return R.ok(invoiceService.statistics(startDate, endDate));
102
+    }
103
+}

+ 77
- 0
wm-revenue/src/main/java/com/water/revenue/entity/CollectionRecord.java Vedi File

@@ -0,0 +1,77 @@
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
+/**
11
+ * 欠费催缴记录实体
12
+ */
13
+@Data
14
+@TableName("rev_collection_record")
15
+public class CollectionRecord {
16
+
17
+    @TableId(type = IdType.AUTO)
18
+    private Long id;
19
+
20
+    /** 催缴单号 */
21
+    private String collectionNo;
22
+
23
+    /** 客户ID */
24
+    private Long customerId;
25
+
26
+    /** 客户名称 */
27
+    private String customerName;
28
+
29
+    /** 欠费金额 */
30
+    private BigDecimal overdueAmount;
31
+
32
+    /** 逾期天数 */
33
+    private Integer overdueDays;
34
+
35
+    /** 催缴类型:sms-短信 phone-电话 visit-上门 letter-催缴函 */
36
+    private String collectionType;
37
+
38
+    /** 催缴结果:success-成功(已缴费) promised-承诺缴费 failed-失败 unreachable-无法联系 refused-拒缴 */
39
+    private String collectionResult;
40
+
41
+    /** 联系人 */
42
+    private String contactPerson;
43
+
44
+    /** 联系电话 */
45
+    private String contactPhone;
46
+
47
+    /** 联系邮箱 */
48
+    private String contactEmail;
49
+
50
+    /** 催缴时间 */
51
+    private LocalDateTime collectionTime;
52
+
53
+    /** 承诺缴费日期 */
54
+    private LocalDate promisedDate;
55
+
56
+    /** 催缴人ID */
57
+    private Long collectorId;
58
+
59
+    /** 催缴人名称 */
60
+    private String collectorName;
61
+
62
+    /** 应收单ID(关联应收账款) */
63
+    private Long receivableId;
64
+
65
+    /** 催缴备注 */
66
+    private String remark;
67
+
68
+    /** 状态:pending-待处理 in_progress-处理中 completed-已完成 cancelled-已取消 */
69
+    private String status;
70
+
71
+    @TableLogic
72
+    private Integer deleted;
73
+
74
+    private LocalDateTime createdAt;
75
+
76
+    private LocalDateTime updatedAt;
77
+}

+ 92
- 0
wm-revenue/src/main/java/com/water/revenue/entity/Invoice.java Vedi File

@@ -0,0 +1,92 @@
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
+/**
11
+ * 电子发票实体
12
+ */
13
+@Data
14
+@TableName("rev_invoice")
15
+public class Invoice {
16
+
17
+    @TableId(type = IdType.AUTO)
18
+    private Long id;
19
+
20
+    /** 发票号码 */
21
+    private String invoiceNo;
22
+
23
+    /** 发票类型:normal-普通发票 special-专用发票 electronic-电子发票 */
24
+    private String invoiceType;
25
+
26
+    /** 客户ID */
27
+    private Long customerId;
28
+
29
+    /** 客户名称 */
30
+    private String customerName;
31
+
32
+    /** 客户税号 */
33
+    private String customerTaxNo;
34
+
35
+    /** 不含税金额 */
36
+    private BigDecimal amount;
37
+
38
+    /** 税率 */
39
+    private BigDecimal taxRate;
40
+
41
+    /** 税额 */
42
+    private BigDecimal taxAmount;
43
+
44
+    /** 含税总额 */
45
+    private BigDecimal totalAmount;
46
+
47
+    /** 发票内容描述 */
48
+    private String content;
49
+
50
+    /** 账单ID(关联水费账单) */
51
+    private Long billId;
52
+
53
+    /** 状态:draft-草稿 issued-已开具 sent-已推送 cancelled-已作废 red-已红冲 */
54
+    private String status;
55
+
56
+    /** 开票日期 */
57
+    private LocalDate issueDate;
58
+
59
+    /** 推送状态:none-未推送 email-邮件推送 sms-短信推送 both-邮件+短信 */
60
+    private String pushStatus;
61
+
62
+    /** 推送时间 */
63
+    private LocalDateTime pushTime;
64
+
65
+    /** 接收邮箱 */
66
+    private String email;
67
+
68
+    /** 接收手机号 */
69
+    private String phone;
70
+
71
+    /** 红冲原因 */
72
+    private String cancelReason;
73
+
74
+    /** 原发票ID(红冲时关联) */
75
+    private Long originalInvoiceId;
76
+
77
+    /** 操作人ID */
78
+    private Long operatorId;
79
+
80
+    /** 操作人名称 */
81
+    private String operatorName;
82
+
83
+    /** 备注 */
84
+    private String remark;
85
+
86
+    @TableLogic
87
+    private Integer deleted;
88
+
89
+    private LocalDateTime createdAt;
90
+
91
+    private LocalDateTime updatedAt;
92
+}

+ 68
- 0
wm-revenue/src/main/java/com/water/revenue/entity/Payable.java Vedi File

@@ -0,0 +1,68 @@
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
+/**
11
+ * 应付账款实体
12
+ */
13
+@Data
14
+@TableName("rev_payable")
15
+public class Payable {
16
+
17
+    @TableId(type = IdType.AUTO)
18
+    private Long id;
19
+
20
+    /** 应付单号 */
21
+    private String payableNo;
22
+
23
+    /** 账期(如 2026-06) */
24
+    private String billPeriod;
25
+
26
+    /** 供应商ID */
27
+    private Long supplierId;
28
+
29
+    /** 供应商名称 */
30
+    private String supplierName;
31
+
32
+    /** 应付金额 */
33
+    private BigDecimal amount;
34
+
35
+    /** 已付金额 */
36
+    private BigDecimal paidAmount;
37
+
38
+    /** 未付金额 */
39
+    private BigDecimal unpaidAmount;
40
+
41
+    /** 状态:pending-待付款 partial-部分付款 completed-已付清 cancelled-已取消 */
42
+    private String status;
43
+
44
+    /** 费用类型:electricity-电费 chemical-药剂费 maintenance-维修费 equipment-设备费 other-其他 */
45
+    private String expenseType;
46
+
47
+    /** 到期日 */
48
+    private LocalDate dueDate;
49
+
50
+    /** 发票号 */
51
+    private String invoiceNo;
52
+
53
+    /** 合同编号 */
54
+    private String contractNo;
55
+
56
+    /** 操作人ID */
57
+    private Long operatorId;
58
+
59
+    /** 备注 */
60
+    private String remark;
61
+
62
+    @TableLogic
63
+    private Integer deleted;
64
+
65
+    private LocalDateTime createdAt;
66
+
67
+    private LocalDateTime updatedAt;
68
+}

+ 71
- 0
wm-revenue/src/main/java/com/water/revenue/entity/Receivable.java Vedi File

@@ -0,0 +1,71 @@
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
+/**
11
+ * 应收账款实体
12
+ */
13
+@Data
14
+@TableName("rev_receivable")
15
+public class Receivable {
16
+
17
+    @TableId(type = IdType.AUTO)
18
+    private Long id;
19
+
20
+    /** 应收单号 */
21
+    private String receivableNo;
22
+
23
+    /** 账期(如 2026-06) */
24
+    private String billPeriod;
25
+
26
+    /** 客户ID */
27
+    private Long customerId;
28
+
29
+    /** 客户名称 */
30
+    private String customerName;
31
+
32
+    /** 应收金额 */
33
+    private BigDecimal amount;
34
+
35
+    /** 已收金额 */
36
+    private BigDecimal receivedAmount;
37
+
38
+    /** 未收金额 */
39
+    private BigDecimal unreceivedAmount;
40
+
41
+    /** 状态:pending-待收款 partial-部分收款 completed-已收清 overdue-逾期 cancelled-已取消 */
42
+    private String status;
43
+
44
+    /** 账单ID(关联水费账单) */
45
+    private Long billId;
46
+
47
+    /** 到期日 */
48
+    private LocalDate dueDate;
49
+
50
+    /** 逾期天数 */
51
+    private Integer overdueDays;
52
+
53
+    /** 催缴次数 */
54
+    private Integer collectionCount;
55
+
56
+    /** 最后催缴时间 */
57
+    private LocalDateTime lastCollectionTime;
58
+
59
+    /** 操作人ID */
60
+    private Long operatorId;
61
+
62
+    /** 备注 */
63
+    private String remark;
64
+
65
+    @TableLogic
66
+    private Integer deleted;
67
+
68
+    private LocalDateTime createdAt;
69
+
70
+    private LocalDateTime updatedAt;
71
+}

+ 83
- 0
wm-revenue/src/main/java/com/water/revenue/entity/Reconciliation.java Vedi File

@@ -0,0 +1,83 @@
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
+/**
11
+ * 对账记录实体
12
+ */
13
+@Data
14
+@TableName("rev_reconciliation")
15
+public class Reconciliation {
16
+
17
+    @TableId(type = IdType.AUTO)
18
+    private Long id;
19
+
20
+    /** 对账单号 */
21
+    private String reconciliationNo;
22
+
23
+    /** 对账账期(如 2026-06) */
24
+    private String billPeriod;
25
+
26
+    /** 对账类型:receivable-应收对账 payable-应付对账 all-综合对账 */
27
+    private String reconciliationType;
28
+
29
+    /** 应收总额 */
30
+    private BigDecimal totalReceivable;
31
+
32
+    /** 实收总额 */
33
+    private BigDecimal totalReceived;
34
+
35
+    /** 应付总额 */
36
+    private BigDecimal totalPayable;
37
+
38
+    /** 实付总额 */
39
+    private BigDecimal totalPaid;
40
+
41
+    /** 差额 */
42
+    private BigDecimal difference;
43
+
44
+    /** 状态:draft-草稿 pending-待审核 approved-已审核 rejected-已驳回 completed-已完成 */
45
+    private String status;
46
+
47
+    /** 对账开始日期 */
48
+    private LocalDate startDate;
49
+
50
+    /** 对账结束日期 */
51
+    private LocalDate endDate;
52
+
53
+    /** 异常笔数 */
54
+    private Integer exceptionCount;
55
+
56
+    /** 异常说明 */
57
+    private String exceptionNote;
58
+
59
+    /** 审核人ID */
60
+    private Long auditorId;
61
+
62
+    /** 审核人名称 */
63
+    private String auditorName;
64
+
65
+    /** 审核时间 */
66
+    private LocalDateTime auditTime;
67
+
68
+    /** 操作人ID */
69
+    private Long operatorId;
70
+
71
+    /** 操作人名称 */
72
+    private String operatorName;
73
+
74
+    /** 备注 */
75
+    private String remark;
76
+
77
+    @TableLogic
78
+    private Integer deleted;
79
+
80
+    private LocalDateTime createdAt;
81
+
82
+    private LocalDateTime updatedAt;
83
+}

+ 27
- 0
wm-revenue/src/main/java/com/water/revenue/mapper/CollectionRecordMapper.java Vedi File

@@ -0,0 +1,27 @@
1
+package com.water.revenue.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.revenue.entity.CollectionRecord;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Param;
7
+import org.apache.ibatis.annotations.Select;
8
+import org.apache.ibatis.annotations.Update;
9
+
10
+import java.util.List;
11
+import java.util.Map;
12
+
13
+@Mapper
14
+public interface CollectionRecordMapper extends BaseMapper<CollectionRecord> {
15
+
16
+    @Select("SELECT * FROM rev_collection_record WHERE customer_id = #{customerId} ORDER BY collection_time DESC")
17
+    List<CollectionRecord> selectByCustomerId(@Param("customerId") Long customerId);
18
+
19
+    @Select("SELECT * FROM rev_collection_record WHERE receivable_id = #{receivableId} ORDER BY collection_time DESC")
20
+    List<CollectionRecord> selectByReceivableId(@Param("receivableId") Long receivableId);
21
+
22
+    @Select("SELECT collection_type, COUNT(*) as count, SUM(CASE WHEN collection_result = 'success' THEN 1 ELSE 0 END) as success_count FROM rev_collection_record WHERE collection_time >= #{startTime} GROUP BY collection_type")
23
+    List<Map<String, Object>> selectStatistics(@Param("startTime") java.time.LocalDateTime startTime);
24
+
25
+    @Update("UPDATE rev_collection_record SET status = #{status}, collection_result = #{result} WHERE id = #{id}")
26
+    int updateResult(@Param("id") Long id, @Param("status") String status, @Param("result") String result);
27
+}

+ 26
- 0
wm-revenue/src/main/java/com/water/revenue/mapper/InvoiceMapper.java Vedi File

@@ -0,0 +1,26 @@
1
+package com.water.revenue.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.revenue.entity.Invoice;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Param;
7
+import org.apache.ibatis.annotations.Select;
8
+import org.apache.ibatis.annotations.Update;
9
+
10
+import java.util.List;
11
+
12
+@Mapper
13
+public interface InvoiceMapper extends BaseMapper<Invoice> {
14
+
15
+    @Select("SELECT * FROM rev_invoice WHERE customer_id = #{customerId} AND status != 'cancelled' ORDER BY created_at DESC")
16
+    List<Invoice> selectByCustomerId(@Param("customerId") Long customerId);
17
+
18
+    @Select("SELECT * FROM rev_invoice WHERE invoice_no = #{invoiceNo} LIMIT 1")
19
+    Invoice selectByInvoiceNo(@Param("invoiceNo") String invoiceNo);
20
+
21
+    @Select("SELECT * FROM rev_invoice WHERE bill_id = #{billId} AND status NOT IN ('cancelled', 'red') ORDER BY created_at DESC")
22
+    List<Invoice> selectByBillId(@Param("billId") Long billId);
23
+
24
+    @Update("UPDATE rev_invoice SET status = #{status}, push_status = #{pushStatus}, push_time = NOW() WHERE id = #{id}")
25
+    int updatePushStatus(@Param("id") Long id, @Param("status") String status, @Param("pushStatus") String pushStatus);
26
+}

+ 24
- 0
wm-revenue/src/main/java/com/water/revenue/mapper/PayableMapper.java Vedi File

@@ -0,0 +1,24 @@
1
+package com.water.revenue.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.revenue.entity.Payable;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Param;
7
+import org.apache.ibatis.annotations.Select;
8
+import org.apache.ibatis.annotations.Update;
9
+
10
+import java.util.List;
11
+
12
+@Mapper
13
+public interface PayableMapper extends BaseMapper<Payable> {
14
+
15
+    @Select("SELECT * FROM rev_payable WHERE supplier_id = #{supplierId} AND status NOT IN ('completed', 'cancelled') ORDER BY due_date ASC")
16
+    List<Payable> selectBySupplierId(@Param("supplierId") Long supplierId);
17
+
18
+    @Select("SELECT * FROM rev_payable WHERE bill_period = #{billPeriod} AND status NOT IN ('completed', 'cancelled') ORDER BY supplier_id")
19
+    List<Payable> selectByBillPeriod(@Param("billPeriod") String billPeriod);
20
+
21
+    @Update("UPDATE rev_payable SET paid_amount = #{paidAmount}, unpaid_amount = #{unpaidAmount}, status = #{status} WHERE id = #{id}")
22
+    int updateAmount(@Param("id") Long id, @Param("paidAmount") java.math.BigDecimal paidAmount,
23
+                     @Param("unpaidAmount") java.math.BigDecimal unpaidAmount, @Param("status") String status);
24
+}

+ 27
- 0
wm-revenue/src/main/java/com/water/revenue/mapper/ReceivableMapper.java Vedi File

@@ -0,0 +1,27 @@
1
+package com.water.revenue.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.revenue.entity.Receivable;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Param;
7
+import org.apache.ibatis.annotations.Select;
8
+import org.apache.ibatis.annotations.Update;
9
+
10
+import java.util.List;
11
+
12
+@Mapper
13
+public interface ReceivableMapper extends BaseMapper<Receivable> {
14
+
15
+    @Select("SELECT * FROM rev_receivable WHERE customer_id = #{customerId} AND status NOT IN ('completed', 'cancelled') ORDER BY due_date ASC")
16
+    List<Receivable> selectOverdueByCustomerId(@Param("customerId") Long customerId);
17
+
18
+    @Select("SELECT * FROM rev_receivable WHERE bill_period = #{billPeriod} AND status NOT IN ('completed', 'cancelled') ORDER BY customer_id")
19
+    List<Receivable> selectByBillPeriod(@Param("billPeriod") String billPeriod);
20
+
21
+    @Select("SELECT * FROM rev_receivable WHERE status = 'overdue' ORDER BY overdue_days DESC")
22
+    List<Receivable> selectAllOverdue();
23
+
24
+    @Update("UPDATE rev_receivable SET received_amount = #{receivedAmount}, unreceived_amount = #{unreceivedAmount}, status = #{status} WHERE id = #{id}")
25
+    int updateAmount(@Param("id") Long id, @Param("receivedAmount") java.math.BigDecimal receivedAmount,
26
+                     @Param("unreceivedAmount") java.math.BigDecimal unreceivedAmount, @Param("status") String status);
27
+}

+ 24
- 0
wm-revenue/src/main/java/com/water/revenue/mapper/ReconciliationMapper.java Vedi File

@@ -0,0 +1,24 @@
1
+package com.water.revenue.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.revenue.entity.Reconciliation;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Param;
7
+import org.apache.ibatis.annotations.Select;
8
+import org.apache.ibatis.annotations.Update;
9
+
10
+import java.util.List;
11
+
12
+@Mapper
13
+public interface ReconciliationMapper extends BaseMapper<Reconciliation> {
14
+
15
+    @Select("SELECT * FROM rev_reconciliation WHERE bill_period = #{billPeriod} ORDER BY created_at DESC")
16
+    List<Reconciliation> selectByBillPeriod(@Param("billPeriod") String billPeriod);
17
+
18
+    @Select("SELECT * FROM rev_reconciliation WHERE reconciliation_no = #{reconciliationNo} LIMIT 1")
19
+    Reconciliation selectByReconciliationNo(@Param("reconciliationNo") String reconciliationNo);
20
+
21
+    @Update("UPDATE rev_reconciliation SET status = #{status}, auditor_id = #{auditorId}, auditor_name = #{auditorName}, audit_time = NOW() WHERE id = #{id}")
22
+    int updateAuditStatus(@Param("id") Long id, @Param("status") String status,
23
+                          @Param("auditorId") Long auditorId, @Param("auditorName") String auditorName);
24
+}

+ 397
- 0
wm-revenue/src/main/java/com/water/revenue/service/AccountService.java Vedi File

@@ -0,0 +1,397 @@
1
+package com.water.revenue.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.water.common.core.exception.BusinessException;
5
+import com.water.revenue.entity.Payable;
6
+import com.water.revenue.entity.Receivable;
7
+import com.water.revenue.entity.Reconciliation;
8
+import com.water.revenue.mapper.PayableMapper;
9
+import com.water.revenue.mapper.ReceivableMapper;
10
+import com.water.revenue.mapper.ReconciliationMapper;
11
+import lombok.RequiredArgsConstructor;
12
+import lombok.extern.slf4j.Slf4j;
13
+import org.springframework.stereotype.Service;
14
+import org.springframework.transaction.annotation.Transactional;
15
+
16
+import java.math.BigDecimal;
17
+import java.time.LocalDate;
18
+import java.time.LocalDateTime;
19
+import java.time.format.DateTimeFormatter;
20
+import java.util.*;
21
+
22
+/**
23
+ * 应收应付账务服务
24
+ */
25
+@Slf4j
26
+@Service
27
+@RequiredArgsConstructor
28
+public class AccountService {
29
+
30
+    private final ReceivableMapper receivableMapper;
31
+    private final PayableMapper payableMapper;
32
+    private final ReconciliationMapper reconciliationMapper;
33
+
34
+    // ==================== 应收账款管理 ====================
35
+
36
+    /**
37
+     * 创建应收账款
38
+     */
39
+    @Transactional
40
+    public Receivable createReceivable(String billPeriod, Long customerId, String customerName,
41
+                                        BigDecimal amount, Long billId, LocalDate dueDate,
42
+                                        Long operatorId, String remark) {
43
+        if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
44
+            throw new BusinessException("应收金额必须大于0");
45
+        }
46
+
47
+        Receivable receivable = new Receivable();
48
+        receivable.setReceivableNo(generateNo("RCV"));
49
+        receivable.setBillPeriod(billPeriod);
50
+        receivable.setCustomerId(customerId);
51
+        receivable.setCustomerName(customerName);
52
+        receivable.setAmount(amount);
53
+        receivable.setReceivedAmount(BigDecimal.ZERO);
54
+        receivable.setUnreceivedAmount(amount);
55
+        receivable.setStatus("pending");
56
+        receivable.setBillId(billId);
57
+        receivable.setDueDate(dueDate);
58
+        receivable.setOverdueDays(0);
59
+        receivable.setCollectionCount(0);
60
+        receivable.setOperatorId(operatorId);
61
+        receivable.setRemark(remark);
62
+        receivable.setDeleted(0);
63
+        receivable.setCreatedAt(LocalDateTime.now());
64
+        receivable.setUpdatedAt(LocalDateTime.now());
65
+
66
+        receivableMapper.insert(receivable);
67
+        log.info("Receivable created: {} amount={}", receivable.getReceivableNo(), amount);
68
+        return receivable;
69
+    }
70
+
71
+    /**
72
+     * 应收账款收款(登记收款)
73
+     */
74
+    @Transactional
75
+    public Receivable receivePayment(Long receivableId, BigDecimal receiveAmount) {
76
+        Receivable receivable = receivableMapper.selectById(receivableId);
77
+        if (receivable == null) {
78
+            throw new BusinessException("应收账款不存在");
79
+        }
80
+        if (receiveAmount == null || receiveAmount.compareTo(BigDecimal.ZERO) <= 0) {
81
+            throw new BusinessException("收款金额必须大于0");
82
+        }
83
+        if (receiveAmount.compareTo(receivable.getUnreceivedAmount()) > 0) {
84
+            throw new BusinessException("收款金额不能超过未收金额: " + receivable.getUnreceivedAmount());
85
+        }
86
+
87
+        BigDecimal newReceived = receivable.getReceivedAmount().add(receiveAmount);
88
+        BigDecimal newUnreceived = receivable.getUnreceivedAmount().subtract(receiveAmount);
89
+        String newStatus = newUnreceived.compareTo(BigDecimal.ZERO) <= 0 ? "completed" : "partial";
90
+
91
+        receivableMapper.updateAmount(receivableId, newReceived, newUnreceived, newStatus);
92
+        receivable.setReceivedAmount(newReceived);
93
+        receivable.setUnreceivedAmount(newUnreceived);
94
+        receivable.setStatus(newStatus);
95
+
96
+        log.info("Receivable payment received: {} amount={} status={}", receivable.getReceivableNo(), receiveAmount, newStatus);
97
+        return receivable;
98
+    }
99
+
100
+    /**
101
+     * 查询客户应收列表
102
+     */
103
+    public List<Receivable> listReceivableByCustomer(Long customerId) {
104
+        return receivableMapper.selectOverdueByCustomerId(customerId);
105
+    }
106
+
107
+    /**
108
+     * 按账期查询应收
109
+     */
110
+    public List<Receivable> listReceivableByPeriod(String billPeriod) {
111
+        return receivableMapper.selectByBillPeriod(billPeriod);
112
+    }
113
+
114
+    /**
115
+     * 查询逾期应收
116
+     */
117
+    public List<Receivable> listOverdueReceivables() {
118
+        return receivableMapper.selectAllOverdue();
119
+    }
120
+
121
+    // ==================== 应付账款管理 ====================
122
+
123
+    /**
124
+     * 创建应付账款
125
+     */
126
+    @Transactional
127
+    public Payable createPayable(String billPeriod, Long supplierId, String supplierName,
128
+                                  BigDecimal amount, String expenseType, LocalDate dueDate,
129
+                                  String invoiceNo, String contractNo, Long operatorId, String remark) {
130
+        if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
131
+            throw new BusinessException("应付金额必须大于0");
132
+        }
133
+
134
+        Payable payable = new Payable();
135
+        payable.setPayableNo(generateNo("PAY"));
136
+        payable.setBillPeriod(billPeriod);
137
+        payable.setSupplierId(supplierId);
138
+        payable.setSupplierName(supplierName);
139
+        payable.setAmount(amount);
140
+        payable.setPaidAmount(BigDecimal.ZERO);
141
+        payable.setUnpaidAmount(amount);
142
+        payable.setStatus("pending");
143
+        payable.setExpenseType(expenseType);
144
+        payable.setDueDate(dueDate);
145
+        payable.setInvoiceNo(invoiceNo);
146
+        payable.setContractNo(contractNo);
147
+        payable.setOperatorId(operatorId);
148
+        payable.setRemark(remark);
149
+        payable.setDeleted(0);
150
+        payable.setCreatedAt(LocalDateTime.now());
151
+        payable.setUpdatedAt(LocalDateTime.now());
152
+
153
+        payableMapper.insert(payable);
154
+        log.info("Payable created: {} amount={}", payable.getPayableNo(), amount);
155
+        return payable;
156
+    }
157
+
158
+    /**
159
+     * 应付账款付款(登记付款)
160
+     */
161
+    @Transactional
162
+    public Payable makePayment(Long payableId, BigDecimal payAmount) {
163
+        Payable payable = payableMapper.selectById(payableId);
164
+        if (payable == null) {
165
+            throw new BusinessException("应付账款不存在");
166
+        }
167
+        if (payAmount == null || payAmount.compareTo(BigDecimal.ZERO) <= 0) {
168
+            throw new BusinessException("付款金额必须大于0");
169
+        }
170
+        if (payAmount.compareTo(payable.getUnpaidAmount()) > 0) {
171
+            throw new BusinessException("付款金额不能超过未付金额: " + payable.getUnpaidAmount());
172
+        }
173
+
174
+        BigDecimal newPaid = payable.getPaidAmount().add(payAmount);
175
+        BigDecimal newUnpaid = payable.getUnpaidAmount().subtract(payAmount);
176
+        String newStatus = newUnpaid.compareTo(BigDecimal.ZERO) <= 0 ? "completed" : "partial";
177
+
178
+        payableMapper.updateAmount(payableId, newPaid, newUnpaid, newStatus);
179
+        payable.setPaidAmount(newPaid);
180
+        payable.setUnpaidAmount(newUnpaid);
181
+        payable.setStatus(newStatus);
182
+
183
+        log.info("Payable payment made: {} amount={} status={}", payable.getPayableNo(), payAmount, newStatus);
184
+        return payable;
185
+    }
186
+
187
+    /**
188
+     * 按供应商查询应付
189
+     */
190
+    public List<Payable> listPayableBySupplier(Long supplierId) {
191
+        return payableMapper.selectBySupplierId(supplierId);
192
+    }
193
+
194
+    /**
195
+     * 按账期查询应付
196
+     */
197
+    public List<Payable> listPayableByPeriod(String billPeriod) {
198
+        return payableMapper.selectByBillPeriod(billPeriod);
199
+    }
200
+
201
+    // ==================== 月度对账 ====================
202
+
203
+    /**
204
+     * 创建月度对账
205
+     */
206
+    @Transactional
207
+    public Reconciliation createReconciliation(String billPeriod, String reconciliationType,
208
+                                                Long operatorId, String operatorName) {
209
+        Reconciliation reconciliation = new Reconciliation();
210
+        reconciliation.setReconciliationNo(generateNo("REC"));
211
+        reconciliation.setBillPeriod(billPeriod);
212
+        reconciliation.setReconciliationType(reconciliationType != null ? reconciliationType : "all");
213
+        reconciliation.setStatus("draft");
214
+
215
+        // 汇总应收
216
+        List<Receivable> receivables = receivableMapper.selectByBillPeriod(billPeriod);
217
+        BigDecimal totalReceivable = BigDecimal.ZERO;
218
+        BigDecimal totalReceived = BigDecimal.ZERO;
219
+        for (Receivable r : receivables) {
220
+            totalReceivable = totalReceivable.add(r.getAmount());
221
+            totalReceived = totalReceived.add(r.getReceivedAmount());
222
+        }
223
+        reconciliation.setTotalReceivable(totalReceivable);
224
+        reconciliation.setTotalReceived(totalReceived);
225
+
226
+        // 汇总应付
227
+        List<Payable> payables = payableMapper.selectByBillPeriod(billPeriod);
228
+        BigDecimal totalPayable = BigDecimal.ZERO;
229
+        BigDecimal totalPaid = BigDecimal.ZERO;
230
+        for (Payable p : payables) {
231
+            totalPayable = totalPayable.add(p.getAmount());
232
+            totalPaid = totalPaid.add(p.getPaidAmount());
233
+        }
234
+        reconciliation.setTotalPayable(totalPayable);
235
+        reconciliation.setTotalPaid(totalPaid);
236
+
237
+        // 差额 = (应收-实收) - (应付-实付)
238
+        BigDecimal difference = totalReceivable.subtract(totalReceived)
239
+                .subtract(totalPayable.subtract(totalPaid));
240
+        reconciliation.setDifference(difference);
241
+
242
+        reconciliation.setStartDate(LocalDate.parse(billPeriod + "-01"));
243
+        reconciliation.setEndDate(LocalDate.parse(billPeriod + "-01").plusMonths(1).minusDays(1));
244
+        reconciliation.setExceptionCount(0);
245
+        reconciliation.setOperatorId(operatorId);
246
+        reconciliation.setOperatorName(operatorName);
247
+        reconciliation.setDeleted(0);
248
+        reconciliation.setCreatedAt(LocalDateTime.now());
249
+        reconciliation.setUpdatedAt(LocalDateTime.now());
250
+
251
+        reconciliationMapper.insert(reconciliation);
252
+        log.info("Reconciliation created: {} period={}", reconciliation.getReconciliationNo(), billPeriod);
253
+        return reconciliation;
254
+    }
255
+
256
+    /**
257
+     * 审核对账单
258
+     */
259
+    @Transactional
260
+    public Reconciliation auditReconciliation(Long reconciliationId, boolean approved,
261
+                                               Long auditorId, String auditorName, String remark) {
262
+        Reconciliation reconciliation = reconciliationMapper.selectById(reconciliationId);
263
+        if (reconciliation == null) {
264
+            throw new BusinessException("对账单不存在");
265
+        }
266
+        if (!"pending".equals(reconciliation.getStatus()) && !"draft".equals(reconciliation.getStatus())) {
267
+            throw new BusinessException("对账单当前状态不允许审核: " + reconciliation.getStatus());
268
+        }
269
+
270
+        String newStatus = approved ? "approved" : "rejected";
271
+        reconciliationMapper.updateAuditStatus(reconciliationId, newStatus, auditorId, auditorName);
272
+        reconciliation.setStatus(newStatus);
273
+        reconciliation.setAuditorId(auditorId);
274
+        reconciliation.setAuditorName(auditorName);
275
+
276
+        if (remark != null) {
277
+            reconciliation.setRemark(remark);
278
+            reconciliation.setUpdatedAt(LocalDateTime.now());
279
+            reconciliationMapper.updateById(reconciliation);
280
+        }
281
+
282
+        log.info("Reconciliation audited: {} result={}", reconciliation.getReconciliationNo(), newStatus);
283
+        return reconciliation;
284
+    }
285
+
286
+    /**
287
+     * 查询对账单列表
288
+     */
289
+    public List<Reconciliation> listReconciliations(String billPeriod) {
290
+        if (billPeriod != null) {
291
+            return reconciliationMapper.selectByBillPeriod(billPeriod);
292
+        }
293
+        LambdaQueryWrapper<Reconciliation> wrapper = new LambdaQueryWrapper<>();
294
+        wrapper.orderByDesc(Reconciliation::getCreatedAt);
295
+        return reconciliationMapper.selectList(wrapper);
296
+    }
297
+
298
+    // ==================== 结转处理 ====================
299
+
300
+    /**
301
+     * 结转处理 - 将未结清的应收/应付结转到下一账期
302
+     */
303
+    @Transactional
304
+    public Map<String, Object> carryForward(String billPeriod, Long operatorId) {
305
+        String nextPeriod = calculateNextPeriod(billPeriod);
306
+
307
+        // 结转未结清的应收
308
+        List<Receivable> receivables = receivableMapper.selectByBillPeriod(billPeriod);
309
+        int carriedReceivable = 0;
310
+        for (Receivable r : receivables) {
311
+            if ("pending".equals(r.getStatus()) || "partial".equals(r.getStatus()) || "overdue".equals(r.getStatus())) {
312
+                createReceivable(nextPeriod, r.getCustomerId(), r.getCustomerName(),
313
+                        r.getUnreceivedAmount(), r.getBillId(),
314
+                        r.getDueDate() != null ? r.getDueDate().plusMonths(1) : null,
315
+                        operatorId, "从" + billPeriod + "结转");
316
+                carriedReceivable++;
317
+            }
318
+        }
319
+
320
+        // 结转未结清的应付
321
+        List<Payable> payables = payableMapper.selectByBillPeriod(billPeriod);
322
+        int carriedPayable = 0;
323
+        for (Payable p : payables) {
324
+            if ("pending".equals(p.getStatus()) || "partial".equals(p.getStatus())) {
325
+                createPayable(nextPeriod, p.getSupplierId(), p.getSupplierName(),
326
+                        p.getUnpaidAmount(), p.getExpenseType(),
327
+                        p.getDueDate() != null ? p.getDueDate().plusMonths(1) : null,
328
+                        p.getInvoiceNo(), p.getContractNo(),
329
+                        operatorId, "从" + billPeriod + "结转");
330
+                carriedPayable++;
331
+            }
332
+        }
333
+
334
+        log.info("Carry forward: {} -> {} receivables={} payables={}", billPeriod, nextPeriod, carriedReceivable, carriedPayable);
335
+        return Map.of(
336
+                "fromPeriod", billPeriod,
337
+                "toPeriod", nextPeriod,
338
+                "carriedReceivable", carriedReceivable,
339
+                "carriedPayable", carriedPayable
340
+        );
341
+    }
342
+
343
+    /**
344
+     * 应收应付汇总统计
345
+     */
346
+    public Map<String, Object> summary(String billPeriod) {
347
+        List<Receivable> receivables = receivableMapper.selectByBillPeriod(billPeriod);
348
+        List<Payable> payables = payableMapper.selectByBillPeriod(billPeriod);
349
+
350
+        BigDecimal totalReceivable = BigDecimal.ZERO;
351
+        BigDecimal totalReceived = BigDecimal.ZERO;
352
+        int overdueCount = 0;
353
+        for (Receivable r : receivables) {
354
+            totalReceivable = totalReceivable.add(r.getAmount());
355
+            totalReceived = totalReceived.add(r.getReceivedAmount());
356
+            if ("overdue".equals(r.getStatus())) overdueCount++;
357
+        }
358
+
359
+        BigDecimal totalPayable = BigDecimal.ZERO;
360
+        BigDecimal totalPaid = BigDecimal.ZERO;
361
+        for (Payable p : payables) {
362
+            totalPayable = totalPayable.add(p.getAmount());
363
+            totalPaid = totalPaid.add(p.getPaidAmount());
364
+        }
365
+
366
+        return Map.of(
367
+                "billPeriod", billPeriod,
368
+                "totalReceivable", totalReceivable,
369
+                "totalReceived", totalReceived,
370
+                "unreceivedAmount", totalReceivable.subtract(totalReceived),
371
+                "totalPayable", totalPayable,
372
+                "totalPaid", totalPaid,
373
+                "unpaidAmount", totalPayable.subtract(totalPaid),
374
+                "overdueCount", overdueCount,
375
+                "receivableCount", receivables.size(),
376
+                "payableCount", payables.size()
377
+        );
378
+    }
379
+
380
+    private String generateNo(String prefix) {
381
+        return prefix + LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"))
382
+                + String.format("%06d", (int) (Math.random() * 999999));
383
+    }
384
+
385
+    private String calculateNextPeriod(String billPeriod) {
386
+        // billPeriod format: "2026-06"
387
+        String[] parts = billPeriod.split("-");
388
+        int year = Integer.parseInt(parts[0]);
389
+        int month = Integer.parseInt(parts[1]);
390
+        month++;
391
+        if (month > 12) {
392
+            month = 1;
393
+            year++;
394
+        }
395
+        return String.format("%04d-%02d", year, month);
396
+    }
397
+}

+ 262
- 0
wm-revenue/src/main/java/com/water/revenue/service/CollectionService.java Vedi File

@@ -0,0 +1,262 @@
1
+package com.water.revenue.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.water.common.core.exception.BusinessException;
5
+import com.water.revenue.entity.CollectionRecord;
6
+import com.water.revenue.entity.Receivable;
7
+import com.water.revenue.mapper.CollectionRecordMapper;
8
+import com.water.revenue.mapper.ReceivableMapper;
9
+import lombok.RequiredArgsConstructor;
10
+import lombok.extern.slf4j.Slf4j;
11
+import org.springframework.stereotype.Service;
12
+import org.springframework.transaction.annotation.Transactional;
13
+
14
+import java.math.BigDecimal;
15
+import java.time.LocalDate;
16
+import java.time.LocalDateTime;
17
+import java.time.format.DateTimeFormatter;
18
+import java.util.*;
19
+
20
+/**
21
+ * 欠费催缴服务
22
+ */
23
+@Slf4j
24
+@Service
25
+@RequiredArgsConstructor
26
+public class CollectionService {
27
+
28
+    private final CollectionRecordMapper collectionRecordMapper;
29
+    private final ReceivableMapper receivableMapper;
30
+
31
+    /**
32
+     * 查询欠费列表(逾期应收)
33
+     */
34
+    public List<Receivable> queryOverdueAccounts() {
35
+        return receivableMapper.selectAllOverdue();
36
+    }
37
+
38
+    /**
39
+     * 查询客户欠费
40
+     */
41
+    public List<Receivable> queryCustomerOverdue(Long customerId) {
42
+        return receivableMapper.selectOverdueByCustomerId(customerId);
43
+    }
44
+
45
+    /**
46
+     * 创建催缴记录
47
+     */
48
+    @Transactional
49
+    public CollectionRecord createCollection(Long customerId, String customerName,
50
+                                              BigDecimal overdueAmount, Integer overdueDays,
51
+                                              String collectionType, String contactPerson,
52
+                                              String contactPhone, String contactEmail,
53
+                                              Long receivableId, Long collectorId,
54
+                                              String collectorName, String remark) {
55
+        if (overdueAmount == null || overdueAmount.compareTo(BigDecimal.ZERO) <= 0) {
56
+            throw new BusinessException("欠费金额必须大于0");
57
+        }
58
+        if (collectionType == null || collectionType.isEmpty()) {
59
+            throw new BusinessException("催缴类型不能为空");
60
+        }
61
+
62
+        CollectionRecord record = new CollectionRecord();
63
+        record.setCollectionNo(generateCollectionNo());
64
+        record.setCustomerId(customerId);
65
+        record.setCustomerName(customerName);
66
+        record.setOverdueAmount(overdueAmount);
67
+        record.setOverdueDays(overdueDays != null ? overdueDays : 0);
68
+        record.setCollectionType(collectionType);
69
+        record.setCollectionResult(null);
70
+        record.setContactPerson(contactPerson);
71
+        record.setContactPhone(contactPhone);
72
+        record.setContactEmail(contactEmail);
73
+        record.setCollectionTime(LocalDateTime.now());
74
+        record.setCollectorId(collectorId);
75
+        record.setCollectorName(collectorName);
76
+        record.setReceivableId(receivableId);
77
+        record.setRemark(remark);
78
+        record.setStatus("pending");
79
+        record.setDeleted(0);
80
+        record.setCreatedAt(LocalDateTime.now());
81
+        record.setUpdatedAt(LocalDateTime.now());
82
+
83
+        collectionRecordMapper.insert(record);
84
+
85
+        // 更新应收账款的催缴次数
86
+        if (receivableId != null) {
87
+            Receivable receivable = receivableMapper.selectById(receivableId);
88
+            if (receivable != null) {
89
+                receivable.setCollectionCount(
90
+                        (receivable.getCollectionCount() != null ? receivable.getCollectionCount() : 0) + 1);
91
+                receivable.setLastCollectionTime(LocalDateTime.now());
92
+                receivableMapper.updateById(receivable);
93
+            }
94
+        }
95
+
96
+        log.info("Collection created: {} customer={} amount={}", record.getCollectionNo(), customerName, overdueAmount);
97
+        return record;
98
+    }
99
+
100
+    /**
101
+     * 发送催缴通知(模拟短信/电话/上门)
102
+     */
103
+    @Transactional
104
+    public Map<String, Object> sendNotification(Long collectionId) {
105
+        CollectionRecord record = collectionRecordMapper.selectById(collectionId);
106
+        if (record == null) {
107
+            throw new BusinessException("催缴记录不存在");
108
+        }
109
+
110
+        String notificationType = record.getCollectionType();
111
+        String message;
112
+
113
+        switch (notificationType) {
114
+            case "sms":
115
+                message = "短信催缴通知已发送至 " + record.getContactPhone() + "(模拟)";
116
+                break;
117
+            case "phone":
118
+                message = "电话催缴已拨打 " + record.getContactPhone() + "(模拟)";
119
+                break;
120
+            case "visit":
121
+                message = "上门催缴已安排,客户: " + record.getCustomerName() + "(模拟)";
122
+                break;
123
+            case "letter":
124
+                message = "催缴函已发送至 " + record.getContactEmail() + "(模拟)";
125
+                break;
126
+            default:
127
+                message = "催缴通知已发送(模拟)";
128
+        }
129
+
130
+        record.setStatus("in_progress");
131
+        record.setUpdatedAt(LocalDateTime.now());
132
+        collectionRecordMapper.updateById(record);
133
+
134
+        log.info("Collection notification sent: {} type={}", record.getCollectionNo(), notificationType);
135
+        return Map.of(
136
+                "collectionNo", record.getCollectionNo(),
137
+                "notificationType", notificationType,
138
+                "message", message
139
+        );
140
+    }
141
+
142
+    /**
143
+     * 更新催缴结果
144
+     */
145
+    @Transactional
146
+    public CollectionRecord updateResult(Long collectionId, String collectionResult,
147
+                                          LocalDate promisedDate, String remark) {
148
+        CollectionRecord record = collectionRecordMapper.selectById(collectionId);
149
+        if (record == null) {
150
+            throw new BusinessException("催缴记录不存在");
151
+        }
152
+
153
+        String newStatus;
154
+        switch (collectionResult) {
155
+            case "success":
156
+                newStatus = "completed";
157
+                break;
158
+            case "promised":
159
+                newStatus = "in_progress";
160
+                break;
161
+            default:
162
+                newStatus = record.getStatus();
163
+        }
164
+
165
+        collectionRecordMapper.updateResult(collectionId, newStatus, collectionResult);
166
+        record.setCollectionResult(collectionResult);
167
+        record.setStatus(newStatus);
168
+        if (promisedDate != null) {
169
+            record.setPromisedDate(promisedDate);
170
+        }
171
+        if (remark != null) {
172
+            record.setRemark(remark);
173
+        }
174
+        record.setUpdatedAt(LocalDateTime.now());
175
+        collectionRecordMapper.updateById(record);
176
+
177
+        log.info("Collection result updated: {} result={}", record.getCollectionNo(), collectionResult);
178
+        return record;
179
+    }
180
+
181
+    /**
182
+     * 查询客户的催缴记录
183
+     */
184
+    public List<CollectionRecord> listByCustomer(Long customerId) {
185
+        return collectionRecordMapper.selectByCustomerId(customerId);
186
+    }
187
+
188
+    /**
189
+     * 查询应收账款关联的催缴记录
190
+     */
191
+    public List<CollectionRecord> listByReceivable(Long receivableId) {
192
+        return collectionRecordMapper.selectByReceivableId(receivableId);
193
+    }
194
+
195
+    /**
196
+     * 分页查询催缴记录
197
+     */
198
+    public List<CollectionRecord> listRecords(String status, String collectionType, int page, int size) {
199
+        LambdaQueryWrapper<CollectionRecord> wrapper = new LambdaQueryWrapper<>();
200
+        if (status != null && !status.isEmpty()) {
201
+            wrapper.eq(CollectionRecord::getStatus, status);
202
+        }
203
+        if (collectionType != null && !collectionType.isEmpty()) {
204
+            wrapper.eq(CollectionRecord::getCollectionType, collectionType);
205
+        }
206
+        wrapper.orderByDesc(CollectionRecord::getCollectionTime);
207
+        wrapper.last("LIMIT " + size + " OFFSET " + ((page - 1) * size));
208
+        return collectionRecordMapper.selectList(wrapper);
209
+    }
210
+
211
+    /**
212
+     * 催缴统计
213
+     */
214
+    public Map<String, Object> statistics(Integer days) {
215
+        LocalDateTime startTime = LocalDateTime.now().minusDays(days != null ? days : 30);
216
+        List<Map<String, Object>> typeStats = collectionRecordMapper.selectStatistics(startTime);
217
+
218
+        LambdaQueryWrapper<CollectionRecord> wrapper = new LambdaQueryWrapper<>();
219
+        wrapper.ge(CollectionRecord::getCollectionTime, startTime);
220
+        List<CollectionRecord> allRecords = collectionRecordMapper.selectList(wrapper);
221
+
222
+        int totalCount = allRecords.size();
223
+        int successCount = 0;
224
+        int promisedCount = 0;
225
+        int failedCount = 0;
226
+        BigDecimal totalOverdue = BigDecimal.ZERO;
227
+        BigDecimal totalCollected = BigDecimal.ZERO;
228
+
229
+        for (CollectionRecord r : allRecords) {
230
+            totalOverdue = totalOverdue.add(r.getOverdueAmount());
231
+            if ("success".equals(r.getCollectionResult())) {
232
+                successCount++;
233
+                totalCollected = totalCollected.add(r.getOverdueAmount());
234
+            } else if ("promised".equals(r.getCollectionResult())) {
235
+                promisedCount++;
236
+            } else if ("failed".equals(r.getCollectionResult()) || "refused".equals(r.getCollectionResult())) {
237
+                failedCount++;
238
+            }
239
+        }
240
+
241
+        BigDecimal successRate = totalCount > 0
242
+                ? BigDecimal.valueOf(successCount).multiply(BigDecimal.valueOf(100)).divide(BigDecimal.valueOf(totalCount), 1, java.math.RoundingMode.HALF_UP)
243
+                : BigDecimal.ZERO;
244
+
245
+        return Map.of(
246
+                "period", days != null ? days : 30,
247
+                "totalCount", totalCount,
248
+                "successCount", successCount,
249
+                "promisedCount", promisedCount,
250
+                "failedCount", failedCount,
251
+                "successRate", successRate,
252
+                "totalOverdueAmount", totalOverdue,
253
+                "totalCollectedAmount", totalCollected,
254
+                "byType", typeStats
255
+        );
256
+    }
257
+
258
+    private String generateCollectionNo() {
259
+        return "CLT" + LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"))
260
+                + String.format("%06d", (int) (Math.random() * 999999));
261
+    }
262
+}

+ 232
- 0
wm-revenue/src/main/java/com/water/revenue/service/InvoiceService.java Vedi File

@@ -0,0 +1,232 @@
1
+package com.water.revenue.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.water.common.core.exception.BusinessException;
5
+import com.water.revenue.entity.Invoice;
6
+import com.water.revenue.mapper.InvoiceMapper;
7
+import lombok.RequiredArgsConstructor;
8
+import lombok.extern.slf4j.Slf4j;
9
+import org.springframework.stereotype.Service;
10
+import org.springframework.transaction.annotation.Transactional;
11
+
12
+import java.math.BigDecimal;
13
+import java.math.RoundingMode;
14
+import java.time.LocalDate;
15
+import java.time.LocalDateTime;
16
+import java.time.format.DateTimeFormatter;
17
+import java.util.*;
18
+
19
+/**
20
+ * 电子发票服务
21
+ */
22
+@Slf4j
23
+@Service
24
+@RequiredArgsConstructor
25
+public class InvoiceService {
26
+
27
+    private final InvoiceMapper invoiceMapper;
28
+
29
+    /**
30
+     * 开具发票(模拟)
31
+     */
32
+    @Transactional
33
+    public Invoice issueInvoice(Long customerId, String customerName, String customerTaxNo,
34
+                                 BigDecimal amount, BigDecimal taxRate, String invoiceType,
35
+                                 String content, Long billId, String email, String phone,
36
+                                 Long operatorId, String operatorName) {
37
+        if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
38
+            throw new BusinessException("发票金额必须大于0");
39
+        }
40
+        if (customerId == null) {
41
+            throw new BusinessException("客户ID不能为空");
42
+        }
43
+
44
+        BigDecimal taxAmount = amount.multiply(taxRate).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
45
+        BigDecimal totalAmount = amount.add(taxAmount);
46
+
47
+        Invoice invoice = new Invoice();
48
+        invoice.setInvoiceNo(generateInvoiceNo());
49
+        invoice.setInvoiceType(invoiceType != null ? invoiceType : "electronic");
50
+        invoice.setCustomerId(customerId);
51
+        invoice.setCustomerName(customerName);
52
+        invoice.setCustomerTaxNo(customerTaxNo);
53
+        invoice.setAmount(amount);
54
+        invoice.setTaxRate(taxRate);
55
+        invoice.setTaxAmount(taxAmount);
56
+        invoice.setTotalAmount(totalAmount);
57
+        invoice.setContent(content);
58
+        invoice.setBillId(billId);
59
+        invoice.setStatus("issued");
60
+        invoice.setIssueDate(LocalDate.now());
61
+        invoice.setPushStatus("none");
62
+        invoice.setEmail(email);
63
+        invoice.setPhone(phone);
64
+        invoice.setOperatorId(operatorId);
65
+        invoice.setOperatorName(operatorName);
66
+        invoice.setDeleted(0);
67
+        invoice.setCreatedAt(LocalDateTime.now());
68
+        invoice.setUpdatedAt(LocalDateTime.now());
69
+
70
+        invoiceMapper.insert(invoice);
71
+        log.info("Invoice issued: {} amount={} total={}", invoice.getInvoiceNo(), amount, totalAmount);
72
+        return invoice;
73
+    }
74
+
75
+    /**
76
+     * 查询发票(按发票号)
77
+     */
78
+    public Invoice getByInvoiceNo(String invoiceNo) {
79
+        Invoice invoice = invoiceMapper.selectByInvoiceNo(invoiceNo);
80
+        if (invoice == null) {
81
+            throw new BusinessException("发票不存在: " + invoiceNo);
82
+        }
83
+        return invoice;
84
+    }
85
+
86
+    /**
87
+     * 查询客户的发票列表
88
+     */
89
+    public List<Invoice> listByCustomerId(Long customerId) {
90
+        return invoiceMapper.selectByCustomerId(customerId);
91
+    }
92
+
93
+    /**
94
+     * 按账单ID查询发票
95
+     */
96
+    public List<Invoice> listByBillId(Long billId) {
97
+        return invoiceMapper.selectByBillId(billId);
98
+    }
99
+
100
+    /**
101
+     * 分页查询发票
102
+     */
103
+    public List<Invoice> listInvoices(String status, String invoiceType, int page, int size) {
104
+        LambdaQueryWrapper<Invoice> wrapper = new LambdaQueryWrapper<>();
105
+        if (status != null && !status.isEmpty()) {
106
+            wrapper.eq(Invoice::getStatus, status);
107
+        }
108
+        if (invoiceType != null && !invoiceType.isEmpty()) {
109
+            wrapper.eq(Invoice::getInvoiceType, invoiceType);
110
+        }
111
+        wrapper.orderByDesc(Invoice::getCreatedAt);
112
+        wrapper.last("LIMIT " + size + " OFFSET " + ((page - 1) * size));
113
+        return invoiceMapper.selectList(wrapper);
114
+    }
115
+
116
+    /**
117
+     * 推送发票(邮件/短信)
118
+     */
119
+    @Transactional
120
+    public Map<String, Object> pushInvoice(Long invoiceId, String pushType) {
121
+        Invoice invoice = invoiceMapper.selectById(invoiceId);
122
+        if (invoice == null) {
123
+            throw new BusinessException("发票不存在");
124
+        }
125
+        if (!"issued".equals(invoice.getStatus()) && !"sent".equals(invoice.getStatus())) {
126
+            throw new BusinessException("当前发票状态不允许推送: " + invoice.getStatus());
127
+        }
128
+
129
+        String pushStatus = pushType != null ? pushType : "email";
130
+        invoiceMapper.updatePushStatus(invoiceId, "sent", pushStatus);
131
+
132
+        log.info("Invoice pushed: {} via {}", invoice.getInvoiceNo(), pushStatus);
133
+        return Map.of(
134
+                "invoiceNo", invoice.getInvoiceNo(),
135
+                "pushStatus", pushStatus,
136
+                "pushTime", LocalDateTime.now().toString(),
137
+                "message", "发票推送成功(模拟)"
138
+        );
139
+    }
140
+
141
+    /**
142
+     * 红冲/作废发票
143
+     */
144
+    @Transactional
145
+    public Invoice redFlush(Long invoiceId, String reason, Long operatorId, String operatorName) {
146
+        Invoice invoice = invoiceMapper.selectById(invoiceId);
147
+        if (invoice == null) {
148
+            throw new BusinessException("发票不存在");
149
+        }
150
+        if ("cancelled".equals(invoice.getStatus()) || "red".equals(invoice.getStatus())) {
151
+            throw new BusinessException("发票已作废或已红冲");
152
+        }
153
+
154
+        // 更新原发票状态为红冲
155
+        invoice.setStatus("red");
156
+        invoice.setCancelReason(reason);
157
+        invoice.setUpdatedAt(LocalDateTime.now());
158
+        invoiceMapper.updateById(invoice);
159
+
160
+        // 创建红冲发票(金额为负)
161
+        Invoice redInvoice = new Invoice();
162
+        redInvoice.setInvoiceNo(generateInvoiceNo());
163
+        redInvoice.setInvoiceType(invoice.getInvoiceType());
164
+        redInvoice.setCustomerId(invoice.getCustomerId());
165
+        redInvoice.setCustomerName(invoice.getCustomerName());
166
+        redInvoice.setCustomerTaxNo(invoice.getCustomerTaxNo());
167
+        redInvoice.setAmount(invoice.getAmount().negate());
168
+        redInvoice.setTaxRate(invoice.getTaxRate());
169
+        redInvoice.setTaxAmount(invoice.getTaxAmount().negate());
170
+        redInvoice.setTotalAmount(invoice.getTotalAmount().negate());
171
+        redInvoice.setContent("红冲-" + invoice.getContent());
172
+        redInvoice.setBillId(invoice.getBillId());
173
+        redInvoice.setStatus("issued");
174
+        redInvoice.setIssueDate(LocalDate.now());
175
+        redInvoice.setPushStatus("none");
176
+        redInvoice.setOriginalInvoiceId(invoiceId);
177
+        redInvoice.setCancelReason(reason);
178
+        redInvoice.setOperatorId(operatorId);
179
+        redInvoice.setOperatorName(operatorName);
180
+        redInvoice.setDeleted(0);
181
+        redInvoice.setCreatedAt(LocalDateTime.now());
182
+        redInvoice.setUpdatedAt(LocalDateTime.now());
183
+
184
+        invoiceMapper.insert(redInvoice);
185
+        log.info("Invoice red-flushed: {} -> {}", invoice.getInvoiceNo(), redInvoice.getInvoiceNo());
186
+        return redInvoice;
187
+    }
188
+
189
+    /**
190
+     * 统计发票数据
191
+     */
192
+    public Map<String, Object> statistics(String startDate, String endDate) {
193
+        LambdaQueryWrapper<Invoice> wrapper = new LambdaQueryWrapper<>();
194
+        wrapper.ne(Invoice::getStatus, "cancelled");
195
+        if (startDate != null) {
196
+            wrapper.ge(Invoice::getIssueDate, LocalDate.parse(startDate));
197
+        }
198
+        if (endDate != null) {
199
+            wrapper.le(Invoice::getIssueDate, LocalDate.parse(endDate));
200
+        }
201
+        List<Invoice> invoices = invoiceMapper.selectList(wrapper);
202
+
203
+        BigDecimal totalAmount = BigDecimal.ZERO;
204
+        BigDecimal totalTax = BigDecimal.ZERO;
205
+        int issuedCount = 0;
206
+        int redCount = 0;
207
+
208
+        for (Invoice inv : invoices) {
209
+            if ("red".equals(inv.getStatus())) {
210
+                redCount++;
211
+            } else {
212
+                issuedCount++;
213
+                totalAmount = totalAmount.add(inv.getAmount());
214
+                totalTax = totalTax.add(inv.getTaxAmount());
215
+            }
216
+        }
217
+
218
+        return Map.of(
219
+                "totalCount", invoices.size(),
220
+                "issuedCount", issuedCount,
221
+                "redCount", redCount,
222
+                "totalAmount", totalAmount,
223
+                "totalTaxAmount", totalTax,
224
+                "totalWithTax", totalAmount.add(totalTax)
225
+        );
226
+    }
227
+
228
+    private String generateInvoiceNo() {
229
+        return "INV" + LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"))
230
+                + String.format("%06d", (int) (Math.random() * 999999));
231
+    }
232
+}

+ 155
- 0
wm-revenue/src/main/resources/db/V_invoice_account.sql Vedi File

@@ -0,0 +1,155 @@
1
+-- =============================================
2
+-- 智慧水务管理系统 - 电子发票 + 应收应付 + 催缴 DDL
3
+-- 版本: V_invoice_account
4
+-- =============================================
5
+
6
+-- 电子发票表
7
+CREATE TABLE IF NOT EXISTS rev_invoice (
8
+    id BIGSERIAL PRIMARY KEY,
9
+    invoice_no VARCHAR(30) UNIQUE NOT NULL,
10
+    invoice_type VARCHAR(20) NOT NULL DEFAULT 'electronic',  -- normal/special/electronic
11
+    customer_id BIGINT NOT NULL,
12
+    customer_name VARCHAR(100),
13
+    customer_tax_no VARCHAR(30),
14
+    amount DECIMAL(12,2) NOT NULL,
15
+    tax_rate DECIMAL(5,2) DEFAULT 6.00,
16
+    tax_amount DECIMAL(12,2) NOT NULL,
17
+    total_amount DECIMAL(12,2) NOT NULL,
18
+    content VARCHAR(500),
19
+    bill_id BIGINT,
20
+    status VARCHAR(20) NOT NULL DEFAULT 'draft',             -- draft/issued/sent/cancelled/red
21
+    issue_date DATE,
22
+    push_status VARCHAR(20) DEFAULT 'none',                  -- none/email/sms/both
23
+    push_time TIMESTAMP,
24
+    email VARCHAR(100),
25
+    phone VARCHAR(20),
26
+    cancel_reason VARCHAR(500),
27
+    original_invoice_id BIGINT,                              -- 红冲关联的原发票
28
+    operator_id BIGINT,
29
+    operator_name VARCHAR(50),
30
+    remark VARCHAR(500),
31
+    deleted SMALLINT DEFAULT 0,
32
+    created_at TIMESTAMP DEFAULT NOW(),
33
+    updated_at TIMESTAMP DEFAULT NOW()
34
+);
35
+COMMENT ON TABLE rev_invoice IS '电子发票表';
36
+CREATE INDEX IF NOT EXISTS idx_invoice_customer ON rev_invoice(customer_id);
37
+CREATE INDEX IF NOT EXISTS idx_invoice_bill ON rev_invoice(bill_id);
38
+CREATE INDEX IF NOT EXISTS idx_invoice_status ON rev_invoice(status);
39
+CREATE INDEX IF NOT EXISTS idx_invoice_issue_date ON rev_invoice(issue_date);
40
+
41
+-- 应收账款表
42
+CREATE TABLE IF NOT EXISTS rev_receivable (
43
+    id BIGSERIAL PRIMARY KEY,
44
+    receivable_no VARCHAR(30) UNIQUE NOT NULL,
45
+    bill_period VARCHAR(10) NOT NULL,                        -- 2026-06
46
+    customer_id BIGINT NOT NULL,
47
+    customer_name VARCHAR(100),
48
+    amount DECIMAL(12,2) NOT NULL,
49
+    received_amount DECIMAL(12,2) DEFAULT 0,
50
+    unreceived_amount DECIMAL(12,2) NOT NULL,
51
+    status VARCHAR(20) NOT NULL DEFAULT 'pending',           -- pending/partial/completed/overdue/cancelled
52
+    bill_id BIGINT,
53
+    due_date DATE,
54
+    overdue_days INT DEFAULT 0,
55
+    collection_count INT DEFAULT 0,
56
+    last_collection_time TIMESTAMP,
57
+    operator_id BIGINT,
58
+    remark VARCHAR(500),
59
+    deleted SMALLINT DEFAULT 0,
60
+    created_at TIMESTAMP DEFAULT NOW(),
61
+    updated_at TIMESTAMP DEFAULT NOW()
62
+);
63
+COMMENT ON TABLE rev_receivable IS '应收账款表';
64
+CREATE INDEX IF NOT EXISTS idx_receivable_customer ON rev_receivable(customer_id);
65
+CREATE INDEX IF NOT EXISTS idx_receivable_period ON rev_receivable(bill_period);
66
+CREATE INDEX IF NOT EXISTS idx_receivable_status ON rev_receivable(status);
67
+CREATE INDEX IF NOT EXISTS idx_receivable_due_date ON rev_receivable(due_date);
68
+
69
+-- 应付账款表
70
+CREATE TABLE IF NOT EXISTS rev_payable (
71
+    id BIGSERIAL PRIMARY KEY,
72
+    payable_no VARCHAR(30) UNIQUE NOT NULL,
73
+    bill_period VARCHAR(10) NOT NULL,                        -- 2026-06
74
+    supplier_id BIGINT NOT NULL,
75
+    supplier_name VARCHAR(100),
76
+    amount DECIMAL(12,2) NOT NULL,
77
+    paid_amount DECIMAL(12,2) DEFAULT 0,
78
+    unpaid_amount DECIMAL(12,2) NOT NULL,
79
+    status VARCHAR(20) NOT NULL DEFAULT 'pending',           -- pending/partial/completed/cancelled
80
+    expense_type VARCHAR(30),                                -- electricity/chemical/maintenance/equipment/other
81
+    due_date DATE,
82
+    invoice_no VARCHAR(50),
83
+    contract_no VARCHAR(50),
84
+    operator_id BIGINT,
85
+    remark VARCHAR(500),
86
+    deleted SMALLINT DEFAULT 0,
87
+    created_at TIMESTAMP DEFAULT NOW(),
88
+    updated_at TIMESTAMP DEFAULT NOW()
89
+);
90
+COMMENT ON TABLE rev_payable IS '应付账款表';
91
+CREATE INDEX IF NOT EXISTS idx_payable_supplier ON rev_payable(supplier_id);
92
+CREATE INDEX IF NOT EXISTS idx_payable_period ON rev_payable(bill_period);
93
+CREATE INDEX IF NOT EXISTS idx_payable_status ON rev_payable(status);
94
+CREATE INDEX IF NOT EXISTS idx_payable_due_date ON rev_payable(due_date);
95
+
96
+-- 对账记录表
97
+CREATE TABLE IF NOT EXISTS rev_reconciliation (
98
+    id BIGSERIAL PRIMARY KEY,
99
+    reconciliation_no VARCHAR(30) UNIQUE NOT NULL,
100
+    bill_period VARCHAR(10) NOT NULL,
101
+    reconciliation_type VARCHAR(20) DEFAULT 'all',           -- receivable/payable/all
102
+    total_receivable DECIMAL(14,2) DEFAULT 0,
103
+    total_received DECIMAL(14,2) DEFAULT 0,
104
+    total_payable DECIMAL(14,2) DEFAULT 0,
105
+    total_paid DECIMAL(14,2) DEFAULT 0,
106
+    difference DECIMAL(14,2) DEFAULT 0,
107
+    status VARCHAR(20) NOT NULL DEFAULT 'draft',             -- draft/pending/approved/rejected/completed
108
+    start_date DATE,
109
+    end_date DATE,
110
+    exception_count INT DEFAULT 0,
111
+    exception_note TEXT,
112
+    auditor_id BIGINT,
113
+    auditor_name VARCHAR(50),
114
+    audit_time TIMESTAMP,
115
+    operator_id BIGINT,
116
+    operator_name VARCHAR(50),
117
+    remark VARCHAR(500),
118
+    deleted SMALLINT DEFAULT 0,
119
+    created_at TIMESTAMP DEFAULT NOW(),
120
+    updated_at TIMESTAMP DEFAULT NOW()
121
+);
122
+COMMENT ON TABLE rev_reconciliation IS '对账记录表';
123
+CREATE INDEX IF NOT EXISTS idx_reconciliation_period ON rev_reconciliation(bill_period);
124
+CREATE INDEX IF NOT EXISTS idx_reconciliation_status ON rev_reconciliation(status);
125
+
126
+-- 催缴记录表
127
+CREATE TABLE IF NOT EXISTS rev_collection_record (
128
+    id BIGSERIAL PRIMARY KEY,
129
+    collection_no VARCHAR(30) UNIQUE NOT NULL,
130
+    customer_id BIGINT NOT NULL,
131
+    customer_name VARCHAR(100),
132
+    overdue_amount DECIMAL(12,2) NOT NULL,
133
+    overdue_days INT DEFAULT 0,
134
+    collection_type VARCHAR(20) NOT NULL,                    -- sms/phone/visit/letter
135
+    collection_result VARCHAR(20),                           -- success/promised/failed/unreachable/refused
136
+    contact_person VARCHAR(50),
137
+    contact_phone VARCHAR(20),
138
+    contact_email VARCHAR(100),
139
+    collection_time TIMESTAMP,
140
+    promised_date DATE,
141
+    collector_id BIGINT,
142
+    collector_name VARCHAR(50),
143
+    receivable_id BIGINT,
144
+    remark VARCHAR(500),
145
+    status VARCHAR(20) DEFAULT 'pending',                    -- pending/in_progress/completed/cancelled
146
+    deleted SMALLINT DEFAULT 0,
147
+    created_at TIMESTAMP DEFAULT NOW(),
148
+    updated_at TIMESTAMP DEFAULT NOW()
149
+);
150
+COMMENT ON TABLE rev_collection_record IS '催缴记录表';
151
+CREATE INDEX IF NOT EXISTS idx_collection_customer ON rev_collection_record(customer_id);
152
+CREATE INDEX IF NOT EXISTS idx_collection_receivable ON rev_collection_record(receivable_id);
153
+CREATE INDEX IF NOT EXISTS idx_collection_status ON rev_collection_record(status);
154
+CREATE INDEX IF NOT EXISTS idx_collection_time ON rev_collection_record(collection_time);
155
+CREATE INDEX IF NOT EXISTS idx_collection_type ON rev_collection_record(collection_type);

+ 380
- 0
wm-revenue/src/test/java/com/water/revenue/InvoiceAccountTest.java Vedi File

@@ -0,0 +1,380 @@
1
+package com.water.revenue;
2
+
3
+import com.water.common.core.exception.BusinessException;
4
+import com.water.revenue.entity.*;
5
+import com.water.revenue.mapper.*;
6
+import com.water.revenue.service.AccountService;
7
+import com.water.revenue.service.CollectionService;
8
+import com.water.revenue.service.InvoiceService;
9
+import org.junit.jupiter.api.BeforeEach;
10
+import org.junit.jupiter.api.DisplayName;
11
+import org.junit.jupiter.api.Test;
12
+import org.junit.jupiter.api.extension.ExtendWith;
13
+import org.mockito.Mock;
14
+import org.mockito.junit.jupiter.MockitoExtension;
15
+
16
+import java.math.BigDecimal;
17
+import java.time.LocalDate;
18
+import java.time.LocalDateTime;
19
+import java.util.*;
20
+
21
+import static org.junit.jupiter.api.Assertions.*;
22
+import static org.mockito.ArgumentMatchers.*;
23
+import static org.mockito.Mockito.*;
24
+
25
+/**
26
+ * 电子发票 + 应收应付 + 欠费催缴 综合测试
27
+ */
28
+@ExtendWith(MockitoExtension.class)
29
+class InvoiceAccountTest {
30
+
31
+    @Mock
32
+    private InvoiceMapper invoiceMapper;
33
+
34
+    @Mock
35
+    private ReceivableMapper receivableMapper;
36
+
37
+    @Mock
38
+    private PayableMapper payableMapper;
39
+
40
+    @Mock
41
+    private ReconciliationMapper reconciliationMapper;
42
+
43
+    @Mock
44
+    private CollectionRecordMapper collectionRecordMapper;
45
+
46
+    private InvoiceService invoiceService;
47
+    private AccountService accountService;
48
+    private CollectionService collectionService;
49
+
50
+    @BeforeEach
51
+    void setUp() {
52
+        invoiceService = new InvoiceService(invoiceMapper);
53
+        accountService = new AccountService(receivableMapper, payableMapper, reconciliationMapper);
54
+        collectionService = new CollectionService(collectionRecordMapper, receivableMapper);
55
+    }
56
+
57
+    // ==================== 发票服务测试 ====================
58
+
59
+    @Test
60
+    @DisplayName("开具发票 - 正常开票")
61
+    void testIssueInvoice() {
62
+        when(invoiceMapper.insert(any(Invoice.class))).thenAnswer(invocation -> {
63
+            Invoice inv = invocation.getArgument(0);
64
+            inv.setId(1L);
65
+            return 1;
66
+        });
67
+
68
+        Invoice invoice = invoiceService.issueInvoice(
69
+                1L, "测试客户", "TAX123456", new BigDecimal("1000.00"),
70
+                BigDecimal.valueOf(6), "electronic", "水费-202606",
71
+                100L, "test@example.com", "13800138000", 1L, "操作员");
72
+
73
+        assertNotNull(invoice);
74
+        assertNotNull(invoice.getInvoiceNo());
75
+        assertTrue(invoice.getInvoiceNo().startsWith("INV"));
76
+        assertEquals(new BigDecimal("1000.00"), invoice.getAmount());
77
+        assertEquals(new BigDecimal("60.00"), invoice.getTaxAmount());
78
+        assertEquals(new BigDecimal("1060.00"), invoice.getTotalAmount());
79
+        assertEquals("issued", invoice.getStatus());
80
+        assertEquals("none", invoice.getPushStatus());
81
+
82
+        verify(invoiceMapper, times(1)).insert(any(Invoice.class));
83
+    }
84
+
85
+    @Test
86
+    @DisplayName("开具发票 - 金额为0应抛异常")
87
+    void testIssueInvoiceZeroAmount() {
88
+        assertThrows(BusinessException.class, () ->
89
+                invoiceService.issueInvoice(1L, "客户", "TAX", BigDecimal.ZERO,
90
+                        BigDecimal.valueOf(6), "electronic", "水费", null, null, null, 1L, "操作员"));
91
+    }
92
+
93
+    @Test
94
+    @DisplayName("开具发票 - 客户ID为空应抛异常")
95
+    void testIssueInvoiceNullCustomer() {
96
+        assertThrows(BusinessException.class, () ->
97
+                invoiceService.issueInvoice(null, "客户", "TAX", new BigDecimal("100"),
98
+                        BigDecimal.valueOf(6), "electronic", "水费", null, null, null, 1L, "操作员"));
99
+    }
100
+
101
+    @Test
102
+    @DisplayName("推送发票 - 正常推送")
103
+    void testPushInvoice() {
104
+        Invoice invoice = new Invoice();
105
+        invoice.setId(1L);
106
+        invoice.setInvoiceNo("INV20260614000001");
107
+        invoice.setStatus("issued");
108
+
109
+        when(invoiceMapper.selectById(1L)).thenReturn(invoice);
110
+        when(invoiceMapper.updatePushStatus(anyLong(), anyString(), anyString())).thenReturn(1);
111
+
112
+        Map<String, Object> result = invoiceService.pushInvoice(1L, "email");
113
+
114
+        assertNotNull(result);
115
+        assertEquals("INV20260614000001", result.get("invoiceNo"));
116
+        assertEquals("email", result.get("pushStatus"));
117
+        verify(invoiceMapper, times(1)).updatePushStatus(1L, "sent", "email");
118
+    }
119
+
120
+    @Test
121
+    @DisplayName("红冲发票 - 正常红冲")
122
+    void testRedFlush() {
123
+        Invoice original = new Invoice();
124
+        original.setId(1L);
125
+        original.setInvoiceNo("INV20260614000001");
126
+        original.setInvoiceType("electronic");
127
+        original.setCustomerId(1L);
128
+        original.setCustomerName("测试客户");
129
+        original.setAmount(new BigDecimal("1000.00"));
130
+        original.setTaxRate(BigDecimal.valueOf(6));
131
+        original.setTaxAmount(new BigDecimal("60.00"));
132
+        original.setTotalAmount(new BigDecimal("1060.00"));
133
+        original.setStatus("issued");
134
+
135
+        when(invoiceMapper.selectById(1L)).thenReturn(original);
136
+        when(invoiceMapper.updateById(any(Invoice.class))).thenReturn(1);
137
+        when(invoiceMapper.insert(any(Invoice.class))).thenAnswer(inv -> {
138
+            Invoice inv = inv.getArgument(0);
139
+            inv.setId(2L);
140
+            return 1;
141
+        });
142
+
143
+        Invoice redInvoice = invoiceService.redFlush(1L, "开票有误", 1L, "操作员");
144
+
145
+        assertNotNull(redInvoice);
146
+        assertEquals(new BigDecimal("-1000.00"), redInvoice.getAmount());
147
+        assertEquals(new BigDecimal("-60.00"), redInvoice.getTaxAmount());
148
+        assertEquals(1L, redInvoice.getOriginalInvoiceId());
149
+        assertTrue(redInvoice.getContent().startsWith("红冲-"));
150
+        verify(invoiceMapper, times(1)).updateById(argThat(inv -> "red".equals(((Invoice) inv).getStatus())));
151
+    }
152
+
153
+    @Test
154
+    @DisplayName("红冲发票 - 已红冲的发票不能再红冲")
155
+    void testRedFlushAlreadyRed() {
156
+        Invoice invoice = new Invoice();
157
+        invoice.setId(1L);
158
+        invoice.setStatus("red");
159
+
160
+        when(invoiceMapper.selectById(1L)).thenReturn(invoice);
161
+
162
+        assertThrows(BusinessException.class, () ->
163
+                invoiceService.redFlush(1L, "重复红冲", 1L, "操作员"));
164
+    }
165
+
166
+    // ==================== 应收应付服务测试 ====================
167
+
168
+    @Test
169
+    @DisplayName("创建应收账款 - 正常创建")
170
+    void testCreateReceivable() {
171
+        when(receivableMapper.insert(any(Receivable.class))).thenAnswer(inv -> {
172
+            Receivable r = inv.getArgument(0);
173
+            r.setId(1L);
174
+            return 1;
175
+        });
176
+
177
+        Receivable receivable = accountService.createReceivable(
178
+                "2026-06", 1L, "客户A", new BigDecimal("5000.00"),
179
+                100L, LocalDate.now().plusDays(30), 1L, "水费应收");
180
+
181
+        assertNotNull(receivable);
182
+        assertTrue(receivable.getReceivableNo().startsWith("RCV"));
183
+        assertEquals("2026-06", receivable.getBillPeriod());
184
+        assertEquals(new BigDecimal("5000.00"), receivable.getAmount());
185
+        assertEquals(BigDecimal.ZERO, receivable.getReceivedAmount());
186
+        assertEquals(new BigDecimal("5000.00"), receivable.getUnreceivedAmount());
187
+        assertEquals("pending", receivable.getStatus());
188
+    }
189
+
190
+    @Test
191
+    @DisplayName("应收收款 - 部分收款")
192
+    void testReceivePayment() {
193
+        Receivable receivable = new Receivable();
194
+        receivable.setId(1L);
195
+        receivable.setReceivableNo("RCV20260614000001");
196
+        receivable.setAmount(new BigDecimal("5000.00"));
197
+        receivable.setReceivedAmount(new BigDecimal("0.00"));
198
+        receivable.setUnreceivedAmount(new BigDecimal("5000.00"));
199
+        receivable.setStatus("pending");
200
+
201
+        when(receivableMapper.selectById(1L)).thenReturn(receivable);
202
+        when(receivableMapper.updateAmount(eq(1L), any(BigDecimal.class), any(BigDecimal.class), anyString())).thenReturn(1);
203
+
204
+        Receivable result = accountService.receivePayment(1L, new BigDecimal("3000.00"));
205
+
206
+        assertEquals(new BigDecimal("3000.00"), result.getReceivedAmount());
207
+        assertEquals(new BigDecimal("2000.00"), result.getUnreceivedAmount());
208
+        assertEquals("partial", result.getStatus());
209
+    }
210
+
211
+    @Test
212
+    @DisplayName("应收收款 - 超额收款应抛异常")
213
+    void testReceivePaymentExceed() {
214
+        Receivable receivable = new Receivable();
215
+        receivable.setId(1L);
216
+        receivable.setUnreceivedAmount(new BigDecimal("2000.00"));
217
+
218
+        when(receivableMapper.selectById(1L)).thenReturn(receivable);
219
+
220
+        assertThrows(BusinessException.class, () ->
221
+                accountService.receivePayment(1L, new BigDecimal("3000.00")));
222
+    }
223
+
224
+    @Test
225
+    @DisplayName("创建应付账款 - 正常创建")
226
+    void testCreatePayable() {
227
+        when(payableMapper.insert(any(Payable.class))).thenAnswer(inv -> {
228
+            Payable p = inv.getArgument(0);
229
+            p.setId(1L);
230
+            return 1;
231
+        });
232
+
233
+        Payable payable = accountService.createPayable(
234
+                "2026-06", 10L, "电力公司", new BigDecimal("20000.00"),
235
+                "electricity", LocalDate.now().plusDays(15),
236
+                "INV-ELEC-001", "CT-2026-001", 1L, "6月电费");
237
+
238
+        assertNotNull(payable);
239
+        assertTrue(payable.getPayableNo().startsWith("PAY"));
240
+        assertEquals("electricity", payable.getExpenseType());
241
+        assertEquals(new BigDecimal("20000.00"), payable.getAmount());
242
+        assertEquals("pending", payable.getStatus());
243
+    }
244
+
245
+    @Test
246
+    @DisplayName("应付付款 - 全额付清")
247
+    void testMakePaymentFull() {
248
+        Payable payable = new Payable();
249
+        payable.setId(1L);
250
+        payable.setPayableNo("PAY20260614000001");
251
+        payable.setAmount(new BigDecimal("10000.00"));
252
+        payable.setPaidAmount(BigDecimal.ZERO);
253
+        payable.setUnpaidAmount(new BigDecimal("10000.00"));
254
+        payable.setStatus("pending");
255
+
256
+        when(payableMapper.selectById(1L)).thenReturn(payable);
257
+        when(payableMapper.updateAmount(eq(1L), any(BigDecimal.class), any(BigDecimal.class), anyString())).thenReturn(1);
258
+
259
+        Payable result = accountService.makePayment(1L, new BigDecimal("10000.00"));
260
+
261
+        assertEquals(new BigDecimal("10000.00"), result.getPaidAmount());
262
+        assertEquals(BigDecimal.ZERO, result.getUnpaidAmount());
263
+        assertEquals("completed", result.getStatus());
264
+    }
265
+
266
+    // ==================== 催缴服务测试 ====================
267
+
268
+    @Test
269
+    @DisplayName("创建催缴记录 - 短信催缴")
270
+    void testCreateCollection() {
271
+        when(collectionRecordMapper.insert(any(CollectionRecord.class))).thenAnswer(inv -> {
272
+            CollectionRecord r = inv.getArgument(0);
273
+            r.setId(1L);
274
+            return 1;
275
+        });
276
+
277
+        Receivable receivable = new Receivable();
278
+        receivable.setId(100L);
279
+        receivable.setCollectionCount(0);
280
+        when(receivableMapper.selectById(100L)).thenReturn(receivable);
281
+        when(receivableMapper.updateById(any(Receivable.class))).thenReturn(1);
282
+
283
+        CollectionRecord record = collectionService.createCollection(
284
+                1L, "欠费客户", new BigDecimal("3000.00"), 60,
285
+                "sms", "张三", "13800138000", "zhangsan@test.com",
286
+                100L, 1L, "催缴员", "首次催缴");
287
+
288
+        assertNotNull(record);
289
+        assertTrue(record.getCollectionNo().startsWith("CLT"));
290
+        assertEquals("sms", record.getCollectionType());
291
+        assertEquals("pending", record.getStatus());
292
+        assertEquals(60, record.getOverdueDays());
293
+
294
+        verify(receivableMapper, times(1)).updateById(argThat(r ->
295
+                ((Receivable) r).getCollectionCount() == 1));
296
+    }
297
+
298
+    @Test
299
+    @DisplayName("创建催缴记录 - 金额为0应抛异常")
300
+    void testCreateCollectionZeroAmount() {
301
+        assertThrows(BusinessException.class, () ->
302
+                collectionService.createCollection(
303
+                        1L, "客户", BigDecimal.ZERO, 30, "sms",
304
+                        "联系人", "138", null, null, 1L, "催缴员", null));
305
+    }
306
+
307
+    @Test
308
+    @DisplayName("发送催缴通知 - 短信通知")
309
+    void testSendNotification() {
310
+        CollectionRecord record = new CollectionRecord();
311
+        record.setId(1L);
312
+        record.setCollectionNo("CLT20260614000001");
313
+        record.setCollectionType("sms");
314
+        record.setContactPhone("13800138000");
315
+        record.setStatus("pending");
316
+
317
+        when(collectionRecordMapper.selectById(1L)).thenReturn(record);
318
+        when(collectionRecordMapper.updateById(any(CollectionRecord.class))).thenReturn(1);
319
+
320
+        Map<String, Object> result = collectionService.sendNotification(1L);
321
+
322
+        assertNotNull(result);
323
+        assertEquals("sms", result.get("notificationType"));
324
+        assertTrue(((String) result.get("message")).contains("短信"));
325
+        verify(collectionRecordMapper, times(1)).updateById(argThat(r ->
326
+                "in_progress".equals(((CollectionRecord) r).getStatus())));
327
+    }
328
+
329
+    @Test
330
+    @DisplayName("更新催缴结果 - 催缴成功")
331
+    void testUpdateResultSuccess() {
332
+        CollectionRecord record = new CollectionRecord();
333
+        record.setId(1L);
334
+        record.setCollectionNo("CLT20260614000001");
335
+        record.setStatus("in_progress");
336
+
337
+        when(collectionRecordMapper.selectById(1L)).thenReturn(record);
338
+        when(collectionRecordMapper.updateResult(eq(1L), anyString(), anyString())).thenReturn(1);
339
+        when(collectionRecordMapper.updateById(any(CollectionRecord.class))).thenReturn(1);
340
+
341
+        CollectionRecord result = collectionService.updateResult(
342
+                1L, "success", null, "客户已缴费");
343
+
344
+        assertEquals("success", result.getCollectionResult());
345
+        assertEquals("completed", result.getStatus());
346
+    }
347
+
348
+    @Test
349
+    @DisplayName("催缴统计 - 正常统计")
350
+    void testStatistics() {
351
+        CollectionRecord success = new CollectionRecord();
352
+        success.setCollectionResult("success");
353
+        success.setOverdueAmount(new BigDecimal("1000.00"));
354
+        success.setCollectionTime(LocalDateTime.now().minusDays(5));
355
+
356
+        CollectionRecord promised = new CollectionRecord();
357
+        promised.setCollectionResult("promised");
358
+        promised.setOverdueAmount(new BigDecimal("2000.00"));
359
+        promised.setCollectionTime(LocalDateTime.now().minusDays(3));
360
+
361
+        CollectionRecord failed = new CollectionRecord();
362
+        failed.setCollectionResult("failed");
363
+        failed.setOverdueAmount(new BigDecimal("500.00"));
364
+        failed.setCollectionTime(LocalDateTime.now().minusDays(1));
365
+
366
+        when(collectionRecordMapper.selectStatistics(any(LocalDateTime.class)))
367
+                .thenReturn(List.of(Map.of("collection_type", "sms", "count", 3L, "success_count", 1L)));
368
+        when(collectionRecordMapper.selectList(any())).thenReturn(List.of(success, promised, failed));
369
+
370
+        Map<String, Object> stats = collectionService.statistics(30);
371
+
372
+        assertNotNull(stats);
373
+        assertEquals(3, stats.get("totalCount"));
374
+        assertEquals(1, stats.get("successCount"));
375
+        assertEquals(1, stats.get("promisedCount"));
376
+        assertEquals(1, stats.get("failedCount"));
377
+        assertEquals(new BigDecimal("3500.00"), stats.get("totalOverdueAmount"));
378
+        assertEquals(new BigDecimal("1000.00"), stats.get("totalCollectedAmount"));
379
+    }
380
+}