Selaa lähdekoodia

feat(wm-revenue): #53 水表全生命周期管理

- Entity: WaterMeter, MeterInstallRecord, MeterReplaceRecord, MeterLifecycleLog
- Enum: MeterStatus (IN_STOCK/INSTALLED/DISMANTLED/SCRAPPED/REPAIRING)
- Mapper: WaterMeterMapper, MeterInstallRecordMapper, MeterReplaceRecordMapper, MeterLifecycleLogMapper
- Service: MeterLifecycleService (入库/安装/查询), MeterReplaceService (换表/报废/审批), MeterStatsService (统计)
- Controller: MeterController (/api/revenue/meter/*), MeterReplaceController (/api/revenue/meter-replace/*), MeterStatsController (/api/revenue/meter-stats/*)
- DDL: V_meter_lifecycle.sql (4张表 + 索引)
- Test: MeterLifecycleTest (12个测试用例)
bot_dev2 4 päivää sitten
vanhempi
commit
ba535c47e2

+ 133
- 0
sql/V_meter_lifecycle.sql Näytä tiedosto

1
+-- ============================================================
2
+-- 水表全生命周期管理 DDL
3
+-- 包含: rev_water_meter, rev_meter_install_record,
4
+--       rev_meter_replace_record, rev_meter_lifecycle_log
5
+-- ============================================================
6
+
7
+-- 1. 水表主表
8
+CREATE TABLE IF NOT EXISTS rev_water_meter (
9
+    id              BIGSERIAL       PRIMARY KEY,
10
+    meter_no        VARCHAR(64)     NOT NULL UNIQUE,
11
+    model           VARCHAR(64),
12
+    diameter        INTEGER,
13
+    manufacturer    VARCHAR(128),
14
+    production_date DATE,
15
+    install_date    DATE,
16
+    install_address VARCHAR(512),
17
+    customer_no     VARCHAR(64),
18
+    status          VARCHAR(32)     NOT NULL DEFAULT 'IN_STOCK',
19
+    in_stock_time   TIMESTAMP,
20
+    out_stock_time  TIMESTAMP,
21
+    initial_reading NUMERIC(14,4)   DEFAULT 0,
22
+    current_reading NUMERIC(14,4)   DEFAULT 0,
23
+    dismantle_date  DATE,
24
+    scrapped_date   DATE,
25
+    remark          VARCHAR(512),
26
+    created_at      TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
27
+    updated_at      TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP
28
+);
29
+
30
+COMMENT ON TABLE  rev_water_meter                     IS '水表主表';
31
+COMMENT ON COLUMN rev_water_meter.meter_no            IS '水表编号';
32
+COMMENT ON COLUMN rev_water_meter.model               IS '水表型号';
33
+COMMENT ON COLUMN rev_water_meter.diameter            IS '口径(mm)';
34
+COMMENT ON COLUMN rev_water_meter.manufacturer        IS '制造商';
35
+COMMENT ON COLUMN rev_water_meter.production_date     IS '生产日期';
36
+COMMENT ON COLUMN rev_water_meter.install_date        IS '安装日期';
37
+COMMENT ON COLUMN rev_water_meter.install_address     IS '安装地址';
38
+COMMENT ON COLUMN rev_water_meter.customer_no         IS '客户编号';
39
+COMMENT ON COLUMN rev_water_meter.status              IS '状态: IN_STOCK/INSTALLED/DISMANTLED/SCRAPPED/REPAIRING';
40
+COMMENT ON COLUMN rev_water_meter.in_stock_time       IS '入库时间';
41
+COMMENT ON COLUMN rev_water_meter.out_stock_time      IS '出库时间';
42
+COMMENT ON COLUMN rev_water_meter.initial_reading     IS '初始读数';
43
+COMMENT ON COLUMN rev_water_meter.current_reading     IS '当前读数';
44
+COMMENT ON COLUMN rev_water_meter.dismantle_date      IS '拆除日期';
45
+COMMENT ON COLUMN rev_water_meter.scrapped_date       IS '报废日期';
46
+
47
+-- 2. 水表安装记录
48
+CREATE TABLE IF NOT EXISTS rev_meter_install_record (
49
+    id              BIGSERIAL       PRIMARY KEY,
50
+    meter_no        VARCHAR(64)     NOT NULL,
51
+    customer_no     VARCHAR(64),
52
+    installer       VARCHAR(128),
53
+    install_date    DATE,
54
+    install_address VARCHAR(512),
55
+    old_meter_no    VARCHAR(64),
56
+    initial_reading NUMERIC(14,4)   DEFAULT 0,
57
+    remark          VARCHAR(512),
58
+    created_at      TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
59
+    updated_at      TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP
60
+);
61
+
62
+COMMENT ON TABLE rev_meter_install_record IS '水表安装记录';
63
+
64
+-- 3. 水表换表记录
65
+CREATE TABLE IF NOT EXISTS rev_meter_replace_record (
66
+    id               BIGSERIAL       PRIMARY KEY,
67
+    old_meter_no     VARCHAR(64)     NOT NULL,
68
+    new_meter_no     VARCHAR(64)     NOT NULL,
69
+    customer_no      VARCHAR(64),
70
+    replace_type     VARCHAR(32)     NOT NULL,
71
+    reason           VARCHAR(512),
72
+    replacer         VARCHAR(128),
73
+    replace_date     DATE,
74
+    old_reading      NUMERIC(14,4),
75
+    new_reading      NUMERIC(14,4),
76
+    approval_status  VARCHAR(32)     DEFAULT 'APPROVED',
77
+    approver         VARCHAR(128),
78
+    approval_time    TIMESTAMP,
79
+    remark           VARCHAR(512),
80
+    created_at       TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
81
+    updated_at       TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP
82
+);
83
+
84
+COMMENT ON TABLE  rev_meter_replace_record                  IS '水表换表记录';
85
+COMMENT ON COLUMN rev_meter_replace_record.replace_type     IS '换表类型: FAULT-故障, EXPIRED-到期';
86
+COMMENT ON COLUMN rev_meter_replace_record.approval_status  IS '审批状态: PENDING/APPROVED/REJECTED';
87
+
88
+-- 4. 水表生命周期日志
89
+CREATE TABLE IF NOT EXISTS rev_meter_lifecycle_log (
90
+    id              BIGSERIAL       PRIMARY KEY,
91
+    meter_no        VARCHAR(64)     NOT NULL,
92
+    action_type     VARCHAR(32)     NOT NULL,
93
+    action_time     TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
94
+    operator        VARCHAR(128),
95
+    detail          VARCHAR(1024),
96
+    old_meter_no    VARCHAR(64),
97
+    new_meter_no    VARCHAR(64),
98
+    customer_no     VARCHAR(64),
99
+    remark          VARCHAR(512),
100
+    created_at      TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP
101
+);
102
+
103
+COMMENT ON TABLE  rev_meter_lifecycle_log                IS '水表生命周期日志';
104
+COMMENT ON COLUMN rev_meter_lifecycle_log.action_type    IS '操作类型: STOCK_IN/INSTALL/DISMANTLE/REPLACE/SCRAP/REPAIR';
105
+
106
+-- ============================================================
107
+-- 索引
108
+-- ============================================================
109
+
110
+-- rev_water_meter 索引
111
+CREATE INDEX IF NOT EXISTS idx_water_meter_status       ON rev_water_meter (status);
112
+CREATE INDEX IF NOT EXISTS idx_water_meter_customer     ON rev_water_meter (customer_no);
113
+CREATE INDEX IF NOT EXISTS idx_water_meter_diameter     ON rev_water_meter (diameter);
114
+CREATE INDEX IF NOT EXISTS idx_water_meter_manufacturer ON rev_water_meter (manufacturer);
115
+CREATE INDEX IF NOT EXISTS idx_water_meter_created      ON rev_water_meter (created_at);
116
+
117
+-- rev_meter_install_record 索引
118
+CREATE INDEX IF NOT EXISTS idx_install_record_meter     ON rev_meter_install_record (meter_no);
119
+CREATE INDEX IF NOT EXISTS idx_install_record_customer  ON rev_meter_install_record (customer_no);
120
+CREATE INDEX IF NOT EXISTS idx_install_record_date      ON rev_meter_install_record (install_date);
121
+
122
+-- rev_meter_replace_record 索引
123
+CREATE INDEX IF NOT EXISTS idx_replace_record_old       ON rev_meter_replace_record (old_meter_no);
124
+CREATE INDEX IF NOT EXISTS idx_replace_record_new       ON rev_meter_replace_record (new_meter_no);
125
+CREATE INDEX IF NOT EXISTS idx_replace_record_customer  ON rev_meter_replace_record (customer_no);
126
+CREATE INDEX IF NOT EXISTS idx_replace_record_approval  ON rev_meter_replace_record (approval_status);
127
+CREATE INDEX IF NOT EXISTS idx_replace_record_date      ON rev_meter_replace_record (replace_date);
128
+
129
+-- rev_meter_lifecycle_log 索引
130
+CREATE INDEX IF NOT EXISTS idx_lifecycle_log_meter      ON rev_meter_lifecycle_log (meter_no);
131
+CREATE INDEX IF NOT EXISTS idx_lifecycle_log_action     ON rev_meter_lifecycle_log (action_type);
132
+CREATE INDEX IF NOT EXISTS idx_lifecycle_log_time       ON rev_meter_lifecycle_log (action_time);
133
+CREATE INDEX IF NOT EXISTS idx_lifecycle_log_customer   ON rev_meter_lifecycle_log (customer_no);

+ 123
- 0
wm-revenue/src/main/java/com/water/revenue/controller/MeterController.java Näytä tiedosto

1
+package com.water.revenue.controller;
2
+
3
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
4
+import com.water.common.core.result.R;
5
+import com.water.revenue.entity.MeterInstallRecord;
6
+import com.water.revenue.entity.MeterLifecycleLog;
7
+import com.water.revenue.entity.WaterMeter;
8
+import com.water.revenue.enums.MeterStatus;
9
+import com.water.revenue.service.MeterLifecycleService;
10
+import lombok.RequiredArgsConstructor;
11
+import lombok.extern.slf4j.Slf4j;
12
+import org.springframework.web.bind.annotation.*;
13
+
14
+import java.math.BigDecimal;
15
+import java.util.List;
16
+
17
+/**
18
+ * 水表管理 — 入库 / 安装 / 查询
19
+ */
20
+@Slf4j
21
+@RestController
22
+@RequiredArgsConstructor
23
+@RequestMapping("/api/revenue/meter")
24
+public class MeterController {
25
+
26
+    private final MeterLifecycleService lifecycleService;
27
+
28
+    /* ==================== 入库 ==================== */
29
+
30
+    /**
31
+     * 水表入库登记
32
+     */
33
+    @PostMapping("/stock-in")
34
+    public R<WaterMeter> stockIn(@RequestBody WaterMeter meter) {
35
+        try {
36
+            return R.ok(lifecycleService.stockIn(meter));
37
+        } catch (Exception e) {
38
+            log.error("水表入库失败", e);
39
+            return R.fail(e.getMessage());
40
+        }
41
+    }
42
+
43
+    /**
44
+     * 批量入库
45
+     */
46
+    @PostMapping("/stock-in/batch")
47
+    public R<List<WaterMeter>> batchStockIn(@RequestBody List<WaterMeter> meters) {
48
+        try {
49
+            return R.ok(lifecycleService.batchStockIn(meters));
50
+        } catch (Exception e) {
51
+            log.error("批量入库失败", e);
52
+            return R.fail(e.getMessage());
53
+        }
54
+    }
55
+
56
+    /* ==================== 安装 ==================== */
57
+
58
+    /**
59
+     * 水表出库安装
60
+     */
61
+    @PostMapping("/install")
62
+    public R<MeterInstallRecord> install(@RequestParam String meterNo,
63
+                                         @RequestParam String customerNo,
64
+                                         @RequestParam String installer,
65
+                                         @RequestParam String installAddress,
66
+                                         @RequestParam(required = false, defaultValue = "0") BigDecimal initialReading) {
67
+        try {
68
+            return R.ok(lifecycleService.installMeter(meterNo, customerNo, installer, installAddress, initialReading));
69
+        } catch (Exception e) {
70
+            log.error("水表安装失败", e);
71
+            return R.fail(e.getMessage());
72
+        }
73
+    }
74
+
75
+    /* ==================== 查询 ==================== */
76
+
77
+    /**
78
+     * 根据表号查询水表详情
79
+     */
80
+    @GetMapping("/{meterNo}")
81
+    public R<WaterMeter> getByMeterNo(@PathVariable String meterNo) {
82
+        try {
83
+            return R.ok(lifecycleService.getByMeterNo(meterNo));
84
+        } catch (Exception e) {
85
+            return R.fail(e.getMessage());
86
+        }
87
+    }
88
+
89
+    /**
90
+     * 分页查询水表列表
91
+     */
92
+    @GetMapping("/list")
93
+    public R<Page<WaterMeter>> list(@RequestParam(defaultValue = "1") int page,
94
+                                    @RequestParam(defaultValue = "20") int size,
95
+                                    @RequestParam(required = false) MeterStatus status,
96
+                                    @RequestParam(required = false) String keyword) {
97
+        return R.ok(lifecycleService.listMeters(page, size, status, keyword));
98
+    }
99
+
100
+    /**
101
+     * 查询客户绑定的水表
102
+     */
103
+    @GetMapping("/customer/{customerNo}")
104
+    public R<List<WaterMeter>> getByCustomer(@PathVariable String customerNo) {
105
+        return R.ok(lifecycleService.getMetersByCustomer(customerNo));
106
+    }
107
+
108
+    /**
109
+     * 查询安装记录
110
+     */
111
+    @GetMapping("/{meterNo}/install-records")
112
+    public R<List<MeterInstallRecord>> getInstallRecords(@PathVariable String meterNo) {
113
+        return R.ok(lifecycleService.getInstallRecords(meterNo));
114
+    }
115
+
116
+    /**
117
+     * 查询水表生命周期日志
118
+     */
119
+    @GetMapping("/{meterNo}/lifecycle-logs")
120
+    public R<List<MeterLifecycleLog>> getLifecycleLogs(@PathVariable String meterNo) {
121
+        return R.ok(lifecycleService.getLifecycleLogs(meterNo));
122
+    }
123
+}

+ 114
- 0
wm-revenue/src/main/java/com/water/revenue/controller/MeterReplaceController.java Näytä tiedosto

1
+package com.water.revenue.controller;
2
+
3
+import com.water.common.core.result.R;
4
+import com.water.revenue.entity.MeterReplaceRecord;
5
+import com.water.revenue.service.MeterReplaceService;
6
+import lombok.RequiredArgsConstructor;
7
+import lombok.extern.slf4j.Slf4j;
8
+import org.springframework.web.bind.annotation.*;
9
+
10
+import java.math.BigDecimal;
11
+import java.util.List;
12
+
13
+/**
14
+ * 水表换表 / 报废管理
15
+ */
16
+@Slf4j
17
+@RestController
18
+@RequiredArgsConstructor
19
+@RequestMapping("/api/revenue/meter-replace")
20
+public class MeterReplaceController {
21
+
22
+    private final MeterReplaceService replaceService;
23
+
24
+    /**
25
+     * 故障换表
26
+     */
27
+    @PostMapping("/fault")
28
+    public R<MeterReplaceRecord> faultReplace(@RequestParam String oldMeterNo,
29
+                                              @RequestParam String newMeterNo,
30
+                                              @RequestParam String replacer,
31
+                                              @RequestParam String reason,
32
+                                              @RequestParam(required = false) BigDecimal oldReading) {
33
+        try {
34
+            return R.ok(replaceService.faultReplace(oldMeterNo, newMeterNo, replacer, reason, oldReading));
35
+        } catch (Exception e) {
36
+            log.error("故障换表失败", e);
37
+            return R.fail(e.getMessage());
38
+        }
39
+    }
40
+
41
+    /**
42
+     * 到期换表
43
+     */
44
+    @PostMapping("/expired")
45
+    public R<MeterReplaceRecord> expiredReplace(@RequestParam String oldMeterNo,
46
+                                                @RequestParam String newMeterNo,
47
+                                                @RequestParam String replacer,
48
+                                                @RequestParam(required = false) BigDecimal oldReading) {
49
+        try {
50
+            return R.ok(replaceService.expiredReplace(oldMeterNo, newMeterNo, replacer, oldReading));
51
+        } catch (Exception e) {
52
+            log.error("到期换表失败", e);
53
+            return R.fail(e.getMessage());
54
+        }
55
+    }
56
+
57
+    /**
58
+     * 水表报废
59
+     */
60
+    @PostMapping("/scrap")
61
+    public R<Void> scrapMeter(@RequestParam String meterNo,
62
+                              @RequestParam String reason,
63
+                              @RequestParam String operator) {
64
+        try {
65
+            replaceService.scrapMeter(meterNo, reason, operator);
66
+            return R.ok();
67
+        } catch (Exception e) {
68
+            log.error("水表报废失败", e);
69
+            return R.fail(e.getMessage());
70
+        }
71
+    }
72
+
73
+    /**
74
+     * 报废审批
75
+     */
76
+    @PostMapping("/approve/{recordId}")
77
+    public R<MeterReplaceRecord> approve(@PathVariable Long recordId,
78
+                                         @RequestParam String approver,
79
+                                         @RequestParam boolean approved,
80
+                                         @RequestParam(required = false) String remark) {
81
+        try {
82
+            return R.ok(replaceService.approveReplace(recordId, approver, approved, remark));
83
+        } catch (Exception e) {
84
+            log.error("审批失败", e);
85
+            return R.fail(e.getMessage());
86
+        }
87
+    }
88
+
89
+    /**
90
+     * 查询换表记录
91
+     */
92
+    @GetMapping("/records/{meterNo}")
93
+    public R<List<MeterReplaceRecord>> getReplaceRecords(@PathVariable String meterNo) {
94
+        return R.ok(replaceService.getReplaceRecords(meterNo));
95
+    }
96
+
97
+    /**
98
+     * 查询待审批列表
99
+     */
100
+    @GetMapping("/pending")
101
+    public R<List<MeterReplaceRecord>> getPendingApprovals() {
102
+        return R.ok(replaceService.getPendingApprovals());
103
+    }
104
+
105
+    /**
106
+     * 分页查询换表记录
107
+     */
108
+    @GetMapping("/list")
109
+    public R<List<MeterReplaceRecord>> list(@RequestParam(defaultValue = "0") int page,
110
+                                            @RequestParam(defaultValue = "20") int size,
111
+                                            @RequestParam(required = false) String replaceType) {
112
+        return R.ok(replaceService.listReplaceRecords(page, size, replaceType));
113
+    }
114
+}

+ 71
- 0
wm-revenue/src/main/java/com/water/revenue/controller/MeterStatsController.java Näytä tiedosto

1
+package com.water.revenue.controller;
2
+
3
+import com.water.common.core.result.R;
4
+import com.water.revenue.entity.MeterLifecycleLog;
5
+import com.water.revenue.service.MeterStatsService;
6
+import lombok.RequiredArgsConstructor;
7
+import lombok.extern.slf4j.Slf4j;
8
+import org.springframework.web.bind.annotation.*;
9
+
10
+import java.util.List;
11
+import java.util.Map;
12
+
13
+/**
14
+ * 水表统计 — 库存 / 安装 / 报废 / 生命周期日志
15
+ */
16
+@Slf4j
17
+@RestController
18
+@RequiredArgsConstructor
19
+@RequestMapping("/api/revenue/meter-stats")
20
+public class MeterStatsController {
21
+
22
+    private final MeterStatsService statsService;
23
+
24
+    /**
25
+     * 库存统计
26
+     */
27
+    @GetMapping("/inventory")
28
+    public R<Map<String, Object>> getInventoryStats() {
29
+        return R.ok(statsService.getInventoryStats());
30
+    }
31
+
32
+    /**
33
+     * 安装统计
34
+     */
35
+    @GetMapping("/install")
36
+    public R<Map<String, Object>> getInstallStats() {
37
+        return R.ok(statsService.getInstallStats());
38
+    }
39
+
40
+    /**
41
+     * 报废统计
42
+     */
43
+    @GetMapping("/scrap")
44
+    public R<Map<String, Object>> getScrapStats() {
45
+        return R.ok(statsService.getScrapStats());
46
+    }
47
+
48
+    /**
49
+     * 水表全生命周期日志
50
+     */
51
+    @GetMapping("/lifecycle/{meterNo}")
52
+    public R<List<MeterLifecycleLog>> getLifecycleLogs(@PathVariable String meterNo) {
53
+        return R.ok(statsService.getLifecycleLogs(meterNo));
54
+    }
55
+
56
+    /**
57
+     * 最近操作记录
58
+     */
59
+    @GetMapping("/recent")
60
+    public R<List<MeterLifecycleLog>> getRecentLogs(@RequestParam(defaultValue = "20") int limit) {
61
+        return R.ok(statsService.getRecentLogs(limit));
62
+    }
63
+
64
+    /**
65
+     * 操作类型统计
66
+     */
67
+    @GetMapping("/action-types")
68
+    public R<List<Map<String, Object>>> getActionTypeStats() {
69
+        return R.ok(statsService.getActionTypeStats());
70
+    }
71
+}

+ 51
- 0
wm-revenue/src/main/java/com/water/revenue/entity/MeterInstallRecord.java Näytä tiedosto

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_meter_install_record")
15
+public class MeterInstallRecord {
16
+
17
+    @TableId(type = IdType.AUTO)
18
+    private Long id;
19
+
20
+    /** 水表编号 */
21
+    private String meterNo;
22
+
23
+    /** 客户编号 */
24
+    private String customerNo;
25
+
26
+    /** 安装人 */
27
+    private String installer;
28
+
29
+    /** 安装日期 */
30
+    private LocalDate installDate;
31
+
32
+    /** 安装地址 */
33
+    private String installAddress;
34
+
35
+    /** 旧水表编号(换表安装时) */
36
+    private String oldMeterNo;
37
+
38
+    /** 初始读数 */
39
+    private BigDecimal initialReading;
40
+
41
+    /** 备注 */
42
+    private String remark;
43
+
44
+    /** 创建时间 */
45
+    @TableField(fill = FieldFill.INSERT)
46
+    private LocalDateTime createdAt;
47
+
48
+    /** 更新时间 */
49
+    @TableField(fill = FieldFill.INSERT_UPDATE)
50
+    private LocalDateTime updatedAt;
51
+}

+ 48
- 0
wm-revenue/src/main/java/com/water/revenue/entity/MeterLifecycleLog.java Näytä tiedosto

1
+package com.water.revenue.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+
6
+import java.time.LocalDateTime;
7
+
8
+/**
9
+ * 水表生命周期日志
10
+ */
11
+@Data
12
+@TableName("rev_meter_lifecycle_log")
13
+public class MeterLifecycleLog {
14
+
15
+    @TableId(type = IdType.AUTO)
16
+    private Long id;
17
+
18
+    /** 水表编号 */
19
+    private String meterNo;
20
+
21
+    /** 操作类型:STOCK_IN/INSTALL/DISMANTLE/REPLACE/SCRAP/REPAIR */
22
+    private String actionType;
23
+
24
+    /** 操作时间 */
25
+    private LocalDateTime actionTime;
26
+
27
+    /** 操作人 */
28
+    private String operator;
29
+
30
+    /** 操作详情 */
31
+    private String detail;
32
+
33
+    /** 旧表编号 */
34
+    private String oldMeterNo;
35
+
36
+    /** 新表编号 */
37
+    private String newMeterNo;
38
+
39
+    /** 关联客户编号 */
40
+    private String customerNo;
41
+
42
+    /** 备注 */
43
+    private String remark;
44
+
45
+    /** 创建时间 */
46
+    @TableField(fill = FieldFill.INSERT)
47
+    private LocalDateTime createdAt;
48
+}

+ 66
- 0
wm-revenue/src/main/java/com/water/revenue/entity/MeterReplaceRecord.java Näytä tiedosto

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_meter_replace_record")
15
+public class MeterReplaceRecord {
16
+
17
+    @TableId(type = IdType.AUTO)
18
+    private Long id;
19
+
20
+    /** 旧水表编号 */
21
+    private String oldMeterNo;
22
+
23
+    /** 新水表编号 */
24
+    private String newMeterNo;
25
+
26
+    /** 客户编号 */
27
+    private String customerNo;
28
+
29
+    /** 换表类型:FAULT-故障换表, EXPIRED-到期换表 */
30
+    private String replaceType;
31
+
32
+    /** 换表原因 */
33
+    private String reason;
34
+
35
+    /** 换表人 */
36
+    private String replacer;
37
+
38
+    /** 换表日期 */
39
+    private LocalDate replaceDate;
40
+
41
+    /** 旧表读数 */
42
+    private BigDecimal oldReading;
43
+
44
+    /** 新表初始读数 */
45
+    private BigDecimal newReading;
46
+
47
+    /** 审批状态:PENDING-待审批, APPROVED-已通过, REJECTED-已拒绝 */
48
+    private String approvalStatus;
49
+
50
+    /** 审批人 */
51
+    private String approver;
52
+
53
+    /** 审批时间 */
54
+    private LocalDateTime approvalTime;
55
+
56
+    /** 备注 */
57
+    private String remark;
58
+
59
+    /** 创建时间 */
60
+    @TableField(fill = FieldFill.INSERT)
61
+    private LocalDateTime createdAt;
62
+
63
+    /** 更新时间 */
64
+    @TableField(fill = FieldFill.INSERT_UPDATE)
65
+    private LocalDateTime updatedAt;
66
+}

+ 76
- 0
wm-revenue/src/main/java/com/water/revenue/entity/WaterMeter.java Näytä tiedosto

1
+package com.water.revenue.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import com.water.revenue.enums.MeterStatus;
5
+import lombok.Data;
6
+
7
+import java.math.BigDecimal;
8
+import java.time.LocalDate;
9
+import java.time.LocalDateTime;
10
+
11
+/**
12
+ * 水表实体
13
+ */
14
+@Data
15
+@TableName("rev_water_meter")
16
+public class WaterMeter {
17
+
18
+    @TableId(type = IdType.AUTO)
19
+    private Long id;
20
+
21
+    /** 水表编号 */
22
+    private String meterNo;
23
+
24
+    /** 水表型号 */
25
+    private String model;
26
+
27
+    /** 口径(mm) */
28
+    private Integer diameter;
29
+
30
+    /** 制造商 */
31
+    private String manufacturer;
32
+
33
+    /** 生产日期 */
34
+    private LocalDate productionDate;
35
+
36
+    /** 安装日期 */
37
+    private LocalDate installDate;
38
+
39
+    /** 安装地址 */
40
+    private String installAddress;
41
+
42
+    /** 客户编号 */
43
+    private String customerNo;
44
+
45
+    /** 水表状态 */
46
+    private MeterStatus status;
47
+
48
+    /** 入库时间 */
49
+    private LocalDateTime inStockTime;
50
+
51
+    /** 出库时间 */
52
+    private LocalDateTime outStockTime;
53
+
54
+    /** 初始读数 */
55
+    private BigDecimal initialReading;
56
+
57
+    /** 当前读数 */
58
+    private BigDecimal currentReading;
59
+
60
+    /** 拆除日期 */
61
+    private LocalDate dismantleDate;
62
+
63
+    /** 报废日期 */
64
+    private LocalDate scrappedDate;
65
+
66
+    /** 备注 */
67
+    private String remark;
68
+
69
+    /** 创建时间 */
70
+    @TableField(fill = FieldFill.INSERT)
71
+    private LocalDateTime createdAt;
72
+
73
+    /** 更新时间 */
74
+    @TableField(fill = FieldFill.INSERT_UPDATE)
75
+    private LocalDateTime updatedAt;
76
+}

+ 19
- 0
wm-revenue/src/main/java/com/water/revenue/enums/MeterStatus.java Näytä tiedosto

1
+package com.water.revenue.enums;
2
+
3
+import lombok.Getter;
4
+
5
+@Getter
6
+public enum MeterStatus {
7
+
8
+    IN_STOCK("在库"),
9
+    INSTALLED("已安装"),
10
+    DISMANTLED("已拆除"),
11
+    SCRAPPED("已报废"),
12
+    REPAIRING("维修中");
13
+
14
+    private final String description;
15
+
16
+    MeterStatus(String description) {
17
+        this.description = description;
18
+    }
19
+}

+ 19
- 0
wm-revenue/src/main/java/com/water/revenue/mapper/MeterInstallRecordMapper.java Näytä tiedosto

1
+package com.water.revenue.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.revenue.entity.MeterInstallRecord;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Param;
7
+import org.apache.ibatis.annotations.Select;
8
+
9
+import java.util.List;
10
+
11
+@Mapper
12
+public interface MeterInstallRecordMapper extends BaseMapper<MeterInstallRecord> {
13
+
14
+    @Select("SELECT * FROM rev_meter_install_record WHERE meter_no = #{meterNo} ORDER BY install_date DESC")
15
+    List<MeterInstallRecord> selectByMeterNo(@Param("meterNo") String meterNo);
16
+
17
+    @Select("SELECT * FROM rev_meter_install_record WHERE customer_no = #{customerNo} ORDER BY install_date DESC")
18
+    List<MeterInstallRecord> selectByCustomerNo(@Param("customerNo") String customerNo);
19
+}

+ 23
- 0
wm-revenue/src/main/java/com/water/revenue/mapper/MeterLifecycleLogMapper.java Näytä tiedosto

1
+package com.water.revenue.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.revenue.entity.MeterLifecycleLog;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Param;
7
+import org.apache.ibatis.annotations.Select;
8
+
9
+import java.util.List;
10
+import java.util.Map;
11
+
12
+@Mapper
13
+public interface MeterLifecycleLogMapper extends BaseMapper<MeterLifecycleLog> {
14
+
15
+    @Select("SELECT * FROM rev_meter_lifecycle_log WHERE meter_no = #{meterNo} ORDER BY action_time ASC")
16
+    List<MeterLifecycleLog> selectByMeterNo(@Param("meterNo") String meterNo);
17
+
18
+    @Select("SELECT * FROM rev_meter_lifecycle_log ORDER BY action_time DESC LIMIT #{limit}")
19
+    List<MeterLifecycleLog> selectRecent(@Param("limit") int limit);
20
+
21
+    @Select("SELECT action_type, COUNT(*) as count FROM rev_meter_lifecycle_log GROUP BY action_type")
22
+    List<Map<String, Object>> countByActionType();
23
+}

+ 22
- 0
wm-revenue/src/main/java/com/water/revenue/mapper/MeterReplaceRecordMapper.java Näytä tiedosto

1
+package com.water.revenue.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.revenue.entity.MeterReplaceRecord;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Param;
7
+import org.apache.ibatis.annotations.Select;
8
+
9
+import java.util.List;
10
+
11
+@Mapper
12
+public interface MeterReplaceRecordMapper extends BaseMapper<MeterReplaceRecord> {
13
+
14
+    @Select("SELECT * FROM rev_meter_replace_record WHERE old_meter_no = #{meterNo} OR new_meter_no = #{meterNo} ORDER BY replace_date DESC")
15
+    List<MeterReplaceRecord> selectByMeterNo(@Param("meterNo") String meterNo);
16
+
17
+    @Select("SELECT * FROM rev_meter_replace_record WHERE customer_no = #{customerNo} ORDER BY replace_date DESC")
18
+    List<MeterReplaceRecord> selectByCustomerNo(@Param("customerNo") String customerNo);
19
+
20
+    @Select("SELECT * FROM rev_meter_replace_record WHERE approval_status = 'PENDING' ORDER BY created_at ASC")
21
+    List<MeterReplaceRecord> selectPendingApprovals();
22
+}

+ 29
- 0
wm-revenue/src/main/java/com/water/revenue/mapper/WaterMeterMapper.java Näytä tiedosto

1
+package com.water.revenue.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.revenue.entity.WaterMeter;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Param;
7
+import org.apache.ibatis.annotations.Select;
8
+
9
+import java.util.List;
10
+import java.util.Map;
11
+
12
+@Mapper
13
+public interface WaterMeterMapper extends BaseMapper<WaterMeter> {
14
+
15
+    @Select("SELECT * FROM rev_water_meter WHERE meter_no = #{meterNo} LIMIT 1")
16
+    WaterMeter selectByMeterNo(@Param("meterNo") String meterNo);
17
+
18
+    @Select("SELECT * FROM rev_water_meter WHERE customer_no = #{customerNo} AND status = 'INSTALLED'")
19
+    List<WaterMeter> selectByCustomerNo(@Param("customerNo") String customerNo);
20
+
21
+    @Select("SELECT status, COUNT(*) as count FROM rev_water_meter GROUP BY status")
22
+    List<Map<String, Object>> countByStatus();
23
+
24
+    @Select("SELECT diameter, COUNT(*) as count FROM rev_water_meter WHERE status = 'IN_STOCK' GROUP BY diameter")
25
+    List<Map<String, Object>> countInStockByDiameter();
26
+
27
+    @Select("SELECT manufacturer, COUNT(*) as count FROM rev_water_meter WHERE status = 'IN_STOCK' GROUP BY manufacturer")
28
+    List<Map<String, Object>> countInStockByManufacturer();
29
+}

+ 181
- 0
wm-revenue/src/main/java/com/water/revenue/service/MeterLifecycleService.java Näytä tiedosto

1
+package com.water.revenue.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
5
+import com.water.revenue.entity.MeterInstallRecord;
6
+import com.water.revenue.entity.MeterLifecycleLog;
7
+import com.water.revenue.entity.WaterMeter;
8
+import com.water.revenue.enums.MeterStatus;
9
+import com.water.revenue.mapper.MeterInstallRecordMapper;
10
+import com.water.revenue.mapper.MeterLifecycleLogMapper;
11
+import com.water.revenue.mapper.WaterMeterMapper;
12
+import lombok.RequiredArgsConstructor;
13
+import lombok.extern.slf4j.Slf4j;
14
+import org.springframework.stereotype.Service;
15
+import org.springframework.transaction.annotation.Transactional;
16
+
17
+import java.math.BigDecimal;
18
+import java.time.LocalDate;
19
+import java.time.LocalDateTime;
20
+import java.util.List;
21
+
22
+/**
23
+ * 水表生命周期服务 — 入库 / 出库安装 / 绑定用户 / 安装记录
24
+ */
25
+@Slf4j
26
+@Service
27
+@RequiredArgsConstructor
28
+public class MeterLifecycleService {
29
+
30
+    private final WaterMeterMapper waterMeterMapper;
31
+    private final MeterInstallRecordMapper installRecordMapper;
32
+    private final MeterLifecycleLogMapper lifecycleLogMapper;
33
+
34
+    /* ==================== 入库登记 ==================== */
35
+
36
+    /**
37
+     * 水表入库登记
38
+     */
39
+    @Transactional
40
+    public WaterMeter stockIn(WaterMeter meter) {
41
+        // 检查表号唯一性
42
+        WaterMeter existing = waterMeterMapper.selectByMeterNo(meter.getMeterNo());
43
+        if (existing != null) {
44
+            throw new IllegalStateException("水表编号已存在: " + meter.getMeterNo());
45
+        }
46
+
47
+        meter.setStatus(MeterStatus.IN_STOCK);
48
+        meter.setInStockTime(LocalDateTime.now());
49
+        meter.setInitialReading(BigDecimal.ZERO);
50
+        meter.setCurrentReading(BigDecimal.ZERO);
51
+        waterMeterMapper.insert(meter);
52
+
53
+        // 记录日志
54
+        addLog(meter.getMeterNo(), "STOCK_IN", meter.getMeterNo(), null, null,
55
+                "水表入库: 型号=" + meter.getModel() + ", 口径=" + meter.getDiameter());
56
+
57
+        log.info("水表入库成功: {}", meter.getMeterNo());
58
+        return meter;
59
+    }
60
+
61
+    /**
62
+     * 批量入库
63
+     */
64
+    @Transactional
65
+    public List<WaterMeter> batchStockIn(List<WaterMeter> meters) {
66
+        for (WaterMeter meter : meters) {
67
+            stockIn(meter);
68
+        }
69
+        return meters;
70
+    }
71
+
72
+    /* ==================== 出库安装 ==================== */
73
+
74
+    /**
75
+     * 水表出库安装(绑定客户)
76
+     */
77
+    @Transactional
78
+    public MeterInstallRecord installMeter(String meterNo, String customerNo, String installer,
79
+                                           String installAddress, BigDecimal initialReading) {
80
+        WaterMeter meter = waterMeterMapper.selectByMeterNo(meterNo);
81
+        if (meter == null) {
82
+            throw new IllegalArgumentException("水表不存在: " + meterNo);
83
+        }
84
+        if (meter.getStatus() != MeterStatus.IN_STOCK) {
85
+            throw new IllegalStateException("水表状态不允许安装, 当前状态: " + meter.getStatus());
86
+        }
87
+
88
+        // 更新水表状态
89
+        meter.setStatus(MeterStatus.INSTALLED);
90
+        meter.setCustomerNo(customerNo);
91
+        meter.setInstallAddress(installAddress);
92
+        meter.setInstallDate(LocalDate.now());
93
+        meter.setOutStockTime(LocalDateTime.now());
94
+        meter.setInitialReading(initialReading != null ? initialReading : BigDecimal.ZERO);
95
+        meter.setCurrentReading(meter.getInitialReading());
96
+        waterMeterMapper.updateById(meter);
97
+
98
+        // 创建安装记录
99
+        MeterInstallRecord record = new MeterInstallRecord();
100
+        record.setMeterNo(meterNo);
101
+        record.setCustomerNo(customerNo);
102
+        record.setInstaller(installer);
103
+        record.setInstallDate(LocalDate.now());
104
+        record.setInstallAddress(installAddress);
105
+        record.setInitialReading(meter.getInitialReading());
106
+        installRecordMapper.insert(record);
107
+
108
+        // 记录日志
109
+        addLog(meterNo, "INSTALL", meterNo, null, customerNo,
110
+                "水表安装: 客户=" + customerNo + ", 地址=" + installAddress + ", 安装人=" + installer);
111
+
112
+        log.info("水表安装成功: {} -> 客户 {}", meterNo, customerNo);
113
+        return record;
114
+    }
115
+
116
+    /* ==================== 查询 ==================== */
117
+
118
+    /**
119
+     * 根据表号查询水表
120
+     */
121
+    public WaterMeter getByMeterNo(String meterNo) {
122
+        WaterMeter meter = waterMeterMapper.selectByMeterNo(meterNo);
123
+        if (meter == null) {
124
+            throw new IllegalArgumentException("水表不存在: " + meterNo);
125
+        }
126
+        return meter;
127
+    }
128
+
129
+    /**
130
+     * 分页查询水表列表
131
+     */
132
+    public Page<WaterMeter> listMeters(int page, int size, MeterStatus status, String keyword) {
133
+        LambdaQueryWrapper<WaterMeter> wrapper = new LambdaQueryWrapper<>();
134
+        if (status != null) {
135
+            wrapper.eq(WaterMeter::getStatus, status);
136
+        }
137
+        if (keyword != null && !keyword.isEmpty()) {
138
+            wrapper.and(w -> w.like(WaterMeter::getMeterNo, keyword)
139
+                    .or().like(WaterMeter::getCustomerNo, keyword)
140
+                    .or().like(WaterMeter::getInstallAddress, keyword));
141
+        }
142
+        wrapper.orderByDesc(WaterMeter::getCreatedAt);
143
+        return waterMeterMapper.selectPage(new Page<>(page, size), wrapper);
144
+    }
145
+
146
+    /**
147
+     * 查询客户绑定的水表
148
+     */
149
+    public List<WaterMeter> getMetersByCustomer(String customerNo) {
150
+        return waterMeterMapper.selectByCustomerNo(customerNo);
151
+    }
152
+
153
+    /**
154
+     * 查询安装记录
155
+     */
156
+    public List<MeterInstallRecord> getInstallRecords(String meterNo) {
157
+        return installRecordMapper.selectByMeterNo(meterNo);
158
+    }
159
+
160
+    /**
161
+     * 查询水表生命周期日志
162
+     */
163
+    public List<MeterLifecycleLog> getLifecycleLogs(String meterNo) {
164
+        return lifecycleLogMapper.selectByMeterNo(meterNo);
165
+    }
166
+
167
+    /* ==================== 内部方法 ==================== */
168
+
169
+    private void addLog(String meterNo, String actionType, String detail,
170
+                        String oldMeterNo, String customerNo, String logDetail) {
171
+        MeterLifecycleLog l = new MeterLifecycleLog();
172
+        l.setMeterNo(meterNo);
173
+        l.setActionType(actionType);
174
+        l.setActionTime(LocalDateTime.now());
175
+        l.setOperator("system");
176
+        l.setDetail(logDetail);
177
+        l.setOldMeterNo(oldMeterNo);
178
+        l.setCustomerNo(customerNo);
179
+        lifecycleLogMapper.insert(l);
180
+    }
181
+}

+ 211
- 0
wm-revenue/src/main/java/com/water/revenue/service/MeterReplaceService.java Näytä tiedosto

1
+package com.water.revenue.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.water.revenue.entity.MeterLifecycleLog;
5
+import com.water.revenue.entity.MeterReplaceRecord;
6
+import com.water.revenue.entity.WaterMeter;
7
+import com.water.revenue.enums.MeterStatus;
8
+import com.water.revenue.mapper.MeterLifecycleLogMapper;
9
+import com.water.revenue.mapper.MeterReplaceRecordMapper;
10
+import com.water.revenue.mapper.WaterMeterMapper;
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.util.List;
20
+
21
+/**
22
+ * 水表换表 / 报废服务
23
+ */
24
+@Slf4j
25
+@Service
26
+@RequiredArgsConstructor
27
+public class MeterReplaceService {
28
+
29
+    private final WaterMeterMapper waterMeterMapper;
30
+    private final MeterReplaceRecordMapper replaceRecordMapper;
31
+    private final MeterLifecycleLogMapper lifecycleLogMapper;
32
+
33
+    /* ==================== 故障换表 ==================== */
34
+
35
+    /**
36
+     * 故障换表
37
+     */
38
+    @Transactional
39
+    public MeterReplaceRecord faultReplace(String oldMeterNo, String newMeterNo,
40
+                                           String replacer, String reason, BigDecimal oldReading) {
41
+        return doReplace(oldMeterNo, newMeterNo, "FAULT", replacer, reason, oldReading);
42
+    }
43
+
44
+    /**
45
+     * 到期换表
46
+     */
47
+    @Transactional
48
+    public MeterReplaceRecord expiredReplace(String oldMeterNo, String newMeterNo,
49
+                                             String replacer, BigDecimal oldReading) {
50
+        return doReplace(oldMeterNo, newMeterNo, "EXPIRED", replacer, "水表到期更换", oldReading);
51
+    }
52
+
53
+    /**
54
+     * 执行换表
55
+     */
56
+    @Transactional
57
+    protected MeterReplaceRecord doReplace(String oldMeterNo, String newMeterNo,
58
+                                           String replaceType, String replacer,
59
+                                           String reason, BigDecimal oldReading) {
60
+        WaterMeter oldMeter = waterMeterMapper.selectByMeterNo(oldMeterNo);
61
+        if (oldMeter == null) {
62
+            throw new IllegalArgumentException("旧水表不存在: " + oldMeterNo);
63
+        }
64
+        if (oldMeter.getStatus() != MeterStatus.INSTALLED) {
65
+            throw new IllegalStateException("旧水表状态不允许换表, 当前状态: " + oldMeter.getStatus());
66
+        }
67
+
68
+        WaterMeter newMeter = waterMeterMapper.selectByMeterNo(newMeterNo);
69
+        if (newMeter == null) {
70
+            throw new IllegalArgumentException("新水表不存在: " + newMeterNo);
71
+        }
72
+        if (newMeter.getStatus() != MeterStatus.IN_STOCK) {
73
+            throw new IllegalStateException("新水表状态不允许安装, 当前状态: " + newMeter.getStatus());
74
+        }
75
+
76
+        // 旧表拆除
77
+        oldMeter.setStatus(MeterStatus.DISMANTLED);
78
+        oldMeter.setDismantleDate(LocalDate.now());
79
+        oldMeter.setCurrentReading(oldReading != null ? oldReading : oldMeter.getCurrentReading());
80
+        waterMeterMapper.updateById(oldMeter);
81
+
82
+        // 新表安装(继承客户信息)
83
+        newMeter.setStatus(MeterStatus.INSTALLED);
84
+        newMeter.setCustomerNo(oldMeter.getCustomerNo());
85
+        newMeter.setInstallAddress(oldMeter.getInstallAddress());
86
+        newMeter.setInstallDate(LocalDate.now());
87
+        newMeter.setOutStockTime(LocalDateTime.now());
88
+        newMeter.setInitialReading(BigDecimal.ZERO);
89
+        newMeter.setCurrentReading(BigDecimal.ZERO);
90
+        waterMeterMapper.updateById(newMeter);
91
+
92
+        // 创建换表记录
93
+        MeterReplaceRecord record = new MeterReplaceRecord();
94
+        record.setOldMeterNo(oldMeterNo);
95
+        record.setNewMeterNo(newMeterNo);
96
+        record.setCustomerNo(oldMeter.getCustomerNo());
97
+        record.setReplaceType(replaceType);
98
+        record.setReason(reason);
99
+        record.setReplacer(replacer);
100
+        record.setReplaceDate(LocalDate.now());
101
+        record.setOldReading(oldReading != null ? oldReading : oldMeter.getCurrentReading());
102
+        record.setNewReading(BigDecimal.ZERO);
103
+        record.setApprovalStatus("APPROVED");
104
+        replaceRecordMapper.insert(record);
105
+
106
+        // 记录日志
107
+        addLog(oldMeterNo, "DISMANTLE", oldMeterNo, newMeterNo, oldMeter.getCustomerNo(),
108
+                "旧表拆除: 原因=" + reason + ", 换表人=" + replacer);
109
+        addLog(newMeterNo, "REPLACE", oldMeterNo, newMeterNo, oldMeter.getCustomerNo(),
110
+                "新表安装(换表): 替换旧表 " + oldMeterNo + ", 换表人=" + replacer);
111
+
112
+        log.info("换表成功: {} -> {}", oldMeterNo, newMeterNo);
113
+        return record;
114
+    }
115
+
116
+    /* ==================== 到期报废 ==================== */
117
+
118
+    /**
119
+     * 水表报废
120
+     */
121
+    @Transactional
122
+    public void scrapMeter(String meterNo, String reason, String operator) {
123
+        WaterMeter meter = waterMeterMapper.selectByMeterNo(meterNo);
124
+        if (meter == null) {
125
+            throw new IllegalArgumentException("水表不存在: " + meterNo);
126
+        }
127
+        if (meter.getStatus() == MeterStatus.INSTALLED) {
128
+            throw new IllegalStateException("已安装的水表不能直接报废, 请先拆除");
129
+        }
130
+
131
+        meter.setStatus(MeterStatus.SCRAPPED);
132
+        meter.setScrappedDate(LocalDate.now());
133
+        meter.setRemark(reason);
134
+        waterMeterMapper.updateById(meter);
135
+
136
+        addLog(meterNo, "SCRAP", meterNo, null, meter.getCustomerNo(),
137
+                "水表报废: 原因=" + reason + ", 操作人=" + operator);
138
+
139
+        log.info("水表报废: {} - {}", meterNo, reason);
140
+    }
141
+
142
+    /**
143
+     * 报废审批
144
+     */
145
+    @Transactional
146
+    public MeterReplaceRecord approveReplace(Long recordId, String approver, boolean approved, String remark) {
147
+        MeterReplaceRecord record = replaceRecordMapper.selectById(recordId);
148
+        if (record == null) {
149
+            throw new IllegalArgumentException("换表记录不存在: " + recordId);
150
+        }
151
+        if (!"PENDING".equals(record.getApprovalStatus())) {
152
+            throw new IllegalStateException("该记录已审批, 当前状态: " + record.getApprovalStatus());
153
+        }
154
+
155
+        record.setApprovalStatus(approved ? "APPROVED" : "REJECTED");
156
+        record.setApprover(approver);
157
+        record.setApprovalTime(LocalDateTime.now());
158
+        if (remark != null && !remark.isEmpty()) {
159
+            record.setRemark(record.getRemark() != null ? record.getRemark() + "; " + remark : remark);
160
+        }
161
+        replaceRecordMapper.updateById(record);
162
+
163
+        log.info("换表审批: id={}, approved={}, approver={}", recordId, approved, approver);
164
+        return record;
165
+    }
166
+
167
+    /* ==================== 查询 ==================== */
168
+
169
+    /**
170
+     * 查询换表记录
171
+     */
172
+    public List<MeterReplaceRecord> getReplaceRecords(String meterNo) {
173
+        return replaceRecordMapper.selectByMeterNo(meterNo);
174
+    }
175
+
176
+    /**
177
+     * 查询待审批列表
178
+     */
179
+    public List<MeterReplaceRecord> getPendingApprovals() {
180
+        return replaceRecordMapper.selectPendingApprovals();
181
+    }
182
+
183
+    /**
184
+     * 分页查询换表记录
185
+     */
186
+    public List<MeterReplaceRecord> listReplaceRecords(int page, int size, String replaceType) {
187
+        LambdaQueryWrapper<MeterReplaceRecord> wrapper = new LambdaQueryWrapper<>();
188
+        if (replaceType != null && !replaceType.isEmpty()) {
189
+            wrapper.eq(MeterReplaceRecord::getReplaceType, replaceType);
190
+        }
191
+        wrapper.orderByDesc(MeterReplaceRecord::getCreatedAt);
192
+        wrapper.last("LIMIT " + size + " OFFSET " + (page * size));
193
+        return replaceRecordMapper.selectList(wrapper);
194
+    }
195
+
196
+    /* ==================== 内部方法 ==================== */
197
+
198
+    private void addLog(String meterNo, String actionType, String oldMeterNo,
199
+                        String newMeterNo, String customerNo, String detail) {
200
+        MeterLifecycleLog l = new MeterLifecycleLog();
201
+        l.setMeterNo(meterNo);
202
+        l.setActionType(actionType);
203
+        l.setActionTime(LocalDateTime.now());
204
+        l.setOperator("system");
205
+        l.setDetail(detail);
206
+        l.setOldMeterNo(oldMeterNo);
207
+        l.setNewMeterNo(newMeterNo);
208
+        l.setCustomerNo(customerNo);
209
+        lifecycleLogMapper.insert(l);
210
+    }
211
+}

+ 144
- 0
wm-revenue/src/main/java/com/water/revenue/service/MeterStatsService.java Näytä tiedosto

1
+package com.water.revenue.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.water.revenue.entity.MeterLifecycleLog;
5
+import com.water.revenue.entity.WaterMeter;
6
+import com.water.revenue.enums.MeterStatus;
7
+import com.water.revenue.mapper.MeterLifecycleLogMapper;
8
+import com.water.revenue.mapper.MeterReplaceRecordMapper;
9
+import com.water.revenue.mapper.WaterMeterMapper;
10
+import lombok.RequiredArgsConstructor;
11
+import lombok.extern.slf4j.Slf4j;
12
+import org.springframework.stereotype.Service;
13
+
14
+import java.util.*;
15
+import java.util.stream.Collectors;
16
+
17
+/**
18
+ * 水表统计服务 — 生命周期日志 / 库存统计 / 安装统计 / 报废统计
19
+ */
20
+@Slf4j
21
+@Service
22
+@RequiredArgsConstructor
23
+public class MeterStatsService {
24
+
25
+    private final WaterMeterMapper waterMeterMapper;
26
+    private final MeterLifecycleLogMapper lifecycleLogMapper;
27
+    private final MeterReplaceRecordMapper replaceRecordMapper;
28
+
29
+    /* ==================== 库存统计 ==================== */
30
+
31
+    /**
32
+     * 库存综合统计
33
+     */
34
+    public Map<String, Object> getInventoryStats() {
35
+        Map<String, Object> stats = new LinkedHashMap<>();
36
+
37
+        // 按状态统计
38
+        List<Map<String, Object>> statusStats = waterMeterMapper.countByStatus();
39
+        stats.put("statusStats", statusStats);
40
+
41
+        // 在库数量
42
+        long inStock = countByStatus(MeterStatus.IN_STOCK);
43
+        stats.put("totalInStock", inStock);
44
+
45
+        // 已安装数量
46
+        long installed = countByStatus(MeterStatus.INSTALLED);
47
+        stats.put("totalInstalled", installed);
48
+
49
+        // 已拆除
50
+        long dismantled = countByStatus(MeterStatus.DISMANTLED);
51
+        stats.put("totalDismantled", dismantled);
52
+
53
+        // 已报废
54
+        long scrapped = countByStatus(MeterStatus.SCRAPPED);
55
+        stats.put("totalScrapped", scrapped);
56
+
57
+        // 维修中
58
+        long repairing = countByStatus(MeterStatus.REPAIRING);
59
+        stats.put("totalRepairing", repairing);
60
+
61
+        // 按口径统计库存
62
+        stats.put("byDiameter", waterMeterMapper.countInStockByDiameter());
63
+
64
+        // 按制造商统计库存
65
+        stats.put("byManufacturer", waterMeterMapper.countInStockByManufacturer());
66
+
67
+        // 总量
68
+        long total = waterMeterMapper.selectCount(null);
69
+        stats.put("totalCount", total);
70
+
71
+        return stats;
72
+    }
73
+
74
+    /* ==================== 安装统计 ==================== */
75
+
76
+    /**
77
+     * 安装统计
78
+     */
79
+    public Map<String, Object> getInstallStats() {
80
+        Map<String, Object> stats = new LinkedHashMap<>();
81
+
82
+        long installed = countByStatus(MeterStatus.INSTALLED);
83
+        stats.put("totalInstalled", installed);
84
+
85
+        // 按口径统计已安装
86
+        LambdaQueryWrapper<WaterMeter> wrapper = new LambdaQueryWrapper<>();
87
+        wrapper.eq(WaterMeter::getStatus, MeterStatus.INSTALLED)
88
+                .select(WaterMeter::getDiameter)
89
+                .groupBy(WaterMeter::getDiameter);
90
+        // Using raw SQL for aggregate
91
+        stats.put("installedByDiameter",
92
+                waterMeterMapper.selectMaps(
93
+                        new LambdaQueryWrapper<WaterMeter>()
94
+                                .select(WaterMeter::getDiameter)
95
+                                .eq(WaterMeter::getStatus, MeterStatus.INSTALLED)
96
+                                .groupBy(WaterMeter::getDiameter)));
97
+
98
+        return stats;
99
+    }
100
+
101
+    /* ==================== 报废统计 ==================== */
102
+
103
+    /**
104
+     * 报废统计
105
+     */
106
+    public Map<String, Object> getScrapStats() {
107
+        Map<String, Object> stats = new LinkedHashMap<>();
108
+
109
+        long scrapped = countByStatus(MeterStatus.SCRAPPED);
110
+        stats.put("totalScrapped", scrapped);
111
+
112
+        return stats;
113
+    }
114
+
115
+    /* ==================== 生命周期日志 ==================== */
116
+
117
+    /**
118
+     * 获取水表全生命周期日志
119
+     */
120
+    public List<MeterLifecycleLog> getLifecycleLogs(String meterNo) {
121
+        return lifecycleLogMapper.selectByMeterNo(meterNo);
122
+    }
123
+
124
+    /**
125
+     * 最近操作记录
126
+     */
127
+    public List<MeterLifecycleLog> getRecentLogs(int limit) {
128
+        return lifecycleLogMapper.selectRecent(limit);
129
+    }
130
+
131
+    /**
132
+     * 操作类型统计
133
+     */
134
+    public List<Map<String, Object>> getActionTypeStats() {
135
+        return lifecycleLogMapper.countByActionType();
136
+    }
137
+
138
+    /* ==================== 内部方法 ==================== */
139
+
140
+    private long countByStatus(MeterStatus status) {
141
+        return waterMeterMapper.selectCount(
142
+                new LambdaQueryWrapper<WaterMeter>().eq(WaterMeter::getStatus, status));
143
+    }
144
+}

+ 313
- 0
wm-revenue/src/test/java/com/water/revenue/service/MeterLifecycleTest.java Näytä tiedosto

1
+package com.water.revenue.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.water.revenue.entity.MeterInstallRecord;
5
+import com.water.revenue.entity.MeterLifecycleLog;
6
+import com.water.revenue.entity.MeterReplaceRecord;
7
+import com.water.revenue.entity.WaterMeter;
8
+import com.water.revenue.enums.MeterStatus;
9
+import com.water.revenue.mapper.MeterInstallRecordMapper;
10
+import com.water.revenue.mapper.MeterLifecycleLogMapper;
11
+import com.water.revenue.mapper.MeterReplaceRecordMapper;
12
+import com.water.revenue.mapper.WaterMeterMapper;
13
+import org.junit.jupiter.api.BeforeEach;
14
+import org.junit.jupiter.api.DisplayName;
15
+import org.junit.jupiter.api.Nested;
16
+import org.junit.jupiter.api.Test;
17
+import org.junit.jupiter.api.extension.ExtendWith;
18
+import org.mockito.ArgumentCaptor;
19
+import org.mockito.Mock;
20
+import org.mockito.junit.jupiter.MockitoExtension;
21
+
22
+import java.math.BigDecimal;
23
+import java.time.LocalDate;
24
+import java.time.LocalDateTime;
25
+import java.util.Arrays;
26
+import java.util.Collections;
27
+import java.util.List;
28
+import java.util.Map;
29
+
30
+import static org.junit.jupiter.api.Assertions.*;
31
+import static org.mockito.ArgumentMatchers.*;
32
+import static org.mockito.Mockito.*;
33
+
34
+@ExtendWith(MockitoExtension.class)
35
+class MeterLifecycleTest {
36
+
37
+    @Mock
38
+    private WaterMeterMapper waterMeterMapper;
39
+    @Mock
40
+    private MeterInstallRecordMapper installRecordMapper;
41
+    @Mock
42
+    private MeterReplaceRecordMapper replaceRecordMapper;
43
+    @Mock
44
+    private MeterLifecycleLogMapper lifecycleLogMapper;
45
+
46
+    private MeterLifecycleService lifecycleService;
47
+    private MeterReplaceService replaceService;
48
+    private MeterStatsService statsService;
49
+
50
+    @BeforeEach
51
+    void setUp() {
52
+        lifecycleService = new MeterLifecycleService(waterMeterMapper, installRecordMapper, lifecycleLogMapper);
53
+        replaceService = new MeterReplaceService(waterMeterMapper, replaceRecordMapper, lifecycleLogMapper);
54
+        statsService = new MeterStatsService(waterMeterMapper, lifecycleLogMapper, replaceRecordMapper);
55
+    }
56
+
57
+    /* ==================== Helper ==================== */
58
+
59
+    private WaterMeter buildMeter(String meterNo, MeterStatus status) {
60
+        WaterMeter m = new WaterMeter();
61
+        m.setId(1L);
62
+        m.setMeterNo(meterNo);
63
+        m.setModel("DN20");
64
+        m.setDiameter(20);
65
+        m.setManufacturer("TestMfg");
66
+        m.setProductionDate(LocalDate.of(2025, 1, 1));
67
+        m.setStatus(status);
68
+        m.setInStockTime(LocalDateTime.now());
69
+        m.setInitialReading(BigDecimal.ZERO);
70
+        m.setCurrentReading(BigDecimal.ZERO);
71
+        m.setCreatedAt(LocalDateTime.now());
72
+        m.setUpdatedAt(LocalDateTime.now());
73
+        return m;
74
+    }
75
+
76
+    /* ==================== Test 1: 水表入库 ==================== */
77
+
78
+    @Test
79
+    @DisplayName("Test 1: 水表入库登记 - 正常入库")
80
+    void testStockIn_Success() {
81
+        WaterMeter meter = buildMeter("WM-001", null);
82
+        when(waterMeterMapper.selectByMeterNo("WM-001")).thenReturn(null);
83
+        when(waterMeterMapper.insert(any(WaterMeter.class))).thenReturn(1);
84
+        when(lifecycleLogMapper.insert(any(MeterLifecycleLog.class))).thenReturn(1);
85
+
86
+        WaterMeter result = lifecycleService.stockIn(meter);
87
+
88
+        assertEquals(MeterStatus.IN_STOCK, result.getStatus());
89
+        assertNotNull(result.getInStockTime());
90
+        verify(waterMeterMapper).insert(any(WaterMeter.class));
91
+        verify(lifecycleLogMapper).insert(any(MeterLifecycleLog.class));
92
+    }
93
+
94
+    /* ==================== Test 2: 重复入库 ==================== */
95
+
96
+    @Test
97
+    @DisplayName("Test 2: 水表入库登记 - 表号重复抛异常")
98
+    void testStockIn_DuplicateMeterNo() {
99
+        WaterMeter meter = buildMeter("WM-001", null);
100
+        when(waterMeterMapper.selectByMeterNo("WM-001")).thenReturn(buildMeter("WM-001", MeterStatus.IN_STOCK));
101
+
102
+        assertThrows(IllegalStateException.class, () -> lifecycleService.stockIn(meter));
103
+        verify(waterMeterMapper, never()).insert(any());
104
+    }
105
+
106
+    /* ==================== Test 3: 水表安装 ==================== */
107
+
108
+    @Test
109
+    @DisplayName("Test 3: 水表出库安装 - 正常安装")
110
+    void testInstallMeter_Success() {
111
+        WaterMeter meter = buildMeter("WM-001", MeterStatus.IN_STOCK);
112
+        when(waterMeterMapper.selectByMeterNo("WM-001")).thenReturn(meter);
113
+        when(waterMeterMapper.updateById(any(WaterMeter.class))).thenReturn(1);
114
+        when(installRecordMapper.insert(any(MeterInstallRecord.class))).thenReturn(1);
115
+        when(lifecycleLogMapper.insert(any(MeterLifecycleLog.class))).thenReturn(1);
116
+
117
+        MeterInstallRecord record = lifecycleService.installMeter(
118
+                "WM-001", "CUST-001", "installer-A", "Test Address", BigDecimal.TEN);
119
+
120
+        assertEquals(MeterStatus.INSTALLED, meter.getStatus());
121
+        assertEquals("CUST-001", meter.getCustomerNo());
122
+        assertEquals("Test Address", meter.getInstallAddress());
123
+        assertNotNull(record);
124
+        verify(installRecordMapper).insert(any(MeterInstallRecord.class));
125
+    }
126
+
127
+    /* ==================== Test 4: 安装状态不对 ==================== */
128
+
129
+    @Test
130
+    @DisplayName("Test 4: 水表安装 - 状态不是 IN_STOCK 抛异常")
131
+    void testInstallMeter_WrongStatus() {
132
+        WaterMeter meter = buildMeter("WM-001", MeterStatus.INSTALLED);
133
+        when(waterMeterMapper.selectByMeterNo("WM-001")).thenReturn(meter);
134
+
135
+        assertThrows(IllegalStateException.class, () ->
136
+                lifecycleService.installMeter("WM-001", "CUST-001", "installer", "addr", BigDecimal.ZERO));
137
+    }
138
+
139
+    /* ==================== Test 5: 故障换表 ==================== */
140
+
141
+    @Test
142
+    @DisplayName("Test 5: 故障换表 - 正常流程")
143
+    void testFaultReplace_Success() {
144
+        WaterMeter oldMeter = buildMeter("OLD-001", MeterStatus.INSTALLED);
145
+        oldMeter.setCustomerNo("CUST-001");
146
+        oldMeter.setInstallAddress("Addr");
147
+        oldMeter.setCurrentReading(new BigDecimal("100"));
148
+
149
+        WaterMeter newMeter = buildMeter("NEW-001", MeterStatus.IN_STOCK);
150
+
151
+        when(waterMeterMapper.selectByMeterNo("OLD-001")).thenReturn(oldMeter);
152
+        when(waterMeterMapper.selectByMeterNo("NEW-001")).thenReturn(newMeter);
153
+        when(waterMeterMapper.updateById(any(WaterMeter.class))).thenReturn(1);
154
+        when(replaceRecordMapper.insert(any(MeterReplaceRecord.class))).thenReturn(1);
155
+        when(lifecycleLogMapper.insert(any(MeterLifecycleLog.class))).thenReturn(1);
156
+
157
+        MeterReplaceRecord record = replaceService.faultReplace(
158
+                "OLD-001", "NEW-001", "replacer-A", "表具故障", new BigDecimal("100"));
159
+
160
+        assertEquals(MeterStatus.DISMANTLED, oldMeter.getStatus());
161
+        assertEquals(MeterStatus.INSTALLED, newMeter.getStatus());
162
+        assertEquals("CUST-001", newMeter.getCustomerNo());
163
+        assertEquals("FAULT", record.getReplaceType());
164
+        assertEquals("APPROVED", record.getApprovalStatus());
165
+
166
+        // 验证日志被记录了2次(拆除+安装)
167
+        verify(lifecycleLogMapper, times(2)).insert(any(MeterLifecycleLog.class));
168
+    }
169
+
170
+    /* ==================== Test 6: 水表报废 ==================== */
171
+
172
+    @Test
173
+    @DisplayName("Test 6: 水表报废 - 正常报废已拆除的水表")
174
+    void testScrapMeter_Success() {
175
+        WaterMeter meter = buildMeter("WM-SCRAP", MeterStatus.DISMANTLED);
176
+        when(waterMeterMapper.selectByMeterNo("WM-SCRAP")).thenReturn(meter);
177
+        when(waterMeterMapper.updateById(any(WaterMeter.class))).thenReturn(1);
178
+        when(lifecycleLogMapper.insert(any(MeterLifecycleLog.class))).thenReturn(1);
179
+
180
+        replaceService.scrapMeter("WM-SCRAP", "超期使用", "operator-A");
181
+
182
+        assertEquals(MeterStatus.SCRAPPED, meter.getStatus());
183
+        assertNotNull(meter.getScrappedDate());
184
+        verify(waterMeterMapper).updateById(meter);
185
+    }
186
+
187
+    /* ==================== Test 7: 已安装水表不能直接报废 ==================== */
188
+
189
+    @Test
190
+    @DisplayName("Test 7: 水表报废 - 已安装水表不能直接报废")
191
+    void testScrapMeter_InstalledMeterRejected() {
192
+        WaterMeter meter = buildMeter("WM-ACTIVE", MeterStatus.INSTALLED);
193
+        when(waterMeterMapper.selectByMeterNo("WM-ACTIVE")).thenReturn(meter);
194
+
195
+        assertThrows(IllegalStateException.class, () ->
196
+                replaceService.scrapMeter("WM-ACTIVE", "reason", "operator"));
197
+    }
198
+
199
+    /* ==================== Test 8: 报废审批 ==================== */
200
+
201
+    @Test
202
+    @DisplayName("Test 8: 报废审批 - 审批通过")
203
+    void testApproveReplace_Approved() {
204
+        MeterReplaceRecord record = new MeterReplaceRecord();
205
+        record.setId(1L);
206
+        record.setApprovalStatus("PENDING");
207
+
208
+        when(replaceRecordMapper.selectById(1L)).thenReturn(record);
209
+        when(replaceRecordMapper.updateById(any(MeterReplaceRecord.class))).thenReturn(1);
210
+
211
+        MeterReplaceRecord result = replaceService.approveReplace(1L, "admin", true, "同意");
212
+
213
+        assertEquals("APPROVED", result.getApprovalStatus());
214
+        assertEquals("admin", result.getApprover());
215
+        assertNotNull(result.getApprovalTime());
216
+    }
217
+
218
+    /* ==================== Test 9: 重复审批 ==================== */
219
+
220
+    @Test
221
+    @DisplayName("Test 9: 报废审批 - 已审批的记录不能再次审批")
222
+    void testApproveReplace_AlreadyApproved() {
223
+        MeterReplaceRecord record = new MeterReplaceRecord();
224
+        record.setId(1L);
225
+        record.setApprovalStatus("APPROVED");
226
+
227
+        when(replaceRecordMapper.selectById(1L)).thenReturn(record);
228
+
229
+        assertThrows(IllegalStateException.class, () ->
230
+                replaceService.approveReplace(1L, "admin", true, null));
231
+    }
232
+
233
+    /* ==================== Test 10: 库存统计 ==================== */
234
+
235
+    @Test
236
+    @DisplayName("Test 10: 库存统计 - 返回完整统计信息")
237
+    void testGetInventoryStats() {
238
+        when(waterMeterMapper.countByStatus()).thenReturn(
239
+                List.of(Map.of("status", "IN_STOCK", "count", 10L),
240
+                        Map.of("status", "INSTALLED", "count", 20L)));
241
+        when(waterMeterMapper.countInStockByDiameter()).thenReturn(
242
+                List.of(Map.of("diameter", 20, "count", 5L)));
243
+        when(waterMeterMapper.countInStockByManufacturer()).thenReturn(
244
+                List.of(Map.of("manufacturer", "TestMfg", "count", 10L)));
245
+
246
+        // mock selectCount for individual status queries
247
+        when(waterMeterMapper.selectCount(any(LambdaQueryWrapper.class)))
248
+                .thenReturn(10L)   // IN_STOCK
249
+                .thenReturn(20L)   // INSTALLED
250
+                .thenReturn(3L)    // DISMANTLED
251
+                .thenReturn(2L)    // SCRAPPED
252
+                .thenReturn(1L);   // REPAIRING
253
+
254
+        Map<String, Object> stats = statsService.getInventoryStats();
255
+
256
+        assertNotNull(stats);
257
+        assertEquals(10L, stats.get("totalInStock"));
258
+        assertEquals(20L, stats.get("totalInstalled"));
259
+        assertEquals(3L, stats.get("totalDismantled"));
260
+        assertEquals(2L, stats.get("totalScrapped"));
261
+        assertEquals(1L, stats.get("totalRepairing"));
262
+        assertNotNull(stats.get("byDiameter"));
263
+        assertNotNull(stats.get("byManufacturer"));
264
+    }
265
+
266
+    /* ==================== Test 11: 生命周期日志查询 ==================== */
267
+
268
+    @Test
269
+    @DisplayName("Test 11: 生命周期日志查询")
270
+    void testGetLifecycleLogs() {
271
+        MeterLifecycleLog log1 = new MeterLifecycleLog();
272
+        log1.setMeterNo("WM-001");
273
+        log1.setActionType("STOCK_IN");
274
+        log1.setActionTime(LocalDateTime.now());
275
+
276
+        MeterLifecycleLog log2 = new MeterLifecycleLog();
277
+        log2.setMeterNo("WM-001");
278
+        log2.setActionType("INSTALL");
279
+        log2.setActionTime(LocalDateTime.now());
280
+
281
+        when(lifecycleLogMapper.selectByMeterNo("WM-001")).thenReturn(Arrays.asList(log1, log2));
282
+
283
+        List<MeterLifecycleLog> logs = lifecycleService.getLifecycleLogs("WM-001");
284
+
285
+        assertEquals(2, logs.size());
286
+        assertEquals("STOCK_IN", logs.get(0).getActionType());
287
+        assertEquals("INSTALL", logs.get(1).getActionType());
288
+    }
289
+
290
+    /* ==================== Test 12: 到期换表 ==================== */
291
+
292
+    @Test
293
+    @DisplayName("Test 12: 到期换表 - EXPIRED 类型")
294
+    void testExpiredReplace() {
295
+        WaterMeter oldMeter = buildMeter("OLD-002", MeterStatus.INSTALLED);
296
+        oldMeter.setCustomerNo("CUST-002");
297
+        oldMeter.setInstallAddress("Addr2");
298
+
299
+        WaterMeter newMeter = buildMeter("NEW-002", MeterStatus.IN_STOCK);
300
+
301
+        when(waterMeterMapper.selectByMeterNo("OLD-002")).thenReturn(oldMeter);
302
+        when(waterMeterMapper.selectByMeterNo("NEW-002")).thenReturn(newMeter);
303
+        when(waterMeterMapper.updateById(any(WaterMeter.class))).thenReturn(1);
304
+        when(replaceRecordMapper.insert(any(MeterReplaceRecord.class))).thenReturn(1);
305
+        when(lifecycleLogMapper.insert(any(MeterLifecycleLog.class))).thenReturn(1);
306
+
307
+        MeterReplaceRecord record = replaceService.expiredReplace(
308
+                "OLD-002", "NEW-002", "replacer-B", new BigDecimal("200"));
309
+
310
+        assertEquals("EXPIRED", record.getReplaceType());
311
+        assertEquals("水表到期更换", record.getReason());
312
+    }
313
+}