diff --git a/wm-production/src/main/java/com/water/production/controller/ChemicalDosingController.java b/wm-production/src/main/java/com/water/production/controller/ChemicalDosingController.java new file mode 100644 index 00000000..c8cfa75d --- /dev/null +++ b/wm-production/src/main/java/com/water/production/controller/ChemicalDosingController.java @@ -0,0 +1,172 @@ +package com.water.production.controller; + +import com.water.common.core.result.R; +import com.water.production.entity.ChemicalDosing; +import com.water.production.entity.ChemicalStock; +import com.water.production.entity.DosingStrategy; +import com.water.production.service.ChemicalDosingService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +/** + * 全工艺药剂投加监控 Controller + * 混凝→沉淀→过滤→消毒 全流程药剂管理 + */ +@Tag(name = "药剂投加监控") +@RestController +@RequestMapping("/api/production/chemical") +@RequiredArgsConstructor +public class ChemicalDosingController { + + private final ChemicalDosingService chemicalDosingService; + + // ==================== 1. 投加监控 ==================== + + @Operation(summary = "投加记录分页列表") + @GetMapping("/dosing/list") + public R> listDosing( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) String processStage, + @RequestParam(required = false) String station, + @RequestParam(required = false) String status) { + return R.ok(chemicalDosingService.listDosing(page, size, processStage, station, status)); + } + + @Operation(summary = "投加记录详情") + @GetMapping("/dosing/{id}") + public R getDosing(@PathVariable Long id) { + ChemicalDosing dosing = chemicalDosingService.getDosingById(id); + return dosing != null ? R.ok(dosing) : R.fail(404, "记录不存在"); + } + + @Operation(summary = "新增投加记录") + @PostMapping("/dosing") + public R createDosing(@RequestBody ChemicalDosing dosing) { + return R.ok(chemicalDosingService.createDosing(dosing)); + } + + @Operation(summary = "更新投加记录") + @PutMapping("/dosing/{id}") + public R updateDosing(@PathVariable Long id, @RequestBody ChemicalDosing dosing) { + return R.ok(chemicalDosingService.updateDosing(id, dosing)); + } + + @Operation(summary = "删除投加记录") + @DeleteMapping("/dosing/{id}") + public R deleteDosing(@PathVariable Long id) { + return R.ok(chemicalDosingService.deleteDosing(id)); + } + + // ==================== 2. 投加记录 + 趋势分析 ==================== + + @Operation(summary = "投加历史记录列表") + @GetMapping("/record/list") + public R> listRecords( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) String processStage, + @RequestParam(required = false) String chemicalName, + @RequestParam(required = false) String startTime, + @RequestParam(required = false) String endTime) { + return R.ok(chemicalDosingService.listRecords(page, size, processStage, chemicalName, startTime, endTime)); + } + + @Operation(summary = "投加趋势分析") + @GetMapping("/record/trend") + public R>> trendAnalysis( + @RequestParam String processStage, + @RequestParam String startTime, + @RequestParam String endTime) { + return R.ok(chemicalDosingService.trendAnalysis(processStage, startTime, endTime)); + } + + // ==================== 3. 药耗统计 ==================== + + @Operation(summary = "药耗统计(日/周/月 + 单位产水药耗)") + @GetMapping("/statistics") + public R> statistics( + @RequestParam(defaultValue = "day") String period, + @RequestParam(required = false) String station) { + return R.ok(chemicalDosingService.statistics(period, station)); + } + + // ==================== 4. 投加策略 ==================== + + @Operation(summary = "投加策略列表") + @GetMapping("/strategy/list") + public R> listStrategies( + @RequestParam(required = false) String processStage, + @RequestParam(required = false) Boolean enabled) { + return R.ok(chemicalDosingService.listStrategies(processStage, enabled)); + } + + @Operation(summary = "投加策略详情") + @GetMapping("/strategy/{id}") + public R getStrategy(@PathVariable Long id) { + DosingStrategy strategy = chemicalDosingService.getStrategyById(id); + return strategy != null ? R.ok(strategy) : R.fail(404, "策略不存在"); + } + + @Operation(summary = "新增投加策略") + @PostMapping("/strategy") + public R createStrategy(@RequestBody DosingStrategy strategy) { + return R.ok(chemicalDosingService.createStrategy(strategy)); + } + + @Operation(summary = "更新投加策略") + @PutMapping("/strategy/{id}") + public R updateStrategy(@PathVariable Long id, @RequestBody DosingStrategy strategy) { + return R.ok(chemicalDosingService.updateStrategy(id, strategy)); + } + + @Operation(summary = "删除投加策略") + @DeleteMapping("/strategy/{id}") + public R deleteStrategy(@PathVariable Long id) { + return R.ok(chemicalDosingService.deleteStrategy(id)); + } + + @Operation(summary = "计算推荐投加量(基于策略联动)") + @PostMapping("/strategy/{id}/calculate") + public R> calculateRecommended( + @PathVariable Long id, + @RequestParam(required = false) BigDecimal turbidity, + @RequestParam(required = false) BigDecimal flow, + @RequestParam(required = false) BigDecimal ph) { + return R.ok(chemicalDosingService.calculateRecommendedDosing(id, turbidity, flow, ph)); + } + + // ==================== 5. 药剂库存 ==================== + + @Operation(summary = "库存列表") + @GetMapping("/stock/list") + public R> listStocks( + @RequestParam(required = false) String station, + @RequestParam(required = false) String status) { + return R.ok(chemicalDosingService.listStocks(station, status)); + } + + @Operation(summary = "新增库存记录") + @PostMapping("/stock") + public R createStock(@RequestBody ChemicalStock stock) { + return R.ok(chemicalDosingService.createStock(stock)); + } + + @Operation(summary = "更新库存") + @PutMapping("/stock/{id}") + public R updateStock(@PathVariable Long id, @RequestBody ChemicalStock stock) { + return R.ok(chemicalDosingService.updateStock(id, stock)); + } + + @Operation(summary = "低库存预警列表") + @GetMapping("/stock/low-alert") + public R>> lowStockAlerts() { + return R.ok(chemicalDosingService.lowStockAlerts()); + } +} diff --git a/wm-production/src/main/java/com/water/production/entity/ChemicalDosing.java b/wm-production/src/main/java/com/water/production/entity/ChemicalDosing.java new file mode 100644 index 00000000..c8314393 --- /dev/null +++ b/wm-production/src/main/java/com/water/production/entity/ChemicalDosing.java @@ -0,0 +1,56 @@ +package com.water.production.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 药剂投加监控记录 + * 各工艺段(混凝/沉淀/过滤/消毒)的药剂投加量实时监控 + */ +@Data +@TableName("prod_chemical_dosing") +public class ChemicalDosing { + + @TableId(type = IdType.AUTO) + private Long id; + + /** 工艺段: coagulation/sedimentation/filtration/disinfection */ + private String processStage; + + /** 药剂名称 */ + private String chemicalName; + + /** 药剂编码 */ + private String chemicalCode; + + /** 投加量(kg) */ + private BigDecimal dosingAmount; + + /** 投加速率(kg/h) */ + private BigDecimal dosingRate; + + /** 投加浓度(mg/L) */ + private BigDecimal concentration; + + /** 当时流量(m³/h) */ + private BigDecimal flowRate; + + /** 站点/水厂 */ + private String station; + + /** 操作员 */ + private String operator; + + /** 状态: active/paused/stopped */ + private String status; + + private String remark; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createdTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updatedTime; +} diff --git a/wm-production/src/main/java/com/water/production/entity/ChemicalStock.java b/wm-production/src/main/java/com/water/production/entity/ChemicalStock.java new file mode 100644 index 00000000..e0ae69d4 --- /dev/null +++ b/wm-production/src/main/java/com/water/production/entity/ChemicalStock.java @@ -0,0 +1,56 @@ +package com.water.production.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 药剂库存管理 + */ +@Data +@TableName("prod_chemical_stock") +public class ChemicalStock { + + @TableId(type = IdType.AUTO) + private Long id; + + /** 药剂名称 */ + private String chemicalName; + + /** 药剂编码 */ + private String chemicalCode; + + /** 当前库存(kg) */ + private BigDecimal currentStock; + + /** 最大库存 */ + private BigDecimal maxStock; + + /** 安全库存(低于此值预警) */ + private BigDecimal minStock; + + /** 单位 */ + private String unit; + + /** 仓库位置 */ + private String warehouse; + + /** 供应商 */ + private String supplier; + + /** 站点 */ + private String station; + + /** 状态: normal/low/out */ + private String status; + + /** 最近入库时间 */ + private LocalDateTime lastInbound; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createdTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updatedTime; +} diff --git a/wm-production/src/main/java/com/water/production/entity/DosingRecord.java b/wm-production/src/main/java/com/water/production/entity/DosingRecord.java new file mode 100644 index 00000000..485611f8 --- /dev/null +++ b/wm-production/src/main/java/com/water/production/entity/DosingRecord.java @@ -0,0 +1,47 @@ +package com.water.production.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 投加历史记录(用于趋势分析) + */ +@Data +@TableName("prod_dosing_record") +public class DosingRecord { + + @TableId(type = IdType.AUTO) + private Long id; + + /** 关联投加记录ID */ + private Long dosingId; + + /** 工艺段 */ + private String processStage; + + /** 药剂名称 */ + private String chemicalName; + + /** 投加量(kg) */ + private BigDecimal dosingAmount; + + /** 投加速率(kg/h) */ + private BigDecimal dosingRate; + + /** 投加浓度(mg/L) */ + private BigDecimal concentration; + + /** 当时流量(m³/h) */ + private BigDecimal flowRate; + + /** 站点 */ + private String station; + + /** 记录时间 */ + private LocalDateTime recordTime; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createdTime; +} diff --git a/wm-production/src/main/java/com/water/production/entity/DosingStrategy.java b/wm-production/src/main/java/com/water/production/entity/DosingStrategy.java new file mode 100644 index 00000000..d35beddc --- /dev/null +++ b/wm-production/src/main/java/com/water/production/entity/DosingStrategy.java @@ -0,0 +1,67 @@ +package com.water.production.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 自动投加策略配置(基于原水水质/流量联动) + */ +@Data +@TableName("prod_dosing_strategy") +public class DosingStrategy { + + @TableId(type = IdType.AUTO) + private Long id; + + /** 策略名称 */ + private String strategyName; + + /** 工艺段 */ + private String processStage; + + /** 药剂名称 */ + private String chemicalName; + + /** 策略类型: auto/manual/semi-auto */ + private String strategyType; + + /** 基础投加速率 */ + private BigDecimal baseDosingRate; + + /** 最小投加速率 */ + private BigDecimal minDosingRate; + + /** 最大投加速率 */ + private BigDecimal maxDosingRate; + + /** 浊度阈值联动 */ + private BigDecimal turbidityThreshold; + + /** 流量阈值联动 */ + private BigDecimal flowThreshold; + + /** pH下限 */ + private BigDecimal phThresholdMin; + + /** pH上限 */ + private BigDecimal phThresholdMax; + + /** 投加公式 */ + private String formula; + + /** 是否启用 */ + private Boolean enabled; + + /** 站点 */ + private String station; + + private String remark; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createdTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updatedTime; +} diff --git a/wm-production/src/main/java/com/water/production/mapper/ChemicalDosingMapper.java b/wm-production/src/main/java/com/water/production/mapper/ChemicalDosingMapper.java new file mode 100644 index 00000000..e7b996b4 --- /dev/null +++ b/wm-production/src/main/java/com/water/production/mapper/ChemicalDosingMapper.java @@ -0,0 +1,28 @@ +package com.water.production.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.water.production.entity.ChemicalDosing; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; +import java.util.Map; + +@Mapper +public interface ChemicalDosingMapper extends BaseMapper { + + /** 按工艺段统计今日投加量 */ + @Select("SELECT process_stage, chemical_name, SUM(dosing_amount) as total_amount " + + "FROM prod_chemical_dosing WHERE DATE(created_time) = CURRENT_DATE " + + "GROUP BY process_stage, chemical_name") + List> dailyStatistics(); + + /** 按时间段统计药耗 */ + @Select("SELECT process_stage, chemical_name, SUM(dosing_amount) as total_amount, " + + "COUNT(*) as record_count FROM prod_chemical_dosing " + + "WHERE created_time BETWEEN #{startTime} AND #{endTime} " + + "GROUP BY process_stage, chemical_name ORDER BY total_amount DESC") + List> statisticsByPeriod(@Param("startTime") String startTime, + @Param("endTime") String endTime); +} diff --git a/wm-production/src/main/java/com/water/production/mapper/ChemicalStockMapper.java b/wm-production/src/main/java/com/water/production/mapper/ChemicalStockMapper.java new file mode 100644 index 00000000..0d450722 --- /dev/null +++ b/wm-production/src/main/java/com/water/production/mapper/ChemicalStockMapper.java @@ -0,0 +1,9 @@ +package com.water.production.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.water.production.entity.ChemicalStock; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ChemicalStockMapper extends BaseMapper { +} diff --git a/wm-production/src/main/java/com/water/production/mapper/DosingRecordMapper.java b/wm-production/src/main/java/com/water/production/mapper/DosingRecordMapper.java new file mode 100644 index 00000000..1db85e60 --- /dev/null +++ b/wm-production/src/main/java/com/water/production/mapper/DosingRecordMapper.java @@ -0,0 +1,24 @@ +package com.water.production.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.water.production.entity.DosingRecord; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; +import java.util.Map; + +@Mapper +public interface DosingRecordMapper extends BaseMapper { + + /** 趋势分析:按小时聚合投加量 */ + @Select("SELECT DATE_TRUNC('hour', record_time) as time_bucket, " + + "AVG(dosing_rate) as avg_rate, SUM(dosing_amount) as total_amount " + + "FROM prod_dosing_record WHERE process_stage = #{stage} " + + "AND record_time BETWEEN #{startTime} AND #{endTime} " + + "GROUP BY time_bucket ORDER BY time_bucket") + List> trendByHour(@Param("stage") String stage, + @Param("startTime") String startTime, + @Param("endTime") String endTime); +} diff --git a/wm-production/src/main/java/com/water/production/mapper/DosingStrategyMapper.java b/wm-production/src/main/java/com/water/production/mapper/DosingStrategyMapper.java new file mode 100644 index 00000000..7add9a3e --- /dev/null +++ b/wm-production/src/main/java/com/water/production/mapper/DosingStrategyMapper.java @@ -0,0 +1,9 @@ +package com.water.production.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.water.production.entity.DosingStrategy; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface DosingStrategyMapper extends BaseMapper { +} diff --git a/wm-production/src/main/java/com/water/production/service/ChemicalDosingService.java b/wm-production/src/main/java/com/water/production/service/ChemicalDosingService.java new file mode 100644 index 00000000..7fcaadbf --- /dev/null +++ b/wm-production/src/main/java/com/water/production/service/ChemicalDosingService.java @@ -0,0 +1,308 @@ +package com.water.production.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.water.production.entity.ChemicalDosing; +import com.water.production.entity.ChemicalStock; +import com.water.production.entity.DosingRecord; +import com.water.production.entity.DosingStrategy; +import com.water.production.mapper.ChemicalDosingMapper; +import com.water.production.mapper.ChemicalStockMapper; +import com.water.production.mapper.DosingRecordMapper; +import com.water.production.mapper.DosingStrategyMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 全工艺药剂投加监控服务 + * 涵盖混凝→沉淀→过滤→消毒全流程 + */ +@Service +@RequiredArgsConstructor +public class ChemicalDosingService { + + private final ChemicalDosingMapper dosingMapper; + private final DosingRecordMapper recordMapper; + private final ChemicalStockMapper stockMapper; + private final DosingStrategyMapper strategyMapper; + + // ==================== 药剂投加监控 ==================== + + /** 分页查询投加记录 */ + public Map listDosing(int page, int size, String processStage, String station, String status) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(StringUtils.hasText(processStage), ChemicalDosing::getProcessStage, processStage) + .eq(StringUtils.hasText(station), ChemicalDosing::getStation, station) + .eq(StringUtils.hasText(status), ChemicalDosing::getStatus, status) + .orderByDesc(ChemicalDosing::getCreatedTime); + Page result = dosingMapper.selectPage(new Page<>(page, size), wrapper); + Map data = new HashMap<>(); + data.put("records", result.getRecords()); + data.put("total", result.getTotal()); + data.put("pages", result.getPages()); + return data; + } + + /** 获取投加详情 */ + public ChemicalDosing getDosingById(Long id) { + return dosingMapper.selectById(id); + } + + /** 新增投加记录 */ + public ChemicalDosing createDosing(ChemicalDosing dosing) { + if (dosing.getStatus() == null) { + dosing.setStatus("active"); + } + dosing.setCreatedTime(LocalDateTime.now()); + dosing.setUpdatedTime(LocalDateTime.now()); + dosingMapper.insert(dosing); + saveDosingRecord(dosing); + return dosing; + } + + /** 更新投加记录 */ + public boolean updateDosing(Long id, ChemicalDosing dosing) { + dosing.setId(id); + dosing.setUpdatedTime(LocalDateTime.now()); + int rows = dosingMapper.updateById(dosing); + if (rows > 0) { + saveDosingRecord(dosing); + } + return rows > 0; + } + + /** 删除投加记录 */ + public boolean deleteDosing(Long id) { + return dosingMapper.deleteById(id) > 0; + } + + // ==================== 投加记录 + 趋势分析 ==================== + + /** 投加记录列表 */ + public Map listRecords(int page, int size, String processStage, String chemicalName, + String startTime, String endTime) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(StringUtils.hasText(processStage), DosingRecord::getProcessStage, processStage) + .eq(StringUtils.hasText(chemicalName), DosingRecord::getChemicalName, chemicalName); + if (StringUtils.hasText(startTime)) { + wrapper.ge(DosingRecord::getRecordTime, LocalDateTime.parse(startTime, DateTimeFormatter.ISO_LOCAL_DATE_TIME)); + } + if (StringUtils.hasText(endTime)) { + wrapper.le(DosingRecord::getRecordTime, LocalDateTime.parse(endTime, DateTimeFormatter.ISO_LOCAL_DATE_TIME)); + } + wrapper.orderByDesc(DosingRecord::getRecordTime); + Page result = recordMapper.selectPage(new Page<>(page, size), wrapper); + Map data = new HashMap<>(); + data.put("records", result.getRecords()); + data.put("total", result.getTotal()); + return data; + } + + /** 趋势分析 */ + public List> trendAnalysis(String processStage, String startTime, String endTime) { + return recordMapper.trendByHour(processStage, startTime, endTime); + } + + // ==================== 药耗统计 ==================== + + /** 药耗统计:日/周/月 + 单位产水药耗 */ + public Map statistics(String period, String station) { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime start; + LocalDateTime end = now; + + switch (period != null ? period : "day") { + case "week": + start = now.minusWeeks(1); + break; + case "month": + start = now.minusMonths(1); + break; + default: + start = now.minusDays(1); + } + + String startTime = start.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); + String endTime = end.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); + List> rawStats = dosingMapper.statisticsByPeriod(startTime, endTime); + + if (StringUtils.hasText(station)) { + rawStats = rawStats.stream() + .filter(m -> station.equals(m.get("station"))) + .collect(Collectors.toList()); + } + + BigDecimal totalAmount = rawStats.stream() + .map(m -> m.get("total_amount") != null ? new BigDecimal(m.get("total_amount").toString()) : BigDecimal.ZERO) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + Map result = new HashMap<>(); + result.put("period", period); + result.put("details", rawStats); + result.put("totalAmount", totalAmount.setScale(4, RoundingMode.HALF_UP)); + result.put("unitConsumption", calculateUnitConsumption(totalAmount, start, end)); + return result; + } + + private BigDecimal calculateUnitConsumption(BigDecimal totalAmount, LocalDateTime start, LocalDateTime end) { + long hours = java.time.Duration.between(start, end).toHours(); + if (hours <= 0) hours = 1; + BigDecimal totalWater = BigDecimal.valueOf(hours * 100L); + return totalAmount.divide(totalWater, 6, RoundingMode.HALF_UP); + } + + // ==================== 投加策略 ==================== + + public List listStrategies(String processStage, Boolean enabled) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(StringUtils.hasText(processStage), DosingStrategy::getProcessStage, processStage) + .eq(enabled != null, DosingStrategy::getEnabled, enabled) + .orderByAsc(DosingStrategy::getProcessStage); + return strategyMapper.selectList(wrapper); + } + + public DosingStrategy getStrategyById(Long id) { + return strategyMapper.selectById(id); + } + + public DosingStrategy createStrategy(DosingStrategy strategy) { + strategy.setCreatedTime(LocalDateTime.now()); + strategy.setUpdatedTime(LocalDateTime.now()); + if (strategy.getEnabled() == null) strategy.setEnabled(true); + strategyMapper.insert(strategy); + return strategy; + } + + public boolean updateStrategy(Long id, DosingStrategy strategy) { + strategy.setId(id); + strategy.setUpdatedTime(LocalDateTime.now()); + return strategyMapper.updateById(strategy) > 0; + } + + public boolean deleteStrategy(Long id) { + return strategyMapper.deleteById(id) > 0; + } + + /** 根据策略计算推荐投加量 */ + public Map calculateRecommendedDosing(Long strategyId, BigDecimal currentTurbidity, + BigDecimal currentFlow, BigDecimal currentPh) { + DosingStrategy strategy = strategyMapper.selectById(strategyId); + if (strategy == null || !Boolean.TRUE.equals(strategy.getEnabled())) { + return Map.of("error", "策略不存在或未启用"); + } + + BigDecimal recommendedRate = strategy.getBaseDosingRate() != null ? strategy.getBaseDosingRate() : BigDecimal.ZERO; + + if (currentTurbidity != null && strategy.getTurbidityThreshold() != null) { + if (currentTurbidity.compareTo(strategy.getTurbidityThreshold()) > 0) { + BigDecimal factor = currentTurbidity.divide(strategy.getTurbidityThreshold(), 4, RoundingMode.HALF_UP); + recommendedRate = recommendedRate.multiply(factor); + } + } + + if (currentFlow != null && strategy.getFlowThreshold() != null) { + if (currentFlow.compareTo(strategy.getFlowThreshold()) > 0) { + BigDecimal flowFactor = currentFlow.divide(strategy.getFlowThreshold(), 4, RoundingMode.HALF_UP); + recommendedRate = recommendedRate.multiply(flowFactor); + } + } + + if (strategy.getMinDosingRate() != null) { + recommendedRate = recommendedRate.max(strategy.getMinDosingRate()); + } + if (strategy.getMaxDosingRate() != null) { + recommendedRate = recommendedRate.min(strategy.getMaxDosingRate()); + } + + Map result = new HashMap<>(); + result.put("strategyId", strategyId); + result.put("strategyName", strategy.getStrategyName()); + result.put("recommendedRate", recommendedRate.setScale(4, RoundingMode.HALF_UP)); + result.put("chemicalName", strategy.getChemicalName()); + result.put("processStage", strategy.getProcessStage()); + return result; + } + + // ==================== 药剂库存 ==================== + + public List listStocks(String station, String status) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(StringUtils.hasText(station), ChemicalStock::getStation, station) + .eq(StringUtils.hasText(status), ChemicalStock::getStatus, status) + .orderByAsc(ChemicalStock::getStatus); + return stockMapper.selectList(wrapper); + } + + public ChemicalStock createStock(ChemicalStock stock) { + stock.setCreatedTime(LocalDateTime.now()); + stock.setUpdatedTime(LocalDateTime.now()); + updateStockStatus(stock); + stockMapper.insert(stock); + return stock; + } + + public boolean updateStock(Long id, ChemicalStock stock) { + stock.setId(id); + stock.setUpdatedTime(LocalDateTime.now()); + updateStockStatus(stock); + return stockMapper.updateById(stock) > 0; + } + + public List> lowStockAlerts() { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ChemicalStock::getStatus, "low").or().eq(ChemicalStock::getStatus, "out"); + List lowStocks = stockMapper.selectList(wrapper); + + return lowStocks.stream().map(s -> { + Map alert = new HashMap<>(); + alert.put("id", s.getId()); + alert.put("chemicalName", s.getChemicalName()); + alert.put("currentStock", s.getCurrentStock()); + alert.put("minStock", s.getMinStock()); + alert.put("status", s.getStatus()); + alert.put("station", s.getStation()); + alert.put("warehouse", s.getWarehouse()); + BigDecimal deficit = s.getMinStock() != null && s.getCurrentStock() != null + ? s.getMinStock().subtract(s.getCurrentStock()) : BigDecimal.ZERO; + alert.put("deficit", deficit.max(BigDecimal.ZERO)); + return alert; + }).collect(Collectors.toList()); + } + + private void updateStockStatus(ChemicalStock stock) { + if (stock.getCurrentStock() == null || stock.getMinStock() == null) { + stock.setStatus("normal"); + return; + } + if (stock.getCurrentStock().compareTo(BigDecimal.ZERO) <= 0) { + stock.setStatus("out"); + } else if (stock.getCurrentStock().compareTo(stock.getMinStock()) < 0) { + stock.setStatus("low"); + } else { + stock.setStatus("normal"); + } + } + + private void saveDosingRecord(ChemicalDosing dosing) { + DosingRecord record = new DosingRecord(); + record.setDosingId(dosing.getId()); + record.setProcessStage(dosing.getProcessStage()); + record.setChemicalName(dosing.getChemicalName()); + record.setDosingAmount(dosing.getDosingAmount()); + record.setDosingRate(dosing.getDosingRate()); + record.setConcentration(dosing.getConcentration()); + record.setFlowRate(dosing.getFlowRate()); + record.setStation(dosing.getStation()); + record.setRecordTime(LocalDateTime.now()); + record.setCreatedTime(LocalDateTime.now()); + recordMapper.insert(record); + } +} diff --git a/wm-production/src/test/java/com/water/production/service/ChemicalDosingServiceTest.java b/wm-production/src/test/java/com/water/production/service/ChemicalDosingServiceTest.java new file mode 100644 index 00000000..0b7b7678 --- /dev/null +++ b/wm-production/src/test/java/com/water/production/service/ChemicalDosingServiceTest.java @@ -0,0 +1,218 @@ +package com.water.production.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.water.production.entity.ChemicalDosing; +import com.water.production.entity.ChemicalStock; +import com.water.production.entity.DosingStrategy; +import com.water.production.mapper.ChemicalDosingMapper; +import com.water.production.mapper.ChemicalStockMapper; +import com.water.production.mapper.DosingRecordMapper; +import com.water.production.mapper.DosingStrategyMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * ChemicalDosingService 单元测试 + * 测试药剂投加监控、库存管理、投加策略计算等核心逻辑 + */ +class ChemicalDosingServiceTest { + + private ChemicalDosingService service; + private ChemicalDosingMapper dosingMapper; + private DosingRecordMapper recordMapper; + private ChemicalStockMapper stockMapper; + private DosingStrategyMapper strategyMapper; + + @BeforeEach + void setUp() { + dosingMapper = mock(ChemicalDosingMapper.class); + recordMapper = mock(DosingRecordMapper.class); + stockMapper = mock(ChemicalStockMapper.class); + strategyMapper = mock(DosingStrategyMapper.class); + service = new ChemicalDosingService(dosingMapper, recordMapper, stockMapper, strategyMapper); + } + + @Test + @DisplayName("测试新增投加记录 - 自动同步历史记录") + void testCreateDosing_autoSyncRecord() { + ChemicalDosing dosing = new ChemicalDosing(); + dosing.setProcessStage("coagulation"); + dosing.setChemicalName("聚合氯化铝"); + dosing.setDosingAmount(new BigDecimal("10.5")); + dosing.setDosingRate(new BigDecimal("2.5")); + dosing.setStation("一水厂"); + + when(dosingMapper.insert(any())).thenReturn(1); + when(recordMapper.insert(any())).thenReturn(1); + + ChemicalDosing result = service.createDosing(dosing); + + assertNotNull(result); + assertEquals("active", result.getStatus()); + assertNotNull(result.getCreatedTime()); + verify(dosingMapper).insert(dosing); + verify(recordMapper).insert(any()); + } + + @Test + @DisplayName("测试删除投加记录") + void testDeleteDosing() { + when(dosingMapper.deleteById(1L)).thenReturn(1); + when(dosingMapper.deleteById(999L)).thenReturn(0); + + assertTrue(service.deleteDosing(1L)); + assertFalse(service.deleteDosing(999L)); + } + + @Test + @DisplayName("测试库存状态自动更新 - 低库存预警") + void testStockStatusAutoUpdate_low() { + ChemicalStock stock = new ChemicalStock(); + stock.setChemicalName("次氯酸钠"); + stock.setCurrentStock(new BigDecimal("50")); + stock.setMinStock(new BigDecimal("100")); + stock.setStation("一水厂"); + + when(stockMapper.insert(any())).thenReturn(1); + + ChemicalStock result = service.createStock(stock); + assertEquals("low", result.getStatus()); + verify(stockMapper).insert(stock); + } + + @Test + @DisplayName("测试库存状态自动更新 - 缺货") + void testStockStatusAutoUpdate_out() { + ChemicalStock stock = new ChemicalStock(); + stock.setChemicalName("聚合氯化铝"); + stock.setCurrentStock(BigDecimal.ZERO); + stock.setMinStock(new BigDecimal("100")); + + when(stockMapper.insert(any())).thenReturn(1); + + ChemicalStock result = service.createStock(stock); + assertEquals("out", result.getStatus()); + } + + @Test + @DisplayName("测试库存状态自动更新 - 正常") + void testStockStatusAutoUpdate_normal() { + ChemicalStock stock = new ChemicalStock(); + stock.setChemicalName("聚丙烯酰胺"); + stock.setCurrentStock(new BigDecimal("500")); + stock.setMinStock(new BigDecimal("100")); + + when(stockMapper.insert(any())).thenReturn(1); + + ChemicalStock result = service.createStock(stock); + assertEquals("normal", result.getStatus()); + } + + @Test + @DisplayName("测试策略推荐投加量计算 - 浊度联动") + void testCalculateRecommended_turbidityLinkage() { + DosingStrategy strategy = new DosingStrategy(); + strategy.setId(1L); + strategy.setStrategyName("混凝自动投加"); + strategy.setProcessStage("coagulation"); + strategy.setChemicalName("聚合氯化铝"); + strategy.setEnabled(true); + strategy.setBaseDosingRate(new BigDecimal("10.0")); + strategy.setTurbidityThreshold(new BigDecimal("5.0")); + strategy.setMinDosingRate(new BigDecimal("5.0")); + strategy.setMaxDosingRate(new BigDecimal("50.0")); + + when(strategyMapper.selectById(1L)).thenReturn(strategy); + + // 浊度=10 > 阈值5, 因子=2, 推荐=10*2=20 + Map result = service.calculateRecommendedDosing( + 1L, new BigDecimal("10.0"), null, null); + + assertNotNull(result); + assertEquals("聚合氯化铝", result.get("chemicalName")); + BigDecimal rate = (BigDecimal) result.get("recommendedRate"); + assertEquals(0, rate.compareTo(new BigDecimal("20.0000"))); + } + + @Test + @DisplayName("测试策略推荐投加量计算 - 超出最大值被限制") + void testCalculateRecommended_cappedAtMax() { + DosingStrategy strategy = new DosingStrategy(); + strategy.setId(2L); + strategy.setStrategyName("消毒投加"); + strategy.setProcessStage("disinfection"); + strategy.setChemicalName("次氯酸钠"); + strategy.setEnabled(true); + strategy.setBaseDosingRate(new BigDecimal("10.0")); + strategy.setTurbidityThreshold(new BigDecimal("1.0")); + strategy.setMaxDosingRate(new BigDecimal("15.0")); + strategy.setMinDosingRate(new BigDecimal("2.0")); + + when(strategyMapper.selectById(2L)).thenReturn(strategy); + + // 浊度=100, 因子=100, 推荐=1000 → 被max限制为15 + Map result = service.calculateRecommendedDosing( + 2L, new BigDecimal("100.0"), null, null); + + BigDecimal rate = (BigDecimal) result.get("recommendedRate"); + assertEquals(0, rate.compareTo(new BigDecimal("15.0000"))); + } + + @Test + @DisplayName("测试策略推荐 - 策略不存在时返回错误") + void testCalculateRecommended_strategyNotFound() { + when(strategyMapper.selectById(999L)).thenReturn(null); + + Map result = service.calculateRecommendedDosing( + 999L, new BigDecimal("5.0"), null, null); + + assertTrue(result.containsKey("error")); + } + + @Test + @DisplayName("测试低库存预警列表") + void testLowStockAlerts() { + ChemicalStock lowStock = new ChemicalStock(); + lowStock.setId(1L); + lowStock.setChemicalName("聚合氯化铝"); + lowStock.setCurrentStock(new BigDecimal("30")); + lowStock.setMinStock(new BigDecimal("100")); + lowStock.setStatus("low"); + lowStock.setStation("一水厂"); + + when(stockMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(lowStock)); + + List> alerts = service.lowStockAlerts(); + + assertFalse(alerts.isEmpty()); + Map alert = alerts.get(0); + assertEquals("聚合氯化铝", alert.get("chemicalName")); + assertEquals("low", alert.get("status")); + BigDecimal deficit = (BigDecimal) alert.get("deficit"); + assertEquals(0, deficit.compareTo(new BigDecimal("70"))); + } + + @Test + @DisplayName("测试创建投加策略 - 默认启用") + void testCreateStrategy_defaultEnabled() { + DosingStrategy strategy = new DosingStrategy(); + strategy.setStrategyName("测试策略"); + strategy.setProcessStage("filtration"); + strategy.setChemicalName("活性炭"); + + when(strategyMapper.insert(any())).thenReturn(1); + + DosingStrategy result = service.createStrategy(strategy); + + assertTrue(result.getEnabled()); + assertNotNull(result.getCreatedTime()); + } +}