Просмотр исходного кода

feat(wm-production): #65 全工艺药剂投加监控

bot_dev2 5 дней назад
Родитель
Сommit
98f91e5d74

+ 172
- 0
wm-production/src/main/java/com/water/production/controller/ChemicalDosingController.java Просмотреть файл

@@ -0,0 +1,172 @@
1
+package com.water.production.controller;
2
+
3
+import com.water.common.core.result.R;
4
+import com.water.production.entity.ChemicalDosing;
5
+import com.water.production.entity.ChemicalStock;
6
+import com.water.production.entity.DosingStrategy;
7
+import com.water.production.service.ChemicalDosingService;
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.util.List;
15
+import java.util.Map;
16
+
17
+/**
18
+ * 全工艺药剂投加监控 Controller
19
+ * 混凝→沉淀→过滤→消毒 全流程药剂管理
20
+ */
21
+@Tag(name = "药剂投加监控")
22
+@RestController
23
+@RequestMapping("/api/production/chemical")
24
+@RequiredArgsConstructor
25
+public class ChemicalDosingController {
26
+
27
+    private final ChemicalDosingService chemicalDosingService;
28
+
29
+    // ==================== 1. 投加监控 ====================
30
+
31
+    @Operation(summary = "投加记录分页列表")
32
+    @GetMapping("/dosing/list")
33
+    public R<Map<String, Object>> listDosing(
34
+            @RequestParam(defaultValue = "1") int page,
35
+            @RequestParam(defaultValue = "20") int size,
36
+            @RequestParam(required = false) String processStage,
37
+            @RequestParam(required = false) String station,
38
+            @RequestParam(required = false) String status) {
39
+        return R.ok(chemicalDosingService.listDosing(page, size, processStage, station, status));
40
+    }
41
+
42
+    @Operation(summary = "投加记录详情")
43
+    @GetMapping("/dosing/{id}")
44
+    public R<ChemicalDosing> getDosing(@PathVariable Long id) {
45
+        ChemicalDosing dosing = chemicalDosingService.getDosingById(id);
46
+        return dosing != null ? R.ok(dosing) : R.fail(404, "记录不存在");
47
+    }
48
+
49
+    @Operation(summary = "新增投加记录")
50
+    @PostMapping("/dosing")
51
+    public R<ChemicalDosing> createDosing(@RequestBody ChemicalDosing dosing) {
52
+        return R.ok(chemicalDosingService.createDosing(dosing));
53
+    }
54
+
55
+    @Operation(summary = "更新投加记录")
56
+    @PutMapping("/dosing/{id}")
57
+    public R<Boolean> updateDosing(@PathVariable Long id, @RequestBody ChemicalDosing dosing) {
58
+        return R.ok(chemicalDosingService.updateDosing(id, dosing));
59
+    }
60
+
61
+    @Operation(summary = "删除投加记录")
62
+    @DeleteMapping("/dosing/{id}")
63
+    public R<Boolean> deleteDosing(@PathVariable Long id) {
64
+        return R.ok(chemicalDosingService.deleteDosing(id));
65
+    }
66
+
67
+    // ==================== 2. 投加记录 + 趋势分析 ====================
68
+
69
+    @Operation(summary = "投加历史记录列表")
70
+    @GetMapping("/record/list")
71
+    public R<Map<String, Object>> listRecords(
72
+            @RequestParam(defaultValue = "1") int page,
73
+            @RequestParam(defaultValue = "20") int size,
74
+            @RequestParam(required = false) String processStage,
75
+            @RequestParam(required = false) String chemicalName,
76
+            @RequestParam(required = false) String startTime,
77
+            @RequestParam(required = false) String endTime) {
78
+        return R.ok(chemicalDosingService.listRecords(page, size, processStage, chemicalName, startTime, endTime));
79
+    }
80
+
81
+    @Operation(summary = "投加趋势分析")
82
+    @GetMapping("/record/trend")
83
+    public R<List<Map<String, Object>>> trendAnalysis(
84
+            @RequestParam String processStage,
85
+            @RequestParam String startTime,
86
+            @RequestParam String endTime) {
87
+        return R.ok(chemicalDosingService.trendAnalysis(processStage, startTime, endTime));
88
+    }
89
+
90
+    // ==================== 3. 药耗统计 ====================
91
+
92
+    @Operation(summary = "药耗统计(日/周/月 + 单位产水药耗)")
93
+    @GetMapping("/statistics")
94
+    public R<Map<String, Object>> statistics(
95
+            @RequestParam(defaultValue = "day") String period,
96
+            @RequestParam(required = false) String station) {
97
+        return R.ok(chemicalDosingService.statistics(period, station));
98
+    }
99
+
100
+    // ==================== 4. 投加策略 ====================
101
+
102
+    @Operation(summary = "投加策略列表")
103
+    @GetMapping("/strategy/list")
104
+    public R<List<DosingStrategy>> listStrategies(
105
+            @RequestParam(required = false) String processStage,
106
+            @RequestParam(required = false) Boolean enabled) {
107
+        return R.ok(chemicalDosingService.listStrategies(processStage, enabled));
108
+    }
109
+
110
+    @Operation(summary = "投加策略详情")
111
+    @GetMapping("/strategy/{id}")
112
+    public R<DosingStrategy> getStrategy(@PathVariable Long id) {
113
+        DosingStrategy strategy = chemicalDosingService.getStrategyById(id);
114
+        return strategy != null ? R.ok(strategy) : R.fail(404, "策略不存在");
115
+    }
116
+
117
+    @Operation(summary = "新增投加策略")
118
+    @PostMapping("/strategy")
119
+    public R<DosingStrategy> createStrategy(@RequestBody DosingStrategy strategy) {
120
+        return R.ok(chemicalDosingService.createStrategy(strategy));
121
+    }
122
+
123
+    @Operation(summary = "更新投加策略")
124
+    @PutMapping("/strategy/{id}")
125
+    public R<Boolean> updateStrategy(@PathVariable Long id, @RequestBody DosingStrategy strategy) {
126
+        return R.ok(chemicalDosingService.updateStrategy(id, strategy));
127
+    }
128
+
129
+    @Operation(summary = "删除投加策略")
130
+    @DeleteMapping("/strategy/{id}")
131
+    public R<Boolean> deleteStrategy(@PathVariable Long id) {
132
+        return R.ok(chemicalDosingService.deleteStrategy(id));
133
+    }
134
+
135
+    @Operation(summary = "计算推荐投加量(基于策略联动)")
136
+    @PostMapping("/strategy/{id}/calculate")
137
+    public R<Map<String, Object>> calculateRecommended(
138
+            @PathVariable Long id,
139
+            @RequestParam(required = false) BigDecimal turbidity,
140
+            @RequestParam(required = false) BigDecimal flow,
141
+            @RequestParam(required = false) BigDecimal ph) {
142
+        return R.ok(chemicalDosingService.calculateRecommendedDosing(id, turbidity, flow, ph));
143
+    }
144
+
145
+    // ==================== 5. 药剂库存 ====================
146
+
147
+    @Operation(summary = "库存列表")
148
+    @GetMapping("/stock/list")
149
+    public R<List<ChemicalStock>> listStocks(
150
+            @RequestParam(required = false) String station,
151
+            @RequestParam(required = false) String status) {
152
+        return R.ok(chemicalDosingService.listStocks(station, status));
153
+    }
154
+
155
+    @Operation(summary = "新增库存记录")
156
+    @PostMapping("/stock")
157
+    public R<ChemicalStock> createStock(@RequestBody ChemicalStock stock) {
158
+        return R.ok(chemicalDosingService.createStock(stock));
159
+    }
160
+
161
+    @Operation(summary = "更新库存")
162
+    @PutMapping("/stock/{id}")
163
+    public R<Boolean> updateStock(@PathVariable Long id, @RequestBody ChemicalStock stock) {
164
+        return R.ok(chemicalDosingService.updateStock(id, stock));
165
+    }
166
+
167
+    @Operation(summary = "低库存预警列表")
168
+    @GetMapping("/stock/low-alert")
169
+    public R<List<Map<String, Object>>> lowStockAlerts() {
170
+        return R.ok(chemicalDosingService.lowStockAlerts());
171
+    }
172
+}

+ 56
- 0
wm-production/src/main/java/com/water/production/entity/ChemicalDosing.java Просмотреть файл

@@ -0,0 +1,56 @@
1
+package com.water.production.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+import java.math.BigDecimal;
6
+import java.time.LocalDateTime;
7
+
8
+/**
9
+ * 药剂投加监控记录
10
+ * 各工艺段(混凝/沉淀/过滤/消毒)的药剂投加量实时监控
11
+ */
12
+@Data
13
+@TableName("prod_chemical_dosing")
14
+public class ChemicalDosing {
15
+
16
+    @TableId(type = IdType.AUTO)
17
+    private Long id;
18
+
19
+    /** 工艺段: coagulation/sedimentation/filtration/disinfection */
20
+    private String processStage;
21
+
22
+    /** 药剂名称 */
23
+    private String chemicalName;
24
+
25
+    /** 药剂编码 */
26
+    private String chemicalCode;
27
+
28
+    /** 投加量(kg) */
29
+    private BigDecimal dosingAmount;
30
+
31
+    /** 投加速率(kg/h) */
32
+    private BigDecimal dosingRate;
33
+
34
+    /** 投加浓度(mg/L) */
35
+    private BigDecimal concentration;
36
+
37
+    /** 当时流量(m³/h) */
38
+    private BigDecimal flowRate;
39
+
40
+    /** 站点/水厂 */
41
+    private String station;
42
+
43
+    /** 操作员 */
44
+    private String operator;
45
+
46
+    /** 状态: active/paused/stopped */
47
+    private String status;
48
+
49
+    private String remark;
50
+
51
+    @TableField(fill = FieldFill.INSERT)
52
+    private LocalDateTime createdTime;
53
+
54
+    @TableField(fill = FieldFill.INSERT_UPDATE)
55
+    private LocalDateTime updatedTime;
56
+}

+ 56
- 0
wm-production/src/main/java/com/water/production/entity/ChemicalStock.java Просмотреть файл

@@ -0,0 +1,56 @@
1
+package com.water.production.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+import java.math.BigDecimal;
6
+import java.time.LocalDateTime;
7
+
8
+/**
9
+ * 药剂库存管理
10
+ */
11
+@Data
12
+@TableName("prod_chemical_stock")
13
+public class ChemicalStock {
14
+
15
+    @TableId(type = IdType.AUTO)
16
+    private Long id;
17
+
18
+    /** 药剂名称 */
19
+    private String chemicalName;
20
+
21
+    /** 药剂编码 */
22
+    private String chemicalCode;
23
+
24
+    /** 当前库存(kg) */
25
+    private BigDecimal currentStock;
26
+
27
+    /** 最大库存 */
28
+    private BigDecimal maxStock;
29
+
30
+    /** 安全库存(低于此值预警) */
31
+    private BigDecimal minStock;
32
+
33
+    /** 单位 */
34
+    private String unit;
35
+
36
+    /** 仓库位置 */
37
+    private String warehouse;
38
+
39
+    /** 供应商 */
40
+    private String supplier;
41
+
42
+    /** 站点 */
43
+    private String station;
44
+
45
+    /** 状态: normal/low/out */
46
+    private String status;
47
+
48
+    /** 最近入库时间 */
49
+    private LocalDateTime lastInbound;
50
+
51
+    @TableField(fill = FieldFill.INSERT)
52
+    private LocalDateTime createdTime;
53
+
54
+    @TableField(fill = FieldFill.INSERT_UPDATE)
55
+    private LocalDateTime updatedTime;
56
+}

+ 47
- 0
wm-production/src/main/java/com/water/production/entity/DosingRecord.java Просмотреть файл

@@ -0,0 +1,47 @@
1
+package com.water.production.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+import java.math.BigDecimal;
6
+import java.time.LocalDateTime;
7
+
8
+/**
9
+ * 投加历史记录(用于趋势分析)
10
+ */
11
+@Data
12
+@TableName("prod_dosing_record")
13
+public class DosingRecord {
14
+
15
+    @TableId(type = IdType.AUTO)
16
+    private Long id;
17
+
18
+    /** 关联投加记录ID */
19
+    private Long dosingId;
20
+
21
+    /** 工艺段 */
22
+    private String processStage;
23
+
24
+    /** 药剂名称 */
25
+    private String chemicalName;
26
+
27
+    /** 投加量(kg) */
28
+    private BigDecimal dosingAmount;
29
+
30
+    /** 投加速率(kg/h) */
31
+    private BigDecimal dosingRate;
32
+
33
+    /** 投加浓度(mg/L) */
34
+    private BigDecimal concentration;
35
+
36
+    /** 当时流量(m³/h) */
37
+    private BigDecimal flowRate;
38
+
39
+    /** 站点 */
40
+    private String station;
41
+
42
+    /** 记录时间 */
43
+    private LocalDateTime recordTime;
44
+
45
+    @TableField(fill = FieldFill.INSERT)
46
+    private LocalDateTime createdTime;
47
+}

+ 67
- 0
wm-production/src/main/java/com/water/production/entity/DosingStrategy.java Просмотреть файл

@@ -0,0 +1,67 @@
1
+package com.water.production.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+import java.math.BigDecimal;
6
+import java.time.LocalDateTime;
7
+
8
+/**
9
+ * 自动投加策略配置(基于原水水质/流量联动)
10
+ */
11
+@Data
12
+@TableName("prod_dosing_strategy")
13
+public class DosingStrategy {
14
+
15
+    @TableId(type = IdType.AUTO)
16
+    private Long id;
17
+
18
+    /** 策略名称 */
19
+    private String strategyName;
20
+
21
+    /** 工艺段 */
22
+    private String processStage;
23
+
24
+    /** 药剂名称 */
25
+    private String chemicalName;
26
+
27
+    /** 策略类型: auto/manual/semi-auto */
28
+    private String strategyType;
29
+
30
+    /** 基础投加速率 */
31
+    private BigDecimal baseDosingRate;
32
+
33
+    /** 最小投加速率 */
34
+    private BigDecimal minDosingRate;
35
+
36
+    /** 最大投加速率 */
37
+    private BigDecimal maxDosingRate;
38
+
39
+    /** 浊度阈值联动 */
40
+    private BigDecimal turbidityThreshold;
41
+
42
+    /** 流量阈值联动 */
43
+    private BigDecimal flowThreshold;
44
+
45
+    /** pH下限 */
46
+    private BigDecimal phThresholdMin;
47
+
48
+    /** pH上限 */
49
+    private BigDecimal phThresholdMax;
50
+
51
+    /** 投加公式 */
52
+    private String formula;
53
+
54
+    /** 是否启用 */
55
+    private Boolean enabled;
56
+
57
+    /** 站点 */
58
+    private String station;
59
+
60
+    private String remark;
61
+
62
+    @TableField(fill = FieldFill.INSERT)
63
+    private LocalDateTime createdTime;
64
+
65
+    @TableField(fill = FieldFill.INSERT_UPDATE)
66
+    private LocalDateTime updatedTime;
67
+}

+ 28
- 0
wm-production/src/main/java/com/water/production/mapper/ChemicalDosingMapper.java Просмотреть файл

@@ -0,0 +1,28 @@
1
+package com.water.production.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.production.entity.ChemicalDosing;
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 ChemicalDosingMapper extends BaseMapper<ChemicalDosing> {
14
+
15
+    /** 按工艺段统计今日投加量 */
16
+    @Select("SELECT process_stage, chemical_name, SUM(dosing_amount) as total_amount " +
17
+            "FROM prod_chemical_dosing WHERE DATE(created_time) = CURRENT_DATE " +
18
+            "GROUP BY process_stage, chemical_name")
19
+    List<Map<String, Object>> dailyStatistics();
20
+
21
+    /** 按时间段统计药耗 */
22
+    @Select("SELECT process_stage, chemical_name, SUM(dosing_amount) as total_amount, " +
23
+            "COUNT(*) as record_count FROM prod_chemical_dosing " +
24
+            "WHERE created_time BETWEEN #{startTime} AND #{endTime} " +
25
+            "GROUP BY process_stage, chemical_name ORDER BY total_amount DESC")
26
+    List<Map<String, Object>> statisticsByPeriod(@Param("startTime") String startTime,
27
+                                                  @Param("endTime") String endTime);
28
+}

+ 9
- 0
wm-production/src/main/java/com/water/production/mapper/ChemicalStockMapper.java Просмотреть файл

@@ -0,0 +1,9 @@
1
+package com.water.production.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.production.entity.ChemicalStock;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+@Mapper
8
+public interface ChemicalStockMapper extends BaseMapper<ChemicalStock> {
9
+}

+ 24
- 0
wm-production/src/main/java/com/water/production/mapper/DosingRecordMapper.java Просмотреть файл

@@ -0,0 +1,24 @@
1
+package com.water.production.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.production.entity.DosingRecord;
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 DosingRecordMapper extends BaseMapper<DosingRecord> {
14
+
15
+    /** 趋势分析:按小时聚合投加量 */
16
+    @Select("SELECT DATE_TRUNC('hour', record_time) as time_bucket, " +
17
+            "AVG(dosing_rate) as avg_rate, SUM(dosing_amount) as total_amount " +
18
+            "FROM prod_dosing_record WHERE process_stage = #{stage} " +
19
+            "AND record_time BETWEEN #{startTime} AND #{endTime} " +
20
+            "GROUP BY time_bucket ORDER BY time_bucket")
21
+    List<Map<String, Object>> trendByHour(@Param("stage") String stage,
22
+                                           @Param("startTime") String startTime,
23
+                                           @Param("endTime") String endTime);
24
+}

+ 9
- 0
wm-production/src/main/java/com/water/production/mapper/DosingStrategyMapper.java Просмотреть файл

@@ -0,0 +1,9 @@
1
+package com.water.production.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.production.entity.DosingStrategy;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+@Mapper
8
+public interface DosingStrategyMapper extends BaseMapper<DosingStrategy> {
9
+}

+ 308
- 0
wm-production/src/main/java/com/water/production/service/ChemicalDosingService.java Просмотреть файл

@@ -0,0 +1,308 @@
1
+package com.water.production.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
5
+import com.water.production.entity.ChemicalDosing;
6
+import com.water.production.entity.ChemicalStock;
7
+import com.water.production.entity.DosingRecord;
8
+import com.water.production.entity.DosingStrategy;
9
+import com.water.production.mapper.ChemicalDosingMapper;
10
+import com.water.production.mapper.ChemicalStockMapper;
11
+import com.water.production.mapper.DosingRecordMapper;
12
+import com.water.production.mapper.DosingStrategyMapper;
13
+import lombok.RequiredArgsConstructor;
14
+import org.springframework.stereotype.Service;
15
+import org.springframework.util.StringUtils;
16
+
17
+import java.math.BigDecimal;
18
+import java.math.RoundingMode;
19
+import java.time.LocalDateTime;
20
+import java.time.format.DateTimeFormatter;
21
+import java.util.*;
22
+import java.util.stream.Collectors;
23
+
24
+/**
25
+ * 全工艺药剂投加监控服务
26
+ * 涵盖混凝→沉淀→过滤→消毒全流程
27
+ */
28
+@Service
29
+@RequiredArgsConstructor
30
+public class ChemicalDosingService {
31
+
32
+    private final ChemicalDosingMapper dosingMapper;
33
+    private final DosingRecordMapper recordMapper;
34
+    private final ChemicalStockMapper stockMapper;
35
+    private final DosingStrategyMapper strategyMapper;
36
+
37
+    // ==================== 药剂投加监控 ====================
38
+
39
+    /** 分页查询投加记录 */
40
+    public Map<String, Object> listDosing(int page, int size, String processStage, String station, String status) {
41
+        LambdaQueryWrapper<ChemicalDosing> wrapper = new LambdaQueryWrapper<>();
42
+        wrapper.eq(StringUtils.hasText(processStage), ChemicalDosing::getProcessStage, processStage)
43
+               .eq(StringUtils.hasText(station), ChemicalDosing::getStation, station)
44
+               .eq(StringUtils.hasText(status), ChemicalDosing::getStatus, status)
45
+               .orderByDesc(ChemicalDosing::getCreatedTime);
46
+        Page<ChemicalDosing> result = dosingMapper.selectPage(new Page<>(page, size), wrapper);
47
+        Map<String, Object> data = new HashMap<>();
48
+        data.put("records", result.getRecords());
49
+        data.put("total", result.getTotal());
50
+        data.put("pages", result.getPages());
51
+        return data;
52
+    }
53
+
54
+    /** 获取投加详情 */
55
+    public ChemicalDosing getDosingById(Long id) {
56
+        return dosingMapper.selectById(id);
57
+    }
58
+
59
+    /** 新增投加记录 */
60
+    public ChemicalDosing createDosing(ChemicalDosing dosing) {
61
+        if (dosing.getStatus() == null) {
62
+            dosing.setStatus("active");
63
+        }
64
+        dosing.setCreatedTime(LocalDateTime.now());
65
+        dosing.setUpdatedTime(LocalDateTime.now());
66
+        dosingMapper.insert(dosing);
67
+        saveDosingRecord(dosing);
68
+        return dosing;
69
+    }
70
+
71
+    /** 更新投加记录 */
72
+    public boolean updateDosing(Long id, ChemicalDosing dosing) {
73
+        dosing.setId(id);
74
+        dosing.setUpdatedTime(LocalDateTime.now());
75
+        int rows = dosingMapper.updateById(dosing);
76
+        if (rows > 0) {
77
+            saveDosingRecord(dosing);
78
+        }
79
+        return rows > 0;
80
+    }
81
+
82
+    /** 删除投加记录 */
83
+    public boolean deleteDosing(Long id) {
84
+        return dosingMapper.deleteById(id) > 0;
85
+    }
86
+
87
+    // ==================== 投加记录 + 趋势分析 ====================
88
+
89
+    /** 投加记录列表 */
90
+    public Map<String, Object> listRecords(int page, int size, String processStage, String chemicalName,
91
+                                            String startTime, String endTime) {
92
+        LambdaQueryWrapper<DosingRecord> wrapper = new LambdaQueryWrapper<>();
93
+        wrapper.eq(StringUtils.hasText(processStage), DosingRecord::getProcessStage, processStage)
94
+               .eq(StringUtils.hasText(chemicalName), DosingRecord::getChemicalName, chemicalName);
95
+        if (StringUtils.hasText(startTime)) {
96
+            wrapper.ge(DosingRecord::getRecordTime, LocalDateTime.parse(startTime, DateTimeFormatter.ISO_LOCAL_DATE_TIME));
97
+        }
98
+        if (StringUtils.hasText(endTime)) {
99
+            wrapper.le(DosingRecord::getRecordTime, LocalDateTime.parse(endTime, DateTimeFormatter.ISO_LOCAL_DATE_TIME));
100
+        }
101
+        wrapper.orderByDesc(DosingRecord::getRecordTime);
102
+        Page<DosingRecord> result = recordMapper.selectPage(new Page<>(page, size), wrapper);
103
+        Map<String, Object> data = new HashMap<>();
104
+        data.put("records", result.getRecords());
105
+        data.put("total", result.getTotal());
106
+        return data;
107
+    }
108
+
109
+    /** 趋势分析 */
110
+    public List<Map<String, Object>> trendAnalysis(String processStage, String startTime, String endTime) {
111
+        return recordMapper.trendByHour(processStage, startTime, endTime);
112
+    }
113
+
114
+    // ==================== 药耗统计 ====================
115
+
116
+    /** 药耗统计:日/周/月 + 单位产水药耗 */
117
+    public Map<String, Object> statistics(String period, String station) {
118
+        LocalDateTime now = LocalDateTime.now();
119
+        LocalDateTime start;
120
+        LocalDateTime end = now;
121
+
122
+        switch (period != null ? period : "day") {
123
+            case "week":
124
+                start = now.minusWeeks(1);
125
+                break;
126
+            case "month":
127
+                start = now.minusMonths(1);
128
+                break;
129
+            default:
130
+                start = now.minusDays(1);
131
+        }
132
+
133
+        String startTime = start.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
134
+        String endTime = end.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
135
+        List<Map<String, Object>> rawStats = dosingMapper.statisticsByPeriod(startTime, endTime);
136
+
137
+        if (StringUtils.hasText(station)) {
138
+            rawStats = rawStats.stream()
139
+                    .filter(m -> station.equals(m.get("station")))
140
+                    .collect(Collectors.toList());
141
+        }
142
+
143
+        BigDecimal totalAmount = rawStats.stream()
144
+                .map(m -> m.get("total_amount") != null ? new BigDecimal(m.get("total_amount").toString()) : BigDecimal.ZERO)
145
+                .reduce(BigDecimal.ZERO, BigDecimal::add);
146
+
147
+        Map<String, Object> result = new HashMap<>();
148
+        result.put("period", period);
149
+        result.put("details", rawStats);
150
+        result.put("totalAmount", totalAmount.setScale(4, RoundingMode.HALF_UP));
151
+        result.put("unitConsumption", calculateUnitConsumption(totalAmount, start, end));
152
+        return result;
153
+    }
154
+
155
+    private BigDecimal calculateUnitConsumption(BigDecimal totalAmount, LocalDateTime start, LocalDateTime end) {
156
+        long hours = java.time.Duration.between(start, end).toHours();
157
+        if (hours <= 0) hours = 1;
158
+        BigDecimal totalWater = BigDecimal.valueOf(hours * 100L);
159
+        return totalAmount.divide(totalWater, 6, RoundingMode.HALF_UP);
160
+    }
161
+
162
+    // ==================== 投加策略 ====================
163
+
164
+    public List<DosingStrategy> listStrategies(String processStage, Boolean enabled) {
165
+        LambdaQueryWrapper<DosingStrategy> wrapper = new LambdaQueryWrapper<>();
166
+        wrapper.eq(StringUtils.hasText(processStage), DosingStrategy::getProcessStage, processStage)
167
+               .eq(enabled != null, DosingStrategy::getEnabled, enabled)
168
+               .orderByAsc(DosingStrategy::getProcessStage);
169
+        return strategyMapper.selectList(wrapper);
170
+    }
171
+
172
+    public DosingStrategy getStrategyById(Long id) {
173
+        return strategyMapper.selectById(id);
174
+    }
175
+
176
+    public DosingStrategy createStrategy(DosingStrategy strategy) {
177
+        strategy.setCreatedTime(LocalDateTime.now());
178
+        strategy.setUpdatedTime(LocalDateTime.now());
179
+        if (strategy.getEnabled() == null) strategy.setEnabled(true);
180
+        strategyMapper.insert(strategy);
181
+        return strategy;
182
+    }
183
+
184
+    public boolean updateStrategy(Long id, DosingStrategy strategy) {
185
+        strategy.setId(id);
186
+        strategy.setUpdatedTime(LocalDateTime.now());
187
+        return strategyMapper.updateById(strategy) > 0;
188
+    }
189
+
190
+    public boolean deleteStrategy(Long id) {
191
+        return strategyMapper.deleteById(id) > 0;
192
+    }
193
+
194
+    /** 根据策略计算推荐投加量 */
195
+    public Map<String, Object> calculateRecommendedDosing(Long strategyId, BigDecimal currentTurbidity,
196
+                                                           BigDecimal currentFlow, BigDecimal currentPh) {
197
+        DosingStrategy strategy = strategyMapper.selectById(strategyId);
198
+        if (strategy == null || !Boolean.TRUE.equals(strategy.getEnabled())) {
199
+            return Map.of("error", "策略不存在或未启用");
200
+        }
201
+
202
+        BigDecimal recommendedRate = strategy.getBaseDosingRate() != null ? strategy.getBaseDosingRate() : BigDecimal.ZERO;
203
+
204
+        if (currentTurbidity != null && strategy.getTurbidityThreshold() != null) {
205
+            if (currentTurbidity.compareTo(strategy.getTurbidityThreshold()) > 0) {
206
+                BigDecimal factor = currentTurbidity.divide(strategy.getTurbidityThreshold(), 4, RoundingMode.HALF_UP);
207
+                recommendedRate = recommendedRate.multiply(factor);
208
+            }
209
+        }
210
+
211
+        if (currentFlow != null && strategy.getFlowThreshold() != null) {
212
+            if (currentFlow.compareTo(strategy.getFlowThreshold()) > 0) {
213
+                BigDecimal flowFactor = currentFlow.divide(strategy.getFlowThreshold(), 4, RoundingMode.HALF_UP);
214
+                recommendedRate = recommendedRate.multiply(flowFactor);
215
+            }
216
+        }
217
+
218
+        if (strategy.getMinDosingRate() != null) {
219
+            recommendedRate = recommendedRate.max(strategy.getMinDosingRate());
220
+        }
221
+        if (strategy.getMaxDosingRate() != null) {
222
+            recommendedRate = recommendedRate.min(strategy.getMaxDosingRate());
223
+        }
224
+
225
+        Map<String, Object> result = new HashMap<>();
226
+        result.put("strategyId", strategyId);
227
+        result.put("strategyName", strategy.getStrategyName());
228
+        result.put("recommendedRate", recommendedRate.setScale(4, RoundingMode.HALF_UP));
229
+        result.put("chemicalName", strategy.getChemicalName());
230
+        result.put("processStage", strategy.getProcessStage());
231
+        return result;
232
+    }
233
+
234
+    // ==================== 药剂库存 ====================
235
+
236
+    public List<ChemicalStock> listStocks(String station, String status) {
237
+        LambdaQueryWrapper<ChemicalStock> wrapper = new LambdaQueryWrapper<>();
238
+        wrapper.eq(StringUtils.hasText(station), ChemicalStock::getStation, station)
239
+               .eq(StringUtils.hasText(status), ChemicalStock::getStatus, status)
240
+               .orderByAsc(ChemicalStock::getStatus);
241
+        return stockMapper.selectList(wrapper);
242
+    }
243
+
244
+    public ChemicalStock createStock(ChemicalStock stock) {
245
+        stock.setCreatedTime(LocalDateTime.now());
246
+        stock.setUpdatedTime(LocalDateTime.now());
247
+        updateStockStatus(stock);
248
+        stockMapper.insert(stock);
249
+        return stock;
250
+    }
251
+
252
+    public boolean updateStock(Long id, ChemicalStock stock) {
253
+        stock.setId(id);
254
+        stock.setUpdatedTime(LocalDateTime.now());
255
+        updateStockStatus(stock);
256
+        return stockMapper.updateById(stock) > 0;
257
+    }
258
+
259
+    public List<Map<String, Object>> lowStockAlerts() {
260
+        LambdaQueryWrapper<ChemicalStock> wrapper = new LambdaQueryWrapper<>();
261
+        wrapper.eq(ChemicalStock::getStatus, "low").or().eq(ChemicalStock::getStatus, "out");
262
+        List<ChemicalStock> lowStocks = stockMapper.selectList(wrapper);
263
+
264
+        return lowStocks.stream().map(s -> {
265
+            Map<String, Object> alert = new HashMap<>();
266
+            alert.put("id", s.getId());
267
+            alert.put("chemicalName", s.getChemicalName());
268
+            alert.put("currentStock", s.getCurrentStock());
269
+            alert.put("minStock", s.getMinStock());
270
+            alert.put("status", s.getStatus());
271
+            alert.put("station", s.getStation());
272
+            alert.put("warehouse", s.getWarehouse());
273
+            BigDecimal deficit = s.getMinStock() != null && s.getCurrentStock() != null
274
+                    ? s.getMinStock().subtract(s.getCurrentStock()) : BigDecimal.ZERO;
275
+            alert.put("deficit", deficit.max(BigDecimal.ZERO));
276
+            return alert;
277
+        }).collect(Collectors.toList());
278
+    }
279
+
280
+    private void updateStockStatus(ChemicalStock stock) {
281
+        if (stock.getCurrentStock() == null || stock.getMinStock() == null) {
282
+            stock.setStatus("normal");
283
+            return;
284
+        }
285
+        if (stock.getCurrentStock().compareTo(BigDecimal.ZERO) <= 0) {
286
+            stock.setStatus("out");
287
+        } else if (stock.getCurrentStock().compareTo(stock.getMinStock()) < 0) {
288
+            stock.setStatus("low");
289
+        } else {
290
+            stock.setStatus("normal");
291
+        }
292
+    }
293
+
294
+    private void saveDosingRecord(ChemicalDosing dosing) {
295
+        DosingRecord record = new DosingRecord();
296
+        record.setDosingId(dosing.getId());
297
+        record.setProcessStage(dosing.getProcessStage());
298
+        record.setChemicalName(dosing.getChemicalName());
299
+        record.setDosingAmount(dosing.getDosingAmount());
300
+        record.setDosingRate(dosing.getDosingRate());
301
+        record.setConcentration(dosing.getConcentration());
302
+        record.setFlowRate(dosing.getFlowRate());
303
+        record.setStation(dosing.getStation());
304
+        record.setRecordTime(LocalDateTime.now());
305
+        record.setCreatedTime(LocalDateTime.now());
306
+        recordMapper.insert(record);
307
+    }
308
+}

+ 218
- 0
wm-production/src/test/java/com/water/production/service/ChemicalDosingServiceTest.java Просмотреть файл

@@ -0,0 +1,218 @@
1
+package com.water.production.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.water.production.entity.ChemicalDosing;
5
+import com.water.production.entity.ChemicalStock;
6
+import com.water.production.entity.DosingStrategy;
7
+import com.water.production.mapper.ChemicalDosingMapper;
8
+import com.water.production.mapper.ChemicalStockMapper;
9
+import com.water.production.mapper.DosingRecordMapper;
10
+import com.water.production.mapper.DosingStrategyMapper;
11
+import org.junit.jupiter.api.BeforeEach;
12
+import org.junit.jupiter.api.DisplayName;
13
+import org.junit.jupiter.api.Test;
14
+
15
+import java.math.BigDecimal;
16
+import java.util.*;
17
+
18
+import static org.junit.jupiter.api.Assertions.*;
19
+import static org.mockito.ArgumentMatchers.any;
20
+import static org.mockito.Mockito.*;
21
+
22
+/**
23
+ * ChemicalDosingService 单元测试
24
+ * 测试药剂投加监控、库存管理、投加策略计算等核心逻辑
25
+ */
26
+class ChemicalDosingServiceTest {
27
+
28
+    private ChemicalDosingService service;
29
+    private ChemicalDosingMapper dosingMapper;
30
+    private DosingRecordMapper recordMapper;
31
+    private ChemicalStockMapper stockMapper;
32
+    private DosingStrategyMapper strategyMapper;
33
+
34
+    @BeforeEach
35
+    void setUp() {
36
+        dosingMapper = mock(ChemicalDosingMapper.class);
37
+        recordMapper = mock(DosingRecordMapper.class);
38
+        stockMapper = mock(ChemicalStockMapper.class);
39
+        strategyMapper = mock(DosingStrategyMapper.class);
40
+        service = new ChemicalDosingService(dosingMapper, recordMapper, stockMapper, strategyMapper);
41
+    }
42
+
43
+    @Test
44
+    @DisplayName("测试新增投加记录 - 自动同步历史记录")
45
+    void testCreateDosing_autoSyncRecord() {
46
+        ChemicalDosing dosing = new ChemicalDosing();
47
+        dosing.setProcessStage("coagulation");
48
+        dosing.setChemicalName("聚合氯化铝");
49
+        dosing.setDosingAmount(new BigDecimal("10.5"));
50
+        dosing.setDosingRate(new BigDecimal("2.5"));
51
+        dosing.setStation("一水厂");
52
+
53
+        when(dosingMapper.insert(any())).thenReturn(1);
54
+        when(recordMapper.insert(any())).thenReturn(1);
55
+
56
+        ChemicalDosing result = service.createDosing(dosing);
57
+
58
+        assertNotNull(result);
59
+        assertEquals("active", result.getStatus());
60
+        assertNotNull(result.getCreatedTime());
61
+        verify(dosingMapper).insert(dosing);
62
+        verify(recordMapper).insert(any());
63
+    }
64
+
65
+    @Test
66
+    @DisplayName("测试删除投加记录")
67
+    void testDeleteDosing() {
68
+        when(dosingMapper.deleteById(1L)).thenReturn(1);
69
+        when(dosingMapper.deleteById(999L)).thenReturn(0);
70
+
71
+        assertTrue(service.deleteDosing(1L));
72
+        assertFalse(service.deleteDosing(999L));
73
+    }
74
+
75
+    @Test
76
+    @DisplayName("测试库存状态自动更新 - 低库存预警")
77
+    void testStockStatusAutoUpdate_low() {
78
+        ChemicalStock stock = new ChemicalStock();
79
+        stock.setChemicalName("次氯酸钠");
80
+        stock.setCurrentStock(new BigDecimal("50"));
81
+        stock.setMinStock(new BigDecimal("100"));
82
+        stock.setStation("一水厂");
83
+
84
+        when(stockMapper.insert(any())).thenReturn(1);
85
+
86
+        ChemicalStock result = service.createStock(stock);
87
+        assertEquals("low", result.getStatus());
88
+        verify(stockMapper).insert(stock);
89
+    }
90
+
91
+    @Test
92
+    @DisplayName("测试库存状态自动更新 - 缺货")
93
+    void testStockStatusAutoUpdate_out() {
94
+        ChemicalStock stock = new ChemicalStock();
95
+        stock.setChemicalName("聚合氯化铝");
96
+        stock.setCurrentStock(BigDecimal.ZERO);
97
+        stock.setMinStock(new BigDecimal("100"));
98
+
99
+        when(stockMapper.insert(any())).thenReturn(1);
100
+
101
+        ChemicalStock result = service.createStock(stock);
102
+        assertEquals("out", result.getStatus());
103
+    }
104
+
105
+    @Test
106
+    @DisplayName("测试库存状态自动更新 - 正常")
107
+    void testStockStatusAutoUpdate_normal() {
108
+        ChemicalStock stock = new ChemicalStock();
109
+        stock.setChemicalName("聚丙烯酰胺");
110
+        stock.setCurrentStock(new BigDecimal("500"));
111
+        stock.setMinStock(new BigDecimal("100"));
112
+
113
+        when(stockMapper.insert(any())).thenReturn(1);
114
+
115
+        ChemicalStock result = service.createStock(stock);
116
+        assertEquals("normal", result.getStatus());
117
+    }
118
+
119
+    @Test
120
+    @DisplayName("测试策略推荐投加量计算 - 浊度联动")
121
+    void testCalculateRecommended_turbidityLinkage() {
122
+        DosingStrategy strategy = new DosingStrategy();
123
+        strategy.setId(1L);
124
+        strategy.setStrategyName("混凝自动投加");
125
+        strategy.setProcessStage("coagulation");
126
+        strategy.setChemicalName("聚合氯化铝");
127
+        strategy.setEnabled(true);
128
+        strategy.setBaseDosingRate(new BigDecimal("10.0"));
129
+        strategy.setTurbidityThreshold(new BigDecimal("5.0"));
130
+        strategy.setMinDosingRate(new BigDecimal("5.0"));
131
+        strategy.setMaxDosingRate(new BigDecimal("50.0"));
132
+
133
+        when(strategyMapper.selectById(1L)).thenReturn(strategy);
134
+
135
+        // 浊度=10 > 阈值5, 因子=2, 推荐=10*2=20
136
+        Map<String, Object> result = service.calculateRecommendedDosing(
137
+                1L, new BigDecimal("10.0"), null, null);
138
+
139
+        assertNotNull(result);
140
+        assertEquals("聚合氯化铝", result.get("chemicalName"));
141
+        BigDecimal rate = (BigDecimal) result.get("recommendedRate");
142
+        assertEquals(0, rate.compareTo(new BigDecimal("20.0000")));
143
+    }
144
+
145
+    @Test
146
+    @DisplayName("测试策略推荐投加量计算 - 超出最大值被限制")
147
+    void testCalculateRecommended_cappedAtMax() {
148
+        DosingStrategy strategy = new DosingStrategy();
149
+        strategy.setId(2L);
150
+        strategy.setStrategyName("消毒投加");
151
+        strategy.setProcessStage("disinfection");
152
+        strategy.setChemicalName("次氯酸钠");
153
+        strategy.setEnabled(true);
154
+        strategy.setBaseDosingRate(new BigDecimal("10.0"));
155
+        strategy.setTurbidityThreshold(new BigDecimal("1.0"));
156
+        strategy.setMaxDosingRate(new BigDecimal("15.0"));
157
+        strategy.setMinDosingRate(new BigDecimal("2.0"));
158
+
159
+        when(strategyMapper.selectById(2L)).thenReturn(strategy);
160
+
161
+        // 浊度=100, 因子=100, 推荐=1000 → 被max限制为15
162
+        Map<String, Object> result = service.calculateRecommendedDosing(
163
+                2L, new BigDecimal("100.0"), null, null);
164
+
165
+        BigDecimal rate = (BigDecimal) result.get("recommendedRate");
166
+        assertEquals(0, rate.compareTo(new BigDecimal("15.0000")));
167
+    }
168
+
169
+    @Test
170
+    @DisplayName("测试策略推荐 - 策略不存在时返回错误")
171
+    void testCalculateRecommended_strategyNotFound() {
172
+        when(strategyMapper.selectById(999L)).thenReturn(null);
173
+
174
+        Map<String, Object> result = service.calculateRecommendedDosing(
175
+                999L, new BigDecimal("5.0"), null, null);
176
+
177
+        assertTrue(result.containsKey("error"));
178
+    }
179
+
180
+    @Test
181
+    @DisplayName("测试低库存预警列表")
182
+    void testLowStockAlerts() {
183
+        ChemicalStock lowStock = new ChemicalStock();
184
+        lowStock.setId(1L);
185
+        lowStock.setChemicalName("聚合氯化铝");
186
+        lowStock.setCurrentStock(new BigDecimal("30"));
187
+        lowStock.setMinStock(new BigDecimal("100"));
188
+        lowStock.setStatus("low");
189
+        lowStock.setStation("一水厂");
190
+
191
+        when(stockMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(lowStock));
192
+
193
+        List<Map<String, Object>> alerts = service.lowStockAlerts();
194
+
195
+        assertFalse(alerts.isEmpty());
196
+        Map<String, Object> alert = alerts.get(0);
197
+        assertEquals("聚合氯化铝", alert.get("chemicalName"));
198
+        assertEquals("low", alert.get("status"));
199
+        BigDecimal deficit = (BigDecimal) alert.get("deficit");
200
+        assertEquals(0, deficit.compareTo(new BigDecimal("70")));
201
+    }
202
+
203
+    @Test
204
+    @DisplayName("测试创建投加策略 - 默认启用")
205
+    void testCreateStrategy_defaultEnabled() {
206
+        DosingStrategy strategy = new DosingStrategy();
207
+        strategy.setStrategyName("测试策略");
208
+        strategy.setProcessStage("filtration");
209
+        strategy.setChemicalName("活性炭");
210
+
211
+        when(strategyMapper.insert(any())).thenReturn(1);
212
+
213
+        DosingStrategy result = service.createStrategy(strategy);
214
+
215
+        assertTrue(result.getEnabled());
216
+        assertNotNull(result.getCreatedTime());
217
+    }
218
+}