feat: 任务自动分配,预测与预警

feature-wangxilei-dev
wxl 4 days ago
parent 5e87e96a6f
commit 0bebfcc342
  1. 6
      lab-service-api/lab-dict-api/src/main/java/org/springblade/system/enums/DictBizEnum.java
  2. 98
      lab-service-api/lab-lims-api/src/main/java/org/springblade/lims/entry/AlertRecord.java
  3. 47
      lab-service-api/lab-lims-api/src/main/java/org/springblade/lims/entry/AssignRule.java
  4. 47
      lab-service-api/lab-lims-api/src/main/java/org/springblade/lims/entry/AssignRuleLog.java
  5. 3
      lab-service-api/lab-lims-api/src/main/java/org/springblade/lims/entry/Entrust.java
  6. 70
      lab-service-api/lab-lims-api/src/main/java/org/springblade/lims/entry/ThresholdConfig.java
  7. 29
      lab-service/lab-lims/pom.xml
  8. 368
      lab-service/lab-lims/src/main/java/org/springblade/lims/Scheduled/GlobalScheduledTasks.java
  9. 47
      lab-service/lab-lims/src/main/java/org/springblade/lims/api/SimpleImportRow.java
  10. 155
      lab-service/lab-lims/src/main/java/org/springblade/lims/controller/AlertRecordController.java
  11. 329
      lab-service/lab-lims/src/main/java/org/springblade/lims/controller/AssignRuleController.java
  12. 70
      lab-service/lab-lims/src/main/java/org/springblade/lims/controller/ThresholdConfigController.java
  13. 167
      lab-service/lab-lims/src/main/java/org/springblade/lims/drools/AssignRuleDroolsService.java
  14. 52
      lab-service/lab-lims/src/main/java/org/springblade/lims/excel/SimpleImportExcel.java
  15. 14
      lab-service/lab-lims/src/main/java/org/springblade/lims/mapper/AlertRecordMapper.java
  16. 10
      lab-service/lab-lims/src/main/java/org/springblade/lims/mapper/AlertRecordMapper.xml
  17. 7
      lab-service/lab-lims/src/main/java/org/springblade/lims/mapper/AssignRuleLogMapper.java
  18. 14
      lab-service/lab-lims/src/main/java/org/springblade/lims/mapper/AssignRuleMapper.java
  19. 14
      lab-service/lab-lims/src/main/java/org/springblade/lims/mapper/ThresholdConfigMapper.java
  20. 10
      lab-service/lab-lims/src/main/java/org/springblade/lims/mapper/ThresholdConfigMapper.xml
  21. 24
      lab-service/lab-lims/src/main/java/org/springblade/lims/service/IAlertRecordService.java
  22. 9
      lab-service/lab-lims/src/main/java/org/springblade/lims/service/IAssignRuleLogService.java
  23. 40
      lab-service/lab-lims/src/main/java/org/springblade/lims/service/IAssignRuleService.java
  24. 14
      lab-service/lab-lims/src/main/java/org/springblade/lims/service/IThresholdConfigService.java
  25. 24
      lab-service/lab-lims/src/main/java/org/springblade/lims/service/impl/AlertRecordServiceImpl.java
  26. 16
      lab-service/lab-lims/src/main/java/org/springblade/lims/service/impl/AssignRuleLogServiceImpl.java
  27. 473
      lab-service/lab-lims/src/main/java/org/springblade/lims/service/impl/AssignRuleServiceImpl.java
  28. 14
      lab-service/lab-lims/src/main/java/org/springblade/lims/service/impl/TaskBlueprintServiceImpl.java
  29. 20
      lab-service/lab-lims/src/main/java/org/springblade/lims/service/impl/ThresholdConfigServiceImpl.java
  30. 122
      lab-service/lab-lims/src/test/java/org/springblade/lims/AssignRuleControllerTest.java
  31. 58
      lab-service/lab-lims/src/test/java/org/springblade/lims/AssignRuleTriggerIntegrationTest.java
  32. 14
      lab-service/lab-lims/src/test/java/org/springblade/lims/BaseLimsTest.java
  33. 193
      lab-service/lab-lims/src/test/java/org/springblade/lims/drools/AssignRuleDroolsServiceTest.java
  34. 8
      lab-service/lab-lims/src/test/resources/application-test.yml
  35. 34
      lab-service/lab-system/src/main/java/org/springblade/system/controller/RegionController.java

@ -92,7 +92,11 @@ public enum DictBizEnum {
/**
* 定时任务
*/
SCHEDULED_TASK("scheduled_task");
SCHEDULED_TASK("scheduled_task"),
/**
* 自动分配规则
*/
ASSIGN_RULE_AUTO("assign_rule_auto");
final String name;

@ -0,0 +1,98 @@
package org.springblade.lims.entry;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import org.springframework.data.annotation.Id;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
/**
* 预警记录
*
* @author swj
*/
@Data
@SuppressWarnings("all")
@TableName("f_alert_record")
public class AlertRecord implements Serializable {
private static final long serialVersionUID = 1L;
@Id
public Long id;
/**
* 关联阈值配置ID
*/
public Long thresholdConfigId;
/**
* 预警级别: warning/danger
*/
public String alertLevel;
/**
* 指标编码
*/
public String indicatorCode;
/**
* 指标名称
*/
public String indicatorName;
/**
* 作用范围描述(: 全省/济南市/口蹄疫)
*/
public String scopeName;
/**
* 触发时的当前值
*/
public BigDecimal currentValue;
/**
* 触发时的阈值线值
*/
public BigDecimal thresholdValue;
/**
* 触发时间
*/
public Date triggerTime;
/**
* 状态: untreated/confirmed/processed (映射到数据库status列)
*/
@TableField("status")
public String alertStatus;
/**
* 备注/处理说明
*/
public String remark;
/** 创建人 */
@TableField(fill = FieldFill.INSERT)
public Long createUser;
/** 创建部门 */
@TableField(fill = FieldFill.INSERT)
public Long createDept;
/** 创建时间 */
@TableField(fill = FieldFill.INSERT)
public Date createTime;
/** 修改人 */
@TableField(fill = FieldFill.UPDATE)
public Long updateUser;
/** 修改时间 */
@TableField(fill = FieldFill.UPDATE)
public Date updateTime;
}

@ -0,0 +1,47 @@
package org.springblade.lims.entry;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import org.springblade.core.mp.base.BaseEntity;
import org.springframework.data.annotation.Id;
import java.io.Serializable;
@Data
@SuppressWarnings("all")
@TableName("f_assign_rule")
public class AssignRule extends BaseEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Id
private Long id;
@ApiModelProperty("规则名称")
public String name;
@ApiModelProperty("条件配置(JSON)")
public String conditions;
@ApiModelProperty("目标科室ID")
public Long targetDeptId;
@ApiModelProperty("目标科室名称")
public String targetDeptName;
@ApiModelProperty("触发频率")
public String frequency;
@ApiModelProperty("科室负载阈值")
public Integer deptLoadThreshold;
@ApiModelProperty("启用状态(0禁用 1启用)")
public Integer enabled;
@ApiModelProperty("排序")
public Integer sort;
@ApiModelProperty("未领取超时时间(小时),0表示不限制")
public Integer unclaimTimeoutHours;
}

@ -0,0 +1,47 @@
package org.springblade.lims.entry;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import org.springblade.core.mp.base.BaseEntity;
import org.springframework.data.annotation.Id;
import java.io.Serializable;
@Data
@TableName("f_assign_rule_log")
public class AssignRuleLog extends BaseEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@ApiModelProperty("主键ID")
private Long id;
@ApiModelProperty("规则ID")
private Long ruleId;
@ApiModelProperty("规则名称")
private String ruleName;
@ApiModelProperty("Examine记录ID")
private Long examineId;
@ApiModelProperty("检测编号")
private String experieNum;
@ApiModelProperty("原科室ID")
private Long sourceDeptId;
@ApiModelProperty("目标科室ID")
private Long targetDeptId;
@ApiModelProperty("目标科室名称")
private String targetDeptName;
@ApiModelProperty("状态(1=成功 0=跳过)")
private Integer status;
@ApiModelProperty("匹配描述")
private String matchDesc;
}

@ -59,6 +59,9 @@ public class Entrust extends BaseEntity implements Serializable {
@ExcelProperty("城市")
public String city;
// 3.行政区划代码(省市区三级联动最后一级的code)
public String regionCode;
// 3.受检单位地址
@ExcelProperty("养殖场地址(受检单位地址)")
public String customeAddress;

@ -0,0 +1,70 @@
package org.springblade.lims.entry;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import org.springblade.core.mp.base.BaseEntity;
import org.springframework.data.annotation.Id;
import java.io.Serializable;
import java.math.BigDecimal;
/**
* 阈值配置
*
* @author swj
*/
@Data
@SuppressWarnings("all")
@TableName("f_threshold_config")
public class ThresholdConfig extends BaseEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Id
public Long id;
/**
* 指标编码: group_positive_rate(群体阳性率), monthly_positive_growth(月度阳性增长率), farm_positive_count(单场阳性数), region_new_cases(区域新增病例)
*/
public String indicatorCode;
/**
* 指标名称
*/
public String indicatorName;
/**
* 警示线(触发warning级别)
*/
public BigDecimal warningLine;
/**
* 预警线(触发danger级别)
*/
public BigDecimal alertLine;
/**
* 作用范围类型: all(全省)/city(某市)/disease(某病种)
*/
public String scopeType;
/**
* 范围具体值: scope_type=city时为blade_region.code
*/
public String scopeValue;
/**
* 关联病种ID(t_examine_item.id)
*/
public Long diseaseId;
/**
* 启用状态: 0=禁用 1=启用
*/
public Integer enabled;
/**
* 备注
*/
public String remark;
}

@ -27,6 +27,23 @@
<artifactId>QLExpress</artifactId>
<version>3.2.0</version>
</dependency>
<!-- Drools rule engine (Java 8 compatible) -->
<dependency>
<groupId>org.drools</groupId>
<artifactId>drools-core</artifactId>
<version>7.73.0.Final</version>
</dependency>
<dependency>
<groupId>org.drools</groupId>
<artifactId>drools-compiler</artifactId>
<version>7.73.0.Final</version>
</dependency>
<dependency>
<groupId>org.drools</groupId>
<artifactId>drools-mvel</artifactId>
<version>7.73.0.Final</version>
</dependency>
<!--word页码-->
<dependency>
<groupId>e-iceblue</groupId>
@ -209,6 +226,18 @@
<artifactId>commons-logging</artifactId>
<version>1.2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>

@ -2,24 +2,35 @@ package org.springblade.lims.Scheduled;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.extern.slf4j.Slf4j;
import org.springblade.lims.entry.AlertRecord;
import org.springblade.lims.entry.Entrust;
import org.springblade.lims.entry.Examine;
import org.springblade.lims.entry.ExamineResult;
import org.springblade.lims.entry.ThresholdConfig;
import org.springblade.lims.service.IAlertRecordService;
import org.springblade.lims.service.IAssignRuleService;
import org.springblade.lims.service.IEntrtrustService;
import org.springblade.lims.service.IExamineResultService;
import org.springblade.lims.service.IExamineService;
import org.springblade.lims.service.IThresholdConfigService;
import org.springblade.system.cache.DictBizCache;
import org.springblade.system.enums.DictBizEnum;
import org.springframework.context.annotation.Lazy;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.text.SimpleDateFormat;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
/**
* @BelongsProject: project_husbandry_back
* @BelongsPackageorg.springblade.lims.Scheduled
* @Annotation:定时任务
* @Author:YangMaoFu
* @date Created in 2023/7/6 14:18
* @Date Created in 2023/7/6 14:18
* @CreateTime: 2026-04-16 23:27
* @Description: TODO
* @Version: 1.0
@ -29,9 +40,23 @@ import java.util.*;
@Component
public class GlobalScheduledTasks {
private final IEntrtrustService service;
private final IAssignRuleService assignRuleService;
private final IThresholdConfigService thresholdConfigService;
private final IAlertRecordService alertRecordService;
private final IExamineService examineService;
private final IExamineResultService examineResultService;
public GlobalScheduledTasks(IEntrtrustService service) {
public GlobalScheduledTasks(IEntrtrustService service, IAssignRuleService assignRuleService,
@Lazy IThresholdConfigService thresholdConfigService,
@Lazy IAlertRecordService alertRecordService,
@Lazy IExamineService examineService,
@Lazy IExamineResultService examineResultService) {
this.service = service;
this.assignRuleService = assignRuleService;
this.thresholdConfigService = thresholdConfigService;
this.alertRecordService = alertRecordService;
this.examineService = examineService;
this.examineResultService = examineResultService;
}
/**
@ -93,4 +118,341 @@ public class GlobalScheduledTasks {
log.error("===== 报告生成任务异常 =====: {}" ,LocalDateTime.now(), e);
}
}
/**
* 30 分钟执行自动分配规则
*/
@Scheduled(cron = "0 0/30 * * * ?")
public void autoAssignRules() {
// ===================== 核心:开关判断 =====================
String enabled = DictBizCache.getKey(DictBizEnum.ASSIGN_RULE_AUTO.getName(), "openScheduled");
if ("1".equals(enabled)) {
log.info("===== 自动分配规则任务已关闭,不执行 =====");
return;
}
log.info("===== 开始执行自动分配规则任务 =====: {}", LocalDateTime.now());
try {
Map<String, Object> result = assignRuleService.triggerAssignment();
log.info("自动分配完成: 匹配{}条, 分配{}条, 规则{}条",
result.get("totalMatched"), result.get("totalAssigned"), result.get("ruleCount"));
log.info("===== 自动分配规则任务完成 =====: {}", LocalDateTime.now());
} catch (Exception e) {
log.error("===== 自动分配规则任务异常 =====: {}", LocalDateTime.now(), e);
}
}
// =================================================================
// 预测预警 - 定时检测指标是否超过阈值
// =================================================================
/**
* 每天凌晨2点执行预警检测定时任务入口
*/
@Scheduled(cron = "0 0 2 * * ?")
public void checkPredictionWarnings() {
// 开关判断:使用predictionWarning字典,默认关闭
String enabled = DictBizCache.getKey("predictionWarning", "openScheduled");
if ("1".equals(enabled)) {
log.info("===== 预测预警任务已关闭,不执行 =====");
return;
}
log.info("===== 凌晨2点,开始执行预警检测 =====: {}", LocalDateTime.now());
doCheck();
}
/**
* 手动触发预警检测跳过开关判断
*/
public void manualCheck() {
log.info("===== 手动触发预警检测 =====: {}", LocalDateTime.now());
doCheck();
}
/**
* 预警检测核心逻辑
*/
private void doCheck() {
try {
// 查询所有启用的阈值配置
List<ThresholdConfig> configs = thresholdConfigService.lambdaQuery()
.eq(ThresholdConfig::getEnabled, 1)
.eq(ThresholdConfig::getIsDeleted, 0)
.list();
if (configs.isEmpty()) {
log.info("===== 没有启用的阈值配置,跳过 ===== ");
return;
}
log.info("查询到启用的阈值配置: {} 条", configs.size());
Calendar cal = Calendar.getInstance();
Date now = cal.getTime();
// 本月开始
cal.set(Calendar.DAY_OF_MONTH, 1);
cal.set(Calendar.HOUR_OF_DAY, 0);
cal.set(Calendar.MINUTE, 0);
cal.set(Calendar.SECOND, 0);
cal.set(Calendar.MILLISECOND, 0);
Date monthStart = cal.getTime();
// 上月开始
cal.add(Calendar.MONTH, -1);
Date prevMonthStart = cal.getTime();
// 7天前
cal = Calendar.getInstance();
cal.add(Calendar.DAY_OF_YEAR, -7);
cal.set(Calendar.HOUR_OF_DAY, 0);
cal.set(Calendar.MINUTE, 0);
cal.set(Calendar.SECOND, 0);
cal.set(Calendar.MILLISECOND, 0);
Date sevenDaysAgo = cal.getTime();
for (ThresholdConfig config : configs) {
try {
BigDecimal currentValue = null;
switch (config.getIndicatorCode()) {
case "group_positive_rate":
currentValue = calcGroupPositiveRate(config, monthStart, now);
break;
case "monthly_positive_growth":
BigDecimal thisMonthRate = calcGroupPositiveRate(config, monthStart, now);
BigDecimal lastMonthRate = calcGroupPositiveRate(config, prevMonthStart, monthStart);
if (thisMonthRate != null && lastMonthRate != null) {
currentValue = thisMonthRate.subtract(lastMonthRate);
}
break;
case "farm_positive_count":
currentValue = calcFarmPositiveCount(config, monthStart, now);
break;
case "region_new_cases":
currentValue = calcRegionNewCases(config, sevenDaysAgo, now);
break;
default:
log.warn("未知指标编码: {}", config.getIndicatorCode());
continue;
}
if (currentValue == null) continue;
// 判断预警级别 (先用预警线检查,预警线更高)
String alertLevel = null;
BigDecimal thresholdValue = null;
if (currentValue.compareTo(config.getAlertLine()) >= 0) {
alertLevel = "danger";
thresholdValue = config.getAlertLine();
} else if (currentValue.compareTo(config.getWarningLine()) >= 0) {
alertLevel = "warning";
thresholdValue = config.getWarningLine();
}
if (alertLevel != null) {
// 构造作用范围名称
String scopeName = getScopeName(config);
// 检查当天是否已为同一配置+级别生成过预警记录
Calendar todayStart = Calendar.getInstance();
todayStart.set(Calendar.HOUR_OF_DAY, 0);
todayStart.set(Calendar.MINUTE, 0);
todayStart.set(Calendar.SECOND, 0);
todayStart.set(Calendar.MILLISECOND, 0);
long existingCount = alertRecordService.lambdaQuery()
.eq(AlertRecord::getThresholdConfigId, config.getId())
.eq(AlertRecord::getAlertLevel, alertLevel)
.ge(AlertRecord::getCreateTime, todayStart.getTime())
.count();
if (existingCount > 0) {
log.info("配置[{}]级别[{}]已生成过预警,跳过", config.getIndicatorName(), alertLevel);
continue;
}
// 生成预警记录
AlertRecord record = new AlertRecord();
record.setThresholdConfigId(config.getId());
record.setAlertLevel(alertLevel);
record.setIndicatorCode(config.getIndicatorCode());
record.setIndicatorName(config.getIndicatorName());
record.setScopeName(scopeName);
record.setCurrentValue(currentValue);
record.setThresholdValue(thresholdValue);
record.setTriggerTime(now);
record.setAlertStatus("untreated");
alertRecordService.save(record);
log.info("生成预警记录: 指标[{}], 级别[{}], 当前值[{}], 阈值[{}]",
config.getIndicatorName(), alertLevel, currentValue, thresholdValue);
}
} catch (Exception e) {
log.error("计算指标[{}]异常: {}", config.getIndicatorCode(), e.getMessage(), e);
}
}
log.info("===== 预警检测完成 =====: {}", LocalDateTime.now());
} catch (Exception e) {
log.error("===== 预警检测任务异常 =====: {}", LocalDateTime.now(), e);
}
}
/**
* 计算群体阳性率 (%)
* positive_count / total_samples * 100
*/
private BigDecimal calcGroupPositiveRate(ThresholdConfig config, Date startTime, Date endTime) {
// 构建查询:关联 ExamineResult → Examine → Entrust
List<Examine> examines = queryExaminesByScope(config, startTime, endTime);
if (examines.isEmpty()) return null;
Set<Long> examineIds = examines.stream().map(Examine::getId).collect(Collectors.toSet());
if (examineIds.isEmpty()) return null;
List<ExamineResult> results = examineResultService.lambdaQuery()
.in(ExamineResult::getExamineId, examineIds)
.list();
if (results.isEmpty()) return null;
// Map examineId -> simpleCount
java.util.Map<Long, Integer> simpleCountMap = examines.stream()
.collect(Collectors.toMap(Examine::getId, Examine::getSimpleCount, (a, b) -> a + b));
long totalPositive = 0;
long totalSamples = 0;
for (ExamineResult result : results) {
// Count positive samples from comma-separated positive_num
String positiveNum = result.getPositiveNum();
if (positiveNum != null && !positiveNum.trim().isEmpty()) {
String trimmed = positiveNum.trim();
if (trimmed.endsWith(",")) {
trimmed = trimmed.substring(0, trimmed.length() - 1);
}
if (!trimmed.isEmpty()) {
totalPositive += trimmed.split(",").length;
}
}
// Get total samples for this examine
Integer simpleCount = simpleCountMap.get(result.getExamineId());
if (simpleCount != null) {
totalSamples += simpleCount;
}
}
if (totalSamples == 0) return null;
return BigDecimal.valueOf(totalPositive * 100.0 / totalSamples)
.setScale(2, BigDecimal.ROUND_HALF_UP);
}
/**
* 计算单场阳性数 (单个委托单的最大阳性数)
*/
private BigDecimal calcFarmPositiveCount(ThresholdConfig config, Date startTime, Date endTime) {
List<Examine> examines = queryExaminesByScope(config, startTime, endTime);
if (examines.isEmpty()) return null;
Set<Long> examineIds = examines.stream().map(Examine::getId).collect(Collectors.toSet());
List<ExamineResult> results = examineResultService.lambdaQuery()
.in(ExamineResult::getExamineId, examineIds)
.list();
if (results.isEmpty()) return null;
// Group by entrustId, sum positives per entrust
java.util.Map<Long, List<Examine>> examineByEntrust = examines.stream()
.collect(Collectors.groupingBy(Examine::getEntrustId));
long maxPositive = 0;
for (java.util.Map.Entry<Long, List<Examine>> entry : examineByEntrust.entrySet()) {
Set<Long> eIds = entry.getValue().stream().map(Examine::getId).collect(Collectors.toSet());
long entrustPositive = 0;
for (ExamineResult result : results) {
if (eIds.contains(result.getExamineId())) {
String positiveNum = result.getPositiveNum();
if (positiveNum != null && !positiveNum.trim().isEmpty()) {
String trimmed = positiveNum.trim();
if (trimmed.endsWith(",")) trimmed = trimmed.substring(0, trimmed.length() - 1);
if (!trimmed.isEmpty()) {
entrustPositive += trimmed.split(",").length;
}
}
}
}
if (entrustPositive > maxPositive) {
maxPositive = entrustPositive;
}
}
return BigDecimal.valueOf(maxPositive);
}
/**
* 计算区域新增病例数 (最近7天新增阳性样本数)
*/
private BigDecimal calcRegionNewCases(ThresholdConfig config, Date startTime, Date endTime) {
List<Examine> examines = queryExaminesByScope(config, startTime, endTime);
if (examines.isEmpty()) return BigDecimal.ZERO;
Set<Long> examineIds = examines.stream().map(Examine::getId).collect(Collectors.toSet());
List<ExamineResult> results = examineResultService.lambdaQuery()
.in(ExamineResult::getExamineId, examineIds)
.list();
if (results.isEmpty()) return BigDecimal.ZERO;
long total = 0;
for (ExamineResult result : results) {
String positiveNum = result.getPositiveNum();
if (positiveNum != null && !positiveNum.trim().isEmpty()) {
String trimmed = positiveNum.trim();
if (trimmed.endsWith(",")) trimmed = trimmed.substring(0, trimmed.length() - 1);
if (!trimmed.isEmpty()) {
total += trimmed.split(",").length;
}
}
}
return BigDecimal.valueOf(total);
}
/**
* 根据作用范围查询Examine记录
*/
private List<Examine> queryExaminesByScope(ThresholdConfig config, Date startTime, Date endTime) {
LambdaQueryWrapper<Examine> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Examine::getIsDeleted, 0);
wrapper.between(Examine::getCreateTime, startTime, endTime);
// 按作用范围过滤
if ("city".equals(config.getScopeType()) && config.getScopeValue() != null) {
// 按区域过滤 (通过Entrust的region_code)
List<Entrust> entrusts = service.lambdaQuery()
.eq(Entrust::getRegionCode, config.getScopeValue())
.eq(Entrust::getIsDeleted, 0)
.list();
if (!entrusts.isEmpty()) {
Set<Long> entrustIds = entrusts.stream().map(Entrust::getId).collect(Collectors.toSet());
wrapper.in(Examine::getEntrustId, entrustIds);
} else {
return Collections.emptyList();
}
} else if ("disease".equals(config.getScopeType()) && config.getDiseaseId() != null) {
wrapper.eq(Examine::getExamineItemId, config.getDiseaseId());
}
// "all" 不额外过滤
return examineService.list(wrapper);
}
/**
* 获取作用范围显示名称
*/
private String getScopeName(ThresholdConfig config) {
if ("all".equals(config.getScopeType()) || config.getScopeValue() == null) {
return "全省";
} else if ("city".equals(config.getScopeType())) {
return "地市(" + config.getScopeValue() + ")";
} else if ("disease".equals(config.getScopeType())) {
return "病种(" + config.getIndicatorName() + ")";
}
return "未知";
}
}

@ -0,0 +1,47 @@
package org.springblade.lims.api;
import lombok.Data;
import java.io.Serializable;
/**
* API 传输 DTO 导入样品解析结果行
*
* @author swj
*/
@Data
public class SimpleImportRow implements Serializable {
private static final long serialVersionUID = 1L;
// 样品编号(必填)
private String experieNum;
// 样品名称
private String simpleName;
// 样品数量
private Integer count;
// 采样日期
private String samplingDate;
// 送检单位
private String customerName;
// 原始编号
private String originalNum;
// 送检人
private String submittedBy;
// 验证状态: valid / warning / error
private String status;
// 验证消息
private String message;
// 原始行号
private Integer rowIndex;
}

@ -0,0 +1,155 @@
package org.springblade.lims.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import lombok.AllArgsConstructor;
import org.springblade.core.boot.ctrl.BladeController;
import org.springblade.core.mp.support.Condition;
import org.springblade.core.mp.support.Query;
import org.springblade.core.tool.api.R;
import org.springblade.core.tool.utils.Func;
import org.springblade.lims.Scheduled.GlobalScheduledTasks;
import org.springblade.lims.entry.AlertRecord;
import org.springblade.lims.service.IAlertRecordService;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
/**
* 预警记录 控制器
*
* @author swj
* @since 2026-05-26
*/
@RestController
@AllArgsConstructor
@RequestMapping("/alertRecord")
public class AlertRecordController extends BladeController {
private final IAlertRecordService service;
private final GlobalScheduledTasks scheduledTasks;
/**
* 分页列表
*/
@GetMapping("/list")
@ApiOperationSupport(order = 2)
@ApiOperation(value = "分页", notes = "分页,支持keyword模糊搜索indicator_name和scope_name")
public R<IPage<AlertRecord>> list(AlertRecord entry, Query query,
@RequestParam(required = false) String keyword) {
QueryWrapper<AlertRecord> wrapper = Condition.getQueryWrapper(entry);
if (Func.isNotBlank(keyword)) {
wrapper.and(w -> w.like("indicator_name", keyword).or().like("scope_name", keyword));
}
IPage<AlertRecord> page = service.page(Condition.getPage(query), wrapper);
return R.data(page);
}
/**
* 手动触发预警检测
*/
@PostMapping("/triggerAll")
@ApiOperationSupport(order = 9)
@ApiOperation(value = "手动触发预警检测", notes = "手动触发预警检测")
public R triggerAll() {
try {
scheduledTasks.manualCheck();
return R.success("预警检测已触发,请稍后刷新列表查看结果");
} catch (Exception e) {
return R.fail("预警检测触发失败: " + e.getMessage());
}
}
/**
* 详情
*/
@GetMapping("/detail")
@ApiOperationSupport(order = 3)
@ApiOperation(value = "详情", notes = "详情")
public R<AlertRecord> detail(@ApiParam(value = "主键", required = true) @RequestParam Long id) {
AlertRecord detail = service.getById(id);
return R.data(detail);
}
/**
* 新增
*/
@PostMapping("/insert")
@ApiOperationSupport(order = 4)
@ApiOperation(value = "新增", notes = "新增")
public R insert(@RequestBody AlertRecord entry) {
return R.data(service.save(entry));
}
/**
* 修改
*/
@PostMapping("/update")
@ApiOperationSupport(order = 5)
@ApiOperation(value = "修改", notes = "修改")
public R update(@RequestBody AlertRecord entry) {
return R.data(service.updateById(entry));
}
/**
* 删除
*/
@PostMapping("/deleteByIds")
@ApiOperationSupport(order = 6)
@ApiOperation(value = "逻辑删除", notes = "传入ids")
public R deleteByIds(@ApiParam(value = "主键集合", required = true) @RequestParam String ids) {
return R.status(service.deleteLogic(Func.toLongList(ids)));
}
/**
* 更新状态
*/
@PostMapping("/updateStatus")
@ApiOperationSupport(order = 7)
@ApiOperation(value = "更新状态", notes = "更新状态: untreated/confirmed/processed")
public R updateStatus(@RequestParam Long id, @RequestParam String status) {
AlertRecord record = service.getById(id);
if (record == null) {
return R.fail("记录不存在");
}
record.setAlertStatus(status);
return R.status(service.updateById(record));
}
/**
* 统计摘要
*/
@GetMapping("/summary")
@ApiOperationSupport(order = 8)
@ApiOperation(value = "统计摘要", notes = "统计摘要")
public R<Map<String, Object>> summary() {
Map<String, Object> result = new HashMap<>(3);
// 活跃预警: status='untreated' AND alert_level='danger'
QueryWrapper<AlertRecord> activeWrapper = new QueryWrapper<>();
activeWrapper.eq("status", "untreated");
activeWrapper.eq("alert_level", "danger");
int activeAlert = service.count(activeWrapper);
result.put("activeAlert", activeAlert);
// 本月触发: trigger_time >= 本月1日
QueryWrapper<AlertRecord> monthlyWrapper = new QueryWrapper<>();
java.time.LocalDate now = java.time.LocalDate.now();
java.time.LocalDate firstDay = now.withDayOfMonth(1);
monthlyWrapper.ge("trigger_time", java.sql.Date.valueOf(firstDay));
int monthlyTriggered = service.count(monthlyWrapper);
result.put("monthlyTriggered", monthlyTriggered);
// 已处理: status='processed'
QueryWrapper<AlertRecord> processedWrapper = new QueryWrapper<>();
processedWrapper.eq("status", "processed");
int processed = service.count(processedWrapper);
result.put("processed", processed);
return R.data(result);
}
}

@ -0,0 +1,329 @@
package org.springblade.lims.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.boot.ctrl.BladeController;
import org.springblade.core.excel.util.ExcelUtil;
import org.springblade.core.mp.support.Condition;
import org.springblade.core.mp.support.Query;
import org.springblade.core.tool.api.R;
import org.springblade.core.tool.utils.Func;
import org.springblade.lims.api.SimpleImportRow;
import org.springblade.lims.entry.AssignRule;
import org.springblade.lims.entry.Examine;
import org.springblade.lims.excel.SimpleImportExcel;
import org.springblade.lims.service.IAssignRuleService;
import org.springblade.lims.service.IExamineService;
import org.springblade.system.feign.ISysClient;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletResponse;
import java.text.DecimalFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.*;
/**
*
*
* @author swj
* @since 2022年6月1日19:49:10
*/
@RestController
@AllArgsConstructor
@Slf4j
@RequestMapping("/assignRule")
public class AssignRuleController extends BladeController {
private final IAssignRuleService service;
private final IExamineService examineService;
private final ISysClient sysClient;
/**
* 分页
*/
@GetMapping("/list")
@ApiOperationSupport(order = 2)
public R<IPage<AssignRule>> list(AssignRule entry, Query query) {
IPage<AssignRule> page = service.page(Condition.getPage(query), Condition.getQueryWrapper(entry));
return R.data(page);
}
/**
*
*/
@PostMapping("/insert")
@ApiOperation(value = "新增", notes = "新增分配规则")
public R insert(@RequestBody AssignRule entry) {
entry.setCreateTime(new Date());
entry.setUpdateTime(new Date());
return R.data(service.save(entry));
}
/**
*
*/
@PostMapping("/update")
@ApiOperation(value = "更改", notes = "更改分配规则")
public R update(@RequestBody AssignRule entry) {
return R.data(service.updateById(entry));
}
/**
* 删除
*/
@PostMapping("/deleteByIds")
@ApiOperation(value = "逻辑删除", notes = "传入ids")
public R deleteByIds(@ApiParam(value = "主键集合", required = true) @RequestParam String ids) {
return R.status(service.deleteLogic(Func.toLongList(ids)));
}
/**
*
*/
@GetMapping("/detail")
@ApiOperation(value = "详情", notes = "查询单条")
public R detail(@RequestParam Long id) {
return R.data(service.getById(id));
}
/**
* 执行率分析
*/
@GetMapping("/analysis")
@ApiOperation(value = "执行率分析", notes = "按科室统计任务执行率")
public R<Map<String, Object>> analysis(
@RequestParam(required = false) Long deptId,
@RequestParam(required = false) String examineBy,
@RequestParam(required = false) String startTime,
@RequestParam(required = false) String endTime
) throws ParseException {
Map<String, Object> result = new HashMap<>(8);
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
// 时间范围:有传参用传参,否则本月
Date rangeStart, rangeEnd, lastStart = null, lastEnd = null;
boolean useDefaultRange;
if (Func.isNotBlank(startTime) && Func.isNotBlank(endTime)) {
rangeStart = sdf.parse(startTime);
rangeEnd = sdf.parse(endTime);
useDefaultRange = false;
} else {
Calendar cal = Calendar.getInstance();
cal.set(Calendar.DAY_OF_MONTH, 1);
cal.set(Calendar.HOUR_OF_DAY, 0);
cal.set(Calendar.MINUTE, 0);
cal.set(Calendar.SECOND, 0);
cal.set(Calendar.MILLISECOND, 0);
rangeStart = cal.getTime();
cal.add(Calendar.MONTH, 1);
cal.add(Calendar.DAY_OF_MONTH, -1);
cal.set(Calendar.HOUR_OF_DAY, 23);
cal.set(Calendar.MINUTE, 59);
cal.set(Calendar.SECOND, 59);
rangeEnd = cal.getTime();
// 上月时间范围(仅默认本月时计算较上月)
lastStart = new Date(rangeStart.getTime());
lastStart.setMonth(lastStart.getMonth() - 1);
lastEnd = new Date(rangeEnd.getTime());
lastEnd.setMonth(lastEnd.getMonth() - 1);
useDefaultRange = true;
}
// ===== 总分配任务 =====
LambdaQueryWrapper<Examine> base = baseWrapper(deptId, examineBy, rangeStart, rangeEnd);
int totalAssigned = examineService.count(base);
// ===== 已完成 =====
base = baseWrapper(deptId, examineBy, rangeStart, rangeEnd);
base.eq(Examine::getIsFinished, "1");
int totalFinished = examineService.count(base);
// ===== 平均领取时长 =====
base = baseWrapper(deptId, examineBy, rangeStart, rangeEnd);
base.isNotNull(Examine::getReceiveTime);
double avgHours = Double.parseDouble(new DecimalFormat("#0.0").format(calcAvgReceiveHours(examineService.list(base))));
// ===== 上月平均领取时长 =====
double changeRate = 0;
if (useDefaultRange) {
LambdaQueryWrapper<Examine> lastBase = baseWrapper(deptId, examineBy, lastStart, lastEnd);
lastBase.isNotNull(Examine::getReceiveTime);
double lastAvg = Double.parseDouble(new DecimalFormat("#0.0").format(calcAvgReceiveHours(examineService.list(lastBase))));
if (lastAvg > 0) {
changeRate = Double.parseDouble(new DecimalFormat("#0.0").format((avgHours - lastAvg) / lastAvg * 100));
}
}
// ===== 各科室统计 =====
QueryWrapper<Examine> deptQuery = new QueryWrapper<>();
deptQuery.select("DISTINCT dept_id").eq("is_distribute", 1);
if (deptId != null) deptQuery.eq("dept_id", deptId);
if (Func.isNotBlank(examineBy)) deptQuery.in("examine_by", (Object[]) examineBy.split(","));
if (rangeStart != null) deptQuery.ge("create_time", rangeStart);
if (rangeEnd != null) deptQuery.le("create_time", rangeEnd);
List<Object> deptIds = examineService.getBaseMapper().selectObjs(deptQuery);
DecimalFormat df = new DecimalFormat("#0.0");
List<Map<String, Object>> deptStats = new ArrayList<>();
for (Object obj : deptIds) {
Long dId = (Long) obj;
if (dId == null) continue;
LambdaQueryWrapper<Examine> dw = baseWrapper(dId, examineBy, rangeStart, rangeEnd);
int deptTotal = examineService.count(dw);
dw = baseWrapper(dId, examineBy, rangeStart, rangeEnd);
dw.eq(Examine::getIsFinished, "1");
int deptFinished = examineService.count(dw);
dw = baseWrapper(dId, examineBy, rangeStart, rangeEnd);
dw.isNotNull(Examine::getReceiveTime);
double deptAvgHours = Double.parseDouble(df.format(calcAvgReceiveHours(examineService.list(dw))));
String deptName = "未知";
try {
deptName = sysClient.getDeptName(dId).getData();
} catch (Exception e) {
log.warn("获取科室名称失败: deptId={}", dId);
}
Map<String, Object> deptInfo = new HashMap<>(6);
deptInfo.put("deptId", dId);
deptInfo.put("deptName", deptName);
deptInfo.put("total", deptTotal);
deptInfo.put("finished", deptFinished);
deptInfo.put("rate", deptTotal > 0 ? Math.round((double) deptFinished / deptTotal * 100) : 0);
deptInfo.put("avgHours", deptAvgHours);
deptStats.add(deptInfo);
}
deptStats.sort(Comparator.comparing(m -> (Long) m.get("deptId")));
result.put("totalAssigned", totalAssigned);
result.put("totalFinished", totalFinished);
result.put("finishRate", totalAssigned > 0 ? Double.parseDouble(df.format((double) totalFinished / totalAssigned * 100)) : 0);
result.put("avgHours", avgHours);
result.put("avgHoursChange", changeRate);
result.put("deptStats", deptStats);
return R.data(result);
}
/**
* 构建基础查询条件
*/
private LambdaQueryWrapper<Examine> baseWrapper(Long deptId, String examineBy, Date start, Date end) {
LambdaQueryWrapper<Examine> w = new LambdaQueryWrapper<>();
w.eq(Examine::getIsDistribute, 1);
if (deptId != null) w.eq(Examine::getDeptId, deptId);
if (Func.isNotBlank(examineBy)) w.in(Examine::getExamineBy, (Object[]) examineBy.split(","));
if (start != null) w.ge(Examine::getCreateTime, start);
if (end != null) w.le(Examine::getCreateTime, end);
return w;
}
/**
* 计算平均领取时长小时
*/
private double calcAvgReceiveHours(List<Examine> list) {
if (list == null || list.isEmpty()) return 0;
long totalMillis = 0;
for (Examine e : list) {
if (e.getReceiveTime() != null && e.getCreateTime() != null) {
totalMillis += e.getReceiveTime().getTime() - e.getCreateTime().getTime();
}
}
if (totalMillis == 0) return 0;
return (double) totalMillis / list.size() / (1000 * 60 * 60);
}
/**
*
*/
@PostMapping("/trigger")
@ApiOperation(value = "手动触发分配", notes = "执行规则匹配并分配任务")
public R trigger() {
log.info("===== 手动触发分配开始 =====");
try {
Map<String, Object> result = service.triggerAssignment();
return R.data(result);
} catch (Exception e) {
log.error("手动触发分配异常", e);
return R.fail("触发失败: " + e.getMessage());
}
}
/**
* 导入Excel解析
*/
@PostMapping("/importExcel/parse")
@ApiOperation(value = "导入Excel解析", notes = "上传Excel文件,解析并验证样品数据")
public R<List<SimpleImportRow>> importParseExcel(@ApiParam(value = "Excel文件", required = true) MultipartFile file) {
try {
List<SimpleImportRow> rows = service.importParseExcel(file);
return R.data(rows);
} catch (Exception e) {
log.error("导入Excel解析失败", e);
return R.fail(e.getMessage());
}
}
/**
* 批量保存导入样品
*/
@PostMapping("/importExcel/save")
@ApiOperation(value = "批量保存导入样品", notes = "保存用户确认后的导入样品数据")
public R<Map<String, Object>> importSaveSimples(@RequestBody List<SimpleImportRow> rows) {
try {
Map<String, Object> result = service.importSaveSimples(rows);
return R.data(result);
} catch (Exception e) {
log.error("批量保存导入样品失败", e);
return R.fail(e.getMessage());
}
}
/**
* 下载导入模板
*/
@GetMapping("/importExcel/template")
@ApiOperation(value = "下载导入模板", notes = "下载样品导入Excel模板")
public void downloadImportTemplate(HttpServletResponse response) {
List<SimpleImportExcel> list = new ArrayList<>();
SimpleImportExcel example = new SimpleImportExcel();
example.setExperieNum("JC2026001");
example.setSimpleName("示例样品");
example.setCount(1);
example.setSamplingDate("2026-05-26");
example.setCustomerName("示例送检单位");
example.setOriginalNum("YS001");
example.setSubmittedBy("张三");
list.add(example);
ExcelUtil.export(response, "样品导入模板", "样品数据", list, SimpleImportExcel.class);
}
@lombok.Data
public static class ManualAssignDTO {
private Long entrustId;
private List<DeptAssignment> assignments;
}
@lombok.Data
public static class DeptAssignment {
private Long deptId;
private List<Long> examineIds;
}
}

@ -0,0 +1,70 @@
package org.springblade.lims.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import lombok.AllArgsConstructor;
import org.springblade.core.boot.ctrl.BladeController;
import org.springblade.core.mp.support.Condition;
import org.springblade.core.mp.support.Query;
import org.springblade.core.tool.api.R;
import org.springblade.core.tool.utils.Func;
import org.springblade.lims.entry.ThresholdConfig;
import org.springblade.lims.service.IThresholdConfigService;
import org.springframework.web.bind.annotation.*;
/**
* 阈值配置 控制器
*
* @author swj
* @since 2026-05-26
*/
@RestController
@AllArgsConstructor
@RequestMapping("/thresholdConfig")
public class ThresholdConfigController extends BladeController {
private final IThresholdConfigService service;
/**
* 分页列表
*/
@GetMapping("/list")
@ApiOperationSupport(order = 2)
@ApiOperation(value = "分页", notes = "分页")
public R<IPage<ThresholdConfig>> list(ThresholdConfig entry, Query query) {
IPage<ThresholdConfig> page = service.page(Condition.getPage(query), Condition.getQueryWrapper(entry));
return R.data(page);
}
/**
* 新增
*/
@PostMapping("/insert")
@ApiOperationSupport(order = 3)
@ApiOperation(value = "新增", notes = "新增")
public R insert(@RequestBody ThresholdConfig entry) {
return R.data(service.save(entry));
}
/**
* 修改
*/
@PostMapping("/update")
@ApiOperationSupport(order = 4)
@ApiOperation(value = "修改", notes = "修改")
public R update(@RequestBody ThresholdConfig entry) {
return R.data(service.updateById(entry));
}
/**
* 删除
*/
@PostMapping("/deleteByIds")
@ApiOperationSupport(order = 5)
@ApiOperation(value = "逻辑删除", notes = "传入ids")
public R deleteByIds(@ApiParam(value = "主键集合", required = true) @RequestParam String ids) {
return R.status(service.deleteLogic(Func.toLongList(ids)));
}
}

@ -0,0 +1,167 @@
package org.springblade.lims.drools;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.kie.api.KieServices;
import org.kie.api.builder.KieBuilder;
import org.kie.api.builder.KieFileSystem;
import org.kie.api.builder.Message;
import org.kie.api.runtime.KieContainer;
import org.kie.api.runtime.KieSession;
import org.springblade.lims.entry.AssignRule;
import org.springblade.lims.entry.Examine;
import org.springblade.lims.entry.ExamineItem;
import org.springblade.lims.service.IExamineItemService;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;
/**
* Drools规则引擎服务 从DB规则JSON动态生成DRL并执行匹配
*/
@Slf4j
@Service
public class AssignRuleDroolsService {
private static final String DRL_PACKAGE = "org.springblade.lims.drools";
private static final String DRL_TEMPLATE = "" +
"package " + DRL_PACKAGE + ";\n" +
"import org.springblade.lims.entry.Examine;\n" +
"import java.util.Set;\n" +
"global java.util.Set matchedIds;\n" +
"\n" +
"rule \"%s\"\n" +
" when\n" +
" $e: Examine(isDistribute == null || isDistribute == 0%s)\n" +
" then\n" +
" matchedIds.add($e.getId());\n" +
"end\n";
private final ObjectMapper objectMapper;
private final IExamineItemService examineItemService;
public AssignRuleDroolsService(ObjectMapper objectMapper, IExamineItemService examineItemService) {
this.objectMapper = objectMapper;
this.examineItemService = examineItemService;
}
/**
* 从规则条件JSON生成DRL文本
*
* @param rule 分配规则含conditions JSON
* @param ruleId 规则ID用于DRL中的规则名称
* @return DRL文本
*/
public String generateDrl(AssignRule rule, String ruleId) {
String conditions = rule.getConditions();
String constraints = "";
if (conditions != null && !conditions.trim().isEmpty()) {
try {
JsonNode root = objectMapper.readTree(conditions);
constraints = buildConstraints(root);
} catch (JsonProcessingException e) {
log.warn("规则[{}]条件JSON解析失败: {}, 将匹配所有未分配记录", ruleId, conditions);
}
}
return String.format(DRL_TEMPLATE, "rule-" + ruleId, constraints);
}
/**
* 从JSON节点构建DRL约束条件返回内容插入Examine()括号内逗号分隔
*/
private String buildConstraints(JsonNode root) {
List<String> parts = new ArrayList<>();
// examineItem: 检测项目ID匹配(JSON存名称,需查表转ID)
JsonNode examineItem = root.get("examineItem");
if (examineItem != null && examineItem.isArray() && examineItem.size() > 0) {
List<String> names = StreamSupport.stream(examineItem.spliterator(), false)
.map(JsonNode::asText)
.filter(n -> n != null && !n.isEmpty())
.collect(Collectors.toList());
if (!names.isEmpty()) {
List<ExamineItem> items = examineItemService.lambdaQuery()
.in(ExamineItem::getName, names)
.list();
if (!items.isEmpty()) {
String values = items.stream()
.map(item -> item.getId() + "L")
.collect(Collectors.joining(", "));
parts.add("examineItemId in (" + values + ")");
}
}
}
// sampleCountThreshold: 样品数量阈值
JsonNode threshold = root.get("sampleCountThreshold");
if (threshold != null && threshold.isNumber() && threshold.asInt() > 0) {
parts.add("simpleCount != null && simpleCount >= " + threshold.asInt());
}
if (parts.isEmpty()) return "";
return ", " + String.join(", ", parts);
}
/**
* 执行DRL规则返回匹配的Examine记录ID列表
*
* @param drl DRL文本
* @param candidates 候选Examine记录
* @return 匹配的Examine ID列表异常时返回空列表
*/
public List<Long> executeDrl(String drl, List<Examine> candidates) {
if (candidates == null || candidates.isEmpty()) {
return Collections.emptyList();
}
try {
KieServices ks = KieServices.Factory.get();
KieFileSystem kfs = ks.newKieFileSystem();
kfs.write("src/main/resources/" + DRL_PACKAGE.replace('.', '/') + "/rules.drl", drl);
KieBuilder kb = ks.newKieBuilder(kfs).buildAll();
// 检查编译错误
if (kb.getResults().hasMessages(Message.Level.ERROR)) {
StringBuilder errors = new StringBuilder("DRL编译失败:");
for (Message msg : kb.getResults().getMessages(Message.Level.ERROR)) {
errors.append("\n ").append(msg.getText());
}
log.error(errors.toString());
if (log.isDebugEnabled()) {
log.debug("失败的DRL内容:\n{}", drl);
}
return Collections.emptyList();
}
KieContainer kc = ks.newKieContainer(kb.getKieModule().getReleaseId());
KieSession kSession = kc.newKieSession();
try {
Set<Long> matchedIds = new HashSet<>();
kSession.setGlobal("matchedIds", matchedIds);
for (Examine candidate : candidates) {
kSession.insert(candidate);
}
kSession.fireAllRules();
return new ArrayList<>(matchedIds);
} finally {
kSession.dispose();
}
} catch (Exception e) {
log.error("Drools执行异常", e);
if (log.isDebugEnabled()) {
log.debug("失败的DRL内容:\n{}", drl);
}
return Collections.emptyList();
}
}
}

@ -0,0 +1,52 @@
package org.springblade.lims.excel;
import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.write.style.ColumnWidth;
import com.alibaba.excel.annotation.write.style.ContentRowHeight;
import com.alibaba.excel.annotation.write.style.HeadRowHeight;
import lombok.Data;
import java.io.Serializable;
/**
* EasyExcel 模型 数据导入模板
*
* @author swj
*/
@Data
@ColumnWidth(25)
@HeadRowHeight(20)
@ContentRowHeight(18)
public class SimpleImportExcel implements Serializable {
private static final long serialVersionUID = 1L;
@ColumnWidth(20)
@ExcelProperty("样品编号")
private String experieNum;
@ColumnWidth(20)
@ExcelProperty("样品名称")
private String simpleName;
@ColumnWidth(15)
@ExcelProperty("样品数量")
private Integer count;
@ColumnWidth(20)
@ExcelProperty("采样日期")
private String samplingDate;
@ColumnWidth(25)
@ExcelProperty("送检单位")
private String customerName;
@ColumnWidth(20)
@ExcelProperty("原始编号")
private String originalNum;
@ColumnWidth(15)
@ExcelProperty("送检人")
private String submittedBy;
}

@ -0,0 +1,14 @@
package org.springblade.lims.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.springblade.lims.entry.AlertRecord;
/**
* 预警记录 Mapper
*
* @author swj
* @since 2026-05-26
*/
public interface AlertRecordMapper extends BaseMapper<AlertRecord> {
}

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.springblade.lims.mapper.AlertRecordMapper">
<!-- 通用查询映射结果 -->
<resultMap id="baseResultMap" type="org.springblade.lims.entry.AlertRecord">
<id column="id" property="id"/>
</resultMap>
</mapper>

@ -0,0 +1,7 @@
package org.springblade.lims.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.springblade.lims.entry.AssignRuleLog;
public interface AssignRuleLogMapper extends BaseMapper<AssignRuleLog> {
}

@ -0,0 +1,14 @@
package org.springblade.lims.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.springblade.lims.entry.AssignRule;
/**
*
* @author swj
* @since 2026-05-22
*/
public interface AssignRuleMapper extends BaseMapper<AssignRule> {
}

@ -0,0 +1,14 @@
package org.springblade.lims.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.springblade.lims.entry.ThresholdConfig;
/**
* 阈值配置 Mapper
*
* @author swj
* @since 2026-05-26
*/
public interface ThresholdConfigMapper extends BaseMapper<ThresholdConfig> {
}

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.springblade.lims.mapper.ThresholdConfigMapper">
<!-- 通用查询映射结果 -->
<resultMap id="baseResultMap" type="org.springblade.lims.entry.ThresholdConfig">
<id column="id" property="id"/>
</resultMap>
</mapper>

@ -0,0 +1,24 @@
package org.springblade.lims.service;
import com.baomidou.mybatisplus.extension.service.IService;
import org.springblade.lims.entry.AlertRecord;
import java.util.List;
/**
* 预警记录 服务类
*
* @author swj
* @since 2026-05-26
*/
public interface IAlertRecordService extends IService<AlertRecord> {
/**
* 逻辑删除
*
* @param ids 主键集合
* @return boolean
*/
boolean deleteLogic(List<Long> ids);
}

@ -0,0 +1,9 @@
package org.springblade.lims.service;
import org.springblade.core.mp.base.BaseService;
import org.springblade.lims.entry.AssignRuleLog;
public interface IAssignRuleLogService extends BaseService<AssignRuleLog> {
void saveLog(AssignRuleLog log);
}

@ -0,0 +1,40 @@
package org.springblade.lims.service;
import org.springblade.core.mp.base.BaseService;
import org.springblade.lims.api.SimpleImportRow;
import org.springblade.lims.entry.AssignRule;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import java.util.Map;
/**
*
* @author swj
* @since 2026-05-22
*/
public interface IAssignRuleService extends BaseService<AssignRule> {
List<AssignRule> listEnabledRules();
/**
* 触发规则分配 执行Drools匹配并分配任务到目标科室
* @return 匹配结果totalMatched, totalAssigned, ruleCount
*/
Map<String, Object> triggerAssignment();
/**
* 解析导入Excel 读取文件逐行验证返回解析结果
* @param file Excel文件
* @return 解析后的行列表含验证状态
*/
List<SimpleImportRow> importParseExcel(MultipartFile file);
/**
* 批量保存导入样品 创建Entrust + 批量插入Simple记录
* @param rows 用户确认后的导入行
* @return 结果MaptotalRows, savedCount, skippedCount, entrustId, entrustNum
*/
Map<String, Object> importSaveSimples(List<SimpleImportRow> rows);
}

@ -0,0 +1,14 @@
package org.springblade.lims.service;
import org.springblade.core.mp.base.BaseService;
import org.springblade.lims.entry.ThresholdConfig;
/**
* 阈值配置 服务类
*
* @author swj
* @since 2026-05-26
*/
public interface IThresholdConfigService extends BaseService<ThresholdConfig> {
}

@ -0,0 +1,24 @@
package org.springblade.lims.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springblade.lims.entry.AlertRecord;
import org.springblade.lims.mapper.AlertRecordMapper;
import org.springblade.lims.service.IAlertRecordService;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 预警记录 服务实现类
*
* @author swj
* @since 2026-05-26
*/
@Service
public class AlertRecordServiceImpl extends ServiceImpl<AlertRecordMapper, AlertRecord> implements IAlertRecordService {
@Override
public boolean deleteLogic(List<Long> ids) {
return baseMapper.deleteBatchIds(ids) > 0;
}
}

@ -0,0 +1,16 @@
package org.springblade.lims.service.impl;
import org.springblade.core.mp.base.BaseServiceImpl;
import org.springblade.lims.entry.AssignRuleLog;
import org.springblade.lims.mapper.AssignRuleLogMapper;
import org.springblade.lims.service.IAssignRuleLogService;
import org.springframework.stereotype.Service;
@Service
public class AssignRuleLogServiceImpl extends BaseServiceImpl<AssignRuleLogMapper, AssignRuleLog> implements IAssignRuleLogService {
@Override
public void saveLog(AssignRuleLog log) {
save(log);
}
}

@ -0,0 +1,473 @@
package org.springblade.lims.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.excel.util.ExcelUtil;
import org.springblade.core.mp.base.BaseServiceImpl;
import org.springblade.core.tool.api.R;
import org.springblade.lims.api.SimpleImportRow;
import org.springblade.lims.drools.AssignRuleDroolsService;
import org.springblade.lims.entry.AssignRule;
import org.springblade.lims.entry.AssignRuleLog;
import org.springblade.lims.entry.ETask;
import org.springblade.lims.entry.Entrust;
import org.springblade.lims.entry.Examine;
import org.springblade.lims.entry.Simple;
import org.springblade.lims.entry.TaskBlueprint;
import org.springblade.lims.excel.SimpleImportExcel;
import org.springblade.lims.mapper.AssignRuleMapper;
import org.springblade.lims.service.IAssignRuleLogService;
import org.springblade.lims.service.IAssignRuleService;
import org.springblade.lims.service.IEntrtrustService;
import org.springblade.lims.service.IExamineService;
import org.springblade.lims.service.IETaskService;
import org.springblade.lims.service.ISimpleService;
import org.springblade.lims.service.ITaskBlueprintService;
import org.springblade.system.entity.Dept;
import org.springblade.system.feign.ISysClient;
import org.apache.commons.lang3.RandomUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;
/**
*
* @author swj
* @since 2026-05-22
*/
@Slf4j
@Service
@AllArgsConstructor
public class AssignRuleServiceImpl extends BaseServiceImpl<AssignRuleMapper, AssignRule> implements IAssignRuleService {
private final AssignRuleDroolsService droolsService;
private final IExamineService examineService;
private final IAssignRuleLogService assignRuleLogService;
private final ObjectMapper objectMapper;
private final ISimpleService simpleService;
private final IETaskService etaskService;
private final ITaskBlueprintService taskBlueprintService;
private final ISysClient sysClient;
private final IEntrtrustService entrtrustService;
@Override
public List<AssignRule> listEnabledRules() {
return lambdaQuery().eq(AssignRule::getEnabled, 1).orderByAsc(AssignRule::getSort).list();
}
@Override
public Map<String, Object> triggerAssignment() {
log.info("===== 开始执行规则分配 =====");
// 1. 加载启用的规则(按sort排序)
List<AssignRule> rules = listEnabledRules();
if (rules.isEmpty()) {
log.info("没有启用的分配规则");
Map<String, Object> result = new HashMap<>();
result.put("totalMatched", 0);
result.put("totalAssigned", 0);
result.put("ruleCount", 0);
return result;
}
log.info("找到 {} 条启用规则", rules.size());
// 2. 超时未领重置:将超时未领取的Examine记录重置为未分配,使其可被重新分配
int totalTimeoutReset = 0;
Set<Long> timeoutResetEntrustIds = new HashSet<>();
for (AssignRule rule : rules) {
if (rule.getUnclaimTimeoutHours() != null && rule.getUnclaimTimeoutHours() > 0) {
Calendar cal = Calendar.getInstance();
cal.add(Calendar.HOUR_OF_DAY, -rule.getUnclaimTimeoutHours());
Date threshold = cal.getTime();
List<Examine> timedOut = examineService.list(
new LambdaQueryWrapper<Examine>()
.eq(Examine::getDeptId, rule.getTargetDeptId())
.eq(Examine::getIsDistribute, 1)
.isNull(Examine::getReceiveTime)
.lt(Examine::getUpdateTime, threshold)
);
if (!timedOut.isEmpty()) {
log.info("规则[{}]超时未领{}条, 重置为未分配(超时{}h)", rule.getName(), timedOut.size(), rule.getUnclaimTimeoutHours());
for (Examine e : timedOut) {
if (e.getEntrustId() != null) {
timeoutResetEntrustIds.add(e.getEntrustId());
}
e.setIsDistribute(0);
e.setDeptId(null);
e.setLockBy(null);
e.setLockTime(null);
}
examineService.updateBatchById(timedOut);
totalTimeoutReset += timedOut.size();
}
}
}
if (totalTimeoutReset > 0) {
log.info("超时未领重置完成, 共重置{}条", totalTimeoutReset);
}
int totalMatched = 0;
int totalAssigned = 0;
Set<Long> assignedEntrustIds = new HashSet<>();
// 3. 逐条执行规则(按sort顺序,第一条匹配即分配)
for (AssignRule rule : rules) {
try {
// a) 检查科室负载阈值
int currentLoad = examineService.count(
new LambdaQueryWrapper<Examine>()
.eq(Examine::getDeptId, rule.getTargetDeptId())
.eq(Examine::getIsDistribute, 1)
.ne(Examine::getIsFinished, "1")
);
if (rule.getDeptLoadThreshold() != null && currentLoad >= rule.getDeptLoadThreshold()) {
log.info("规则[{}]跳过: 目标科室负载{}已达阈值{}",
rule.getName(), currentLoad, rule.getDeptLoadThreshold());
// 记录跳过日志
AssignRuleLog skipLog = new AssignRuleLog();
skipLog.setRuleId(rule.getId());
skipLog.setRuleName(rule.getName());
skipLog.setStatus(0);
skipLog.setMatchDesc("科室负载" + currentLoad + "已达阈值" + rule.getDeptLoadThreshold());
assignRuleLogService.saveLog(skipLog);
continue;
}
// b) 查询未分配的Examine记录(isDistribute IS NULL 或 0,且 deptId 为空)
List<Examine> candidates = examineService.list(
new LambdaQueryWrapper<Examine>()
.eq(Examine::getIsDeleted, 0)
.and(w -> w.eq(Examine::getIsDistribute, 0)
.or().isNull(Examine::getIsDistribute))
.isNull(Examine::getDeptId)
);
if (candidates.isEmpty()) {
log.info("规则[{}]无待分配记录", rule.getName());
continue;
}
// b2) 检测类型过滤(条件JSON中的investigativeType对应Entrust.investigativeType,Java层预过滤)
try {
String condStr = rule.getConditions();
if (condStr != null && !condStr.trim().isEmpty()) {
JsonNode root = objectMapper.readTree(condStr);
JsonNode invTypeNode = root.get("investigativeType");
if (invTypeNode != null && invTypeNode.isArray() && invTypeNode.size() > 0) {
Set<String> types = StreamSupport.stream(invTypeNode.spliterator(), false)
.map(JsonNode::asText)
.collect(Collectors.toSet());
List<Long> entrustIds = candidates.stream()
.map(Examine::getEntrustId)
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
if (!entrustIds.isEmpty()) {
List<Entrust> entrusts = entrtrustService.listByIds(entrustIds);
Map<Long, String> entrustTypeMap = entrusts.stream()
.collect(Collectors.toMap(Entrust::getId, Entrust::getInvestigativeType, (a, b) -> a));
int before = candidates.size();
candidates = candidates.stream()
.filter(e -> e.getEntrustId() != null
&& types.contains(entrustTypeMap.get(e.getEntrustId())))
.collect(Collectors.toList());
log.info("规则[{}]检测类型过滤: {}条→{}条", rule.getName(), before, candidates.size());
if (candidates.isEmpty()) {
log.info("规则[{}]无匹配检测类型的待分配记录", rule.getName());
continue;
}
} else {
log.info("规则[{}]候选记录无委托单ID, 跳过", rule.getName());
continue;
}
}
}
} catch (JsonProcessingException e) {
log.warn("规则[{}]条件JSON解析失败: {}", rule.getName(), rule.getConditions());
}
// c) 生成DRL并执行Drools匹配
String drl = droolsService.generateDrl(rule, String.valueOf(rule.getId()));
if (log.isDebugEnabled()) {
log.debug("规则[{}]生成DRL:\n{}", rule.getName(), drl);
}
List<Long> matchedIds = droolsService.executeDrl(drl, candidates);
if (matchedIds.isEmpty()) {
log.info("规则[{}]无匹配记录", rule.getName());
continue;
}
totalMatched += matchedIds.size();
// d) 更新匹配的Examine记录
List<Examine> toUpdate = candidates.stream()
.filter(e -> matchedIds.contains(e.getId()))
.collect(Collectors.toList());
for (Examine examine : toUpdate) {
examine.setDeptId(rule.getTargetDeptId());
examine.setIsDistribute(0);
examine.setSimpleCurrPlace("接样室");
examine.setIsFinished("-1");
if (examine.getEntrustId() != null) {
assignedEntrustIds.add(examine.getEntrustId());
}
// 写分配日志
AssignRuleLog logEntry = new AssignRuleLog();
logEntry.setRuleId(rule.getId());
logEntry.setRuleName(rule.getName());
logEntry.setExamineId(examine.getId());
logEntry.setExperieNum(examine.getExperieNum());
logEntry.setSourceDeptId(examine.getDeptId());
logEntry.setTargetDeptId(rule.getTargetDeptId());
logEntry.setTargetDeptName(rule.getTargetDeptName());
logEntry.setStatus(1);
logEntry.setMatchDesc("Drools匹配成功");
assignRuleLogService.saveLog(logEntry);
}
// e) 创建/查找TaskBlueprint和ETask基础设施
Map<Long, List<Examine>> byEntrust = toUpdate.stream()
.filter(e -> e.getEntrustId() != null)
.collect(Collectors.groupingBy(Examine::getEntrustId));
for (Map.Entry<Long, List<Examine>> entry : byEntrust.entrySet()) {
Long entrustId = entry.getKey();
List<Examine> entrustExamines = entry.getValue();
// e1) 查找或创建TaskBlueprint
TaskBlueprint blueprint = taskBlueprintService.lambdaQuery()
.eq(TaskBlueprint::getEntrustId, entrustId)
.one();
if (blueprint == null) {
blueprint = new TaskBlueprint();
blueprint.setId(RandomUtils.nextLong());
blueprint.setEntrustId(entrustId);
blueprint.setIsDeleted(0);
blueprint.setStatus(0);
blueprint.setCreateTime(new Date());
blueprint.setUpdateTime(new Date());
taskBlueprintService.save(blueprint);
}
// e2) 查找或创建ETask (按科室)
ETask eTask = etaskService.lambdaQuery()
.eq(ETask::getTaskBlueprintId, blueprint.getId())
.eq(ETask::getDeptId, rule.getTargetDeptId())
.one();
if (eTask == null) {
eTask = new ETask();
eTask.setId(RandomUtils.nextLong());
eTask.setTaskBlueprintId(blueprint.getId());
eTask.setDeptId(rule.getTargetDeptId());
eTask.setStatus(0);
eTask.setIsDeleted(0);
eTask.setCreateTime(new Date());
eTask.setUpdateTime(new Date());
try {
R<Dept> deptR = sysClient.getDept(rule.getTargetDeptId());
if (deptR != null && deptR.getData() != null) {
eTask.setWeight(deptR.getData().getSort());
}
} catch (Exception ex) {
log.warn("获取部门{}排序失败: {}", rule.getTargetDeptId(), ex.getMessage());
}
etaskService.save(eTask);
}
// e3) 关联Examine到ETask
for (Examine examine : entrustExamines) {
examine.setETaskId(eTask.getId());
}
}
examineService.updateBatchById(toUpdate);
totalAssigned += toUpdate.size();
log.info("规则[{}]匹配{}条, 分配{}条", rule.getName(), matchedIds.size(), toUpdate.size());
} catch (Exception e) {
log.error("规则[{}]执行异常: {}", rule.getName(), e.getMessage(), e);
// 单条规则异常不影响后续规则
}
}
// 4. 更新受影响委托单状态:全部已分配→检测中(000),仍有未分配→待计划(2)
Set<Long> allAffectedEntrustIds = new HashSet<>();
allAffectedEntrustIds.addAll(timeoutResetEntrustIds);
allAffectedEntrustIds.addAll(assignedEntrustIds);
if (!allAffectedEntrustIds.isEmpty()) {
for (Long entrustId : allAffectedEntrustIds) {
long unassignedCount = examineService.count(
new LambdaQueryWrapper<Examine>()
.eq(Examine::getEntrustId, entrustId)
.ne(Examine::getStatus, -1)
.and(w -> w.eq(Examine::getIsDistribute, 0)
.or().isNull(Examine::getIsDistribute))
);
Entrust entrust = new Entrust();
entrust.setId(entrustId);
if (unassignedCount > 0) {
entrust.setEntrustStatus("2");
log.info("委托单[{}]仍有{}条未分配, 状态保持待计划", entrustId, unassignedCount);
} else {
entrust.setEntrustStatus("000");
log.info("委托单[{}]全部已分配, 状态推进至检测中(000)", entrustId);
}
entrtrustService.updateById(entrust);
}
}
Map<String, Object> result = new HashMap<>();
result.put("totalMatched", totalMatched);
result.put("totalAssigned", totalAssigned);
result.put("ruleCount", rules.size());
log.info("===== 规则分配完成: 匹配{}条, 分配{}条, 规则{}条 =====",
totalMatched, totalAssigned, rules.size());
return result;
}
@Override
public List<SimpleImportRow> importParseExcel(MultipartFile file) {
List<SimpleImportRow> resultRows = new ArrayList<>();
try {
List<SimpleImportExcel> excelList = ExcelUtil.read(file, SimpleImportExcel.class);
if (excelList == null || excelList.isEmpty()) {
return resultRows;
}
for (int i = 0; i < excelList.size(); i++) {
SimpleImportExcel row = excelList.get(i);
SimpleImportRow dto = new SimpleImportRow();
dto.setRowIndex(i);
dto.setExperieNum(row.getExperieNum());
dto.setSimpleName(row.getSimpleName());
dto.setCount(row.getCount());
dto.setSamplingDate(row.getSamplingDate());
dto.setCustomerName(row.getCustomerName());
dto.setOriginalNum(row.getOriginalNum());
dto.setSubmittedBy(row.getSubmittedBy());
// 验证
String status = "valid";
String message = "";
if (row.getExperieNum() == null || row.getExperieNum().trim().isEmpty()) {
status = "error";
message = "样品编号不能为空";
} else {
StringBuilder warnMsg = new StringBuilder();
if (row.getSimpleName() == null || row.getSimpleName().trim().isEmpty()) {
warnMsg.append("样品名称未填写; ");
}
if (row.getCustomerName() == null || row.getCustomerName().trim().isEmpty()) {
warnMsg.append("送检单位未填写; ");
}
if (row.getSubmittedBy() == null || row.getSubmittedBy().trim().isEmpty()) {
warnMsg.append("送检人未填写; ");
}
if (row.getSamplingDate() == null || row.getSamplingDate().trim().isEmpty()) {
warnMsg.append("采样日期未填写; ");
}
if (warnMsg.length() > 0) {
status = "warning";
message = warnMsg.substring(0, warnMsg.length() - 2);
}
}
dto.setStatus(status);
dto.setMessage(message);
resultRows.add(dto);
}
} catch (Exception e) {
log.error("Excel解析失败", e);
throw new RuntimeException("Excel文件格式错误,请下载模板后重试", e);
}
return resultRows;
}
@Override
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> importSaveSimples(List<SimpleImportRow> rows) {
Map<String, Object> result = new HashMap<>(6);
if (rows == null || rows.isEmpty()) {
result.put("totalRows", 0);
result.put("savedCount", 0);
result.put("skippedCount", 0);
result.put("entrustId", null);
result.put("entrustNum", null);
return result;
}
int totalRows = rows.size();
// 过滤出非error的行
List<SimpleImportRow> validRows = new ArrayList<>();
int skippedCount = 0;
for (SimpleImportRow row : rows) {
if ("error".equals(row.getStatus())) {
skippedCount++;
} else {
validRows.add(row);
}
}
if (validRows.isEmpty()) {
result.put("totalRows", totalRows);
result.put("savedCount", 0);
result.put("skippedCount", skippedCount);
result.put("entrustId", null);
result.put("entrustNum", null);
return result;
}
// 创建Entrust
Entrust entrust = new Entrust();
entrust.setEntrustType(99);
entrust.setAcceptanceNum("IMPORT-" + System.currentTimeMillis());
entrust.setEntrustCustomerName(validRows.get(0).getCustomerName());
entrust.setSubmittedBy(validRows.get(0).getSubmittedBy());
entrust.setSimpleCount(validRows.size());
entrust.setEntrustStatus("000");
entrust.setCreateTime(new Date());
entrust.setUpdateTime(new Date());
entrtrustService.save(entrust);
Long entrustId = entrust.getId();
// 创建Simple记录
List<Simple> simpleList = new ArrayList<>();
for (SimpleImportRow row : validRows) {
Simple simple = new Simple();
simple.setEntrustId(entrustId);
simple.setExperieNum(row.getExperieNum());
simple.setSimpleName(row.getSimpleName());
simple.setOriginalNum(row.getOriginalNum());
if (row.getSamplingDate() != null && !row.getSamplingDate().trim().isEmpty()) {
try {
simple.setSamplingDate(new java.text.SimpleDateFormat("yyyy-MM-dd").parse(row.getSamplingDate()));
} catch (Exception ignored) {
// 日期解析失败则跳过
}
}
simple.setIsDistribution(0);
simpleList.add(simple);
}
simpleService.saveBatch(simpleList);
result.put("totalRows", totalRows);
result.put("savedCount", validRows.size());
result.put("skippedCount", skippedCount);
result.put("entrustId", entrustId);
result.put("entrustNum", entrust.getAcceptanceNum());
return result;
}
}

@ -335,9 +335,21 @@ public class TaskBlueprintServiceImpl extends BaseServiceImpl<TaskBlueprintMappe
}
simpleService.updateBatchById(simples);
// 判断是否所有检验项目都已分配到科室
LambdaQueryWrapper<Examine> unassignedCheck = new LambdaQueryWrapper<>();
unassignedCheck.eq(Examine::getEntrustId, taskBlueprint.getEntrustId());
unassignedCheck.isNull(Examine::getDeptId);
unassignedCheck.ne(Examine::getStatus, -1);
long unassignedCount = examineService.count(unassignedCheck);
Entrust entrust = new Entrust();
entrust.setId(taskBlueprint.getEntrustId());
entrust.setEntrustStatus("111");
if (unassignedCount > 0) {
entrust.setEntrustStatus("2"); // 待计划(部分检验未分配科室)
} else {
entrust.setEntrustStatus("000"); // 检测中(全部已分配,待领取)
}
return entrtrustService.updateById(entrust);
}

@ -0,0 +1,20 @@
package org.springblade.lims.service.impl;
import lombok.AllArgsConstructor;
import org.springblade.core.mp.base.BaseServiceImpl;
import org.springblade.lims.entry.ThresholdConfig;
import org.springblade.lims.mapper.ThresholdConfigMapper;
import org.springblade.lims.service.IThresholdConfigService;
import org.springframework.stereotype.Service;
/**
* 阈值配置 服务实现类
*
* @author swj
* @since 2026-05-26
*/
@Service
@AllArgsConstructor
public class ThresholdConfigServiceImpl extends BaseServiceImpl<ThresholdConfigMapper, ThresholdConfig> implements IThresholdConfigService {
}

@ -0,0 +1,122 @@
package org.springblade.lims;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springblade.lims.controller.AssignRuleController;
import org.springblade.lims.entry.AssignRule;
import org.springblade.lims.service.IAssignRuleService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(AssignRuleController.class)
class AssignRuleControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private IAssignRuleService service;
@Test
void testInsert() throws Exception {
AssignRule rule = new AssignRule();
rule.setName("测试规则");
rule.setTargetDeptId(1L);
rule.setEnabled(1);
when(service.save(any())).thenReturn(true);
mockMvc.perform(post("/assignRule/insert")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(rule)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
}
@Test
void testList() throws Exception {
when(service.page(any(), any())).thenReturn(new Page<>());
mockMvc.perform(get("/assignRule/list")
.param("current", "1")
.param("size", "10"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.records").isArray());
}
@Test
void testUpdate() throws Exception {
AssignRule rule = new AssignRule();
rule.setId(1L);
rule.setName("更新规则");
when(service.updateById(any())).thenReturn(true);
mockMvc.perform(post("/assignRule/update")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(rule)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
}
@Test
void testDelete() throws Exception {
when(service.deleteLogic(any())).thenReturn(true);
mockMvc.perform(post("/assignRule/deleteByIds")
.param("ids", "1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
}
@Test
void testDetail() throws Exception {
AssignRule rule = new AssignRule();
rule.setId(1L);
rule.setName("测试规则");
when(service.getById(1L)).thenReturn(rule);
mockMvc.perform(get("/assignRule/detail")
.param("id", "1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.id").value(1));
}
@Test
void testTrigger() throws Exception {
Map<String, Object> mockResult = new HashMap<>();
mockResult.put("totalMatched", 5);
mockResult.put("totalAssigned", 3);
mockResult.put("ruleCount", 2);
when(service.triggerAssignment()).thenReturn(mockResult);
mockMvc.perform(post("/assignRule/trigger"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.totalMatched").value(5))
.andExpect(jsonPath("$.data.totalAssigned").value(3))
.andExpect(jsonPath("$.data.ruleCount").value(2));
}
}

@ -0,0 +1,58 @@
package org.springblade.lims;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 规则触发集成测试 验证端到端触发流程
* <p>
* 场景:
* 1. 无规则场景: DB中没有启用规则返回 totalMatched=0, totalAssigned=0, ruleCount=0
* 2. 基础触发: 调用 /assignRule/trigger 返回标准响应结构
* 3. 重复触发的幂等性: 连续调用两次返回结构一致
* <p>
* 注意: 需要完整Spring上下文 + 数据库环境才能真实执行分配逻辑
* 在无数据库的测试环境中默认走"无规则"或空查询路径
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class AssignRuleTriggerIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
void testTrigger_basicInvocation_shouldReturnResultStructure() {
ResponseEntity<String> response = restTemplate.postForEntity("/assignRule/trigger", null, String.class);
assertThat(response.getStatusCodeValue()).isEqualTo(200);
assertThat(response.getBody()).contains("totalMatched");
assertThat(response.getBody()).contains("totalAssigned");
assertThat(response.getBody()).contains("ruleCount");
}
@Test
void testTrigger_noEnabledRules_shouldReturnZeroCounts() {
// 无数据库环境或数据库中无启用规则 → 返回0
ResponseEntity<String> response = restTemplate.postForEntity("/assignRule/trigger", null, String.class);
assertThat(response.getStatusCodeValue()).isEqualTo(200);
assertThat(response.getBody()).contains("totalMatched");
assertThat(response.getBody()).contains("totalAssigned");
}
@Test
void testTrigger_idempotent_shouldNotThrowOnRepeatedCall() {
// 幂等性: 连续触发不抛异常
ResponseEntity<String> first = restTemplate.postForEntity("/assignRule/trigger", null, String.class);
ResponseEntity<String> second = restTemplate.postForEntity("/assignRule/trigger", null, String.class);
assertThat(first.getStatusCodeValue()).isEqualTo(200);
assertThat(second.getStatusCodeValue()).isEqualTo(200);
}
}

@ -0,0 +1,14 @@
package org.springblade.lims;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
@AutoConfigureMockMvc
public class BaseLimsTest {
@Test
public void contextLoads() {
assert true;
}
}

@ -0,0 +1,193 @@
package org.springblade.lims.drools;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springblade.lims.entry.AssignRule;
import org.springblade.lims.entry.Examine;
import org.springblade.lims.service.IExamineItemService;
import org.springframework.boot.test.mock.mockito.MockBean;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* AssignRuleDroolsService 单元测试
* <p>
* 覆盖6个场景:
* 1. generateDrl null条件
* 2. generateDrl 空JSON
* 3. generateDrl sampleSource条件
* 4. generateDrl 全部条件
* 5. executeDrl 匹配命中
* 6. executeDrl 无匹配
*/
class AssignRuleDroolsServiceTest {
private AssignRuleDroolsService service;
@MockBean
private IExamineItemService examineItemService;
@BeforeEach
void setUp() {
service = new AssignRuleDroolsService(new ObjectMapper(), examineItemService);
}
// ── DRL生成测试 ──
@Test
void generateDrl_nullConditions_shouldProduceBasicDrl() {
AssignRule rule = new AssignRule();
rule.setConditions(null);
String drl = service.generateDrl(rule, "1");
assertThat(drl).contains("rule \"rule-1\"");
assertThat(drl).contains("$e: Examine(isDistribute == null || isDistribute == 0)");
assertThat(drl).contains("matchedIds.add($e.getId())");
// 不应包含额外约束
assertThat(drl).doesNotContain("simpleSource in");
assertThat(drl).doesNotContain("examineItemId in");
assertThat(drl).doesNotContain("simpleCount");
}
@Test
void generateDrl_emptyConditions_shouldProduceBasicDrl() {
AssignRule rule = new AssignRule();
rule.setConditions("{}");
String drl = service.generateDrl(rule, "2");
assertThat(drl).contains("rule \"rule-2\"");
assertThat(drl).contains("$e: Examine(isDistribute == null || isDistribute == 0)");
assertThat(drl).doesNotContain("sampleSource in");
}
@Test
void generateDrl_sampleSourceCondition_shouldIncludeSimpleSourceConstraint() {
AssignRule rule = new AssignRule();
rule.setConditions("{\"sampleSource\":[\"血清\",\"全血\"]}");
String drl = service.generateDrl(rule, "3");
// 约束现在在Examine()括号内,使用字段名(无$e.前缀)
assertThat(drl).contains("Examine(isDistribute == null || isDistribute == 0, simpleSource in (\"血清\", \"全血\"))");
assertThat(drl).doesNotContain("examineItemId in");
assertThat(drl).doesNotContain("simpleCount");
}
@Test
void generateDrl_allConditions_shouldIncludeAllConstraints() {
AssignRule rule = new AssignRule();
rule.setConditions("{\"sampleSource\":[\"血清\",\"尿样\"],\"examineItem\":[1,2,3],\"sampleCountThreshold\":5}");
String drl = service.generateDrl(rule, "4");
assertThat(drl).contains("Examine(isDistribute == null || isDistribute == 0, simpleSource in (\"血清\", \"尿样\"), examineItemId in (1L, 2L, 3L), simpleCount != null && simpleCount >= 5)");
}
// ── DRL执行测试 ──
@Test
void executeDrl_withMatchingRecords_shouldReturnMatchedIds() {
// 规则: 匹配所有未分配记录
String drl = "" +
"package org.springblade.lims.drools;\n" +
"import org.springblade.lims.entry.Examine;\n" +
"import java.util.Set;\n" +
"global java.util.Set matchedIds;\n" +
"\n" +
"rule \"all-unassigned\"\n" +
" when\n" +
" $e: Examine(isDistribute == null || isDistribute == 0)\n" +
" then\n" +
" matchedIds.add($e.getId());\n" +
"end\n";
Examine matched1 = new Examine();
matched1.setId(100L);
matched1.setIsDistribute(0);
Examine matched2 = new Examine();
matched2.setId(200L);
matched2.setIsDistribute(null);
Examine unmatched = new Examine();
unmatched.setId(300L);
unmatched.setIsDistribute(1);
List<Examine> candidates = Arrays.asList(matched1, matched2, unmatched);
List<Long> result = service.executeDrl(drl, candidates);
assertThat(result).containsExactlyInAnyOrder(100L, 200L);
assertThat(result).doesNotContain(300L);
}
@Test
void executeDrl_withNoMatchingRecords_shouldReturnEmpty() {
String drl = "" +
"package org.springblade.lims.drools;\n" +
"import org.springblade.lims.entry.Examine;\n" +
"import java.util.Set;\n" +
"global java.util.Set matchedIds;\n" +
"\n" +
"rule \"no-match\"\n" +
" when\n" +
" $e: Examine(isDistribute == 2)\n" +
" then\n" +
" matchedIds.add($e.getId());\n" +
"end\n";
Examine e = new Examine();
e.setId(1L);
e.setIsDistribute(0);
List<Long> result = service.executeDrl(drl, Collections.singletonList(e));
assertThat(result).isEmpty();
}
@Test
void executeDrl_withEmptyCandidates_shouldReturnEmpty() {
String drl = "" +
"package org.springblade.lims.drools;\n" +
"import org.springblade.lims.entry.Examine;\n" +
"import java.util.Set;\n" +
"global java.util.Set matchedIds;\n" +
"\n" +
"rule \"any\"\n" +
" when\n" +
" $e: Examine()\n" +
" then\n" +
" matchedIds.add($e.getId());\n" +
"end\n";
List<Long> result = service.executeDrl(drl, new ArrayList<>());
assertThat(result).isEmpty();
}
@Test
void executeDrl_withNullCandidates_shouldReturnEmpty() {
String drl = "" +
"package org.springblade.lims.drools;\n" +
"import org.springblade.lims.entry.Examine;\n" +
"import java.util.Set;\n" +
"global java.util.Set matchedIds;\n" +
"\n" +
"rule \"any\"\n" +
" when\n" +
" $e: Examine()\n" +
" then\n" +
" matchedIds.add($e.getId());\n" +
"end\n";
List<Long> result = service.executeDrl(drl, null);
assertThat(result).isEmpty();
}
}

@ -0,0 +1,8 @@
spring:
main:
allow-bean-definition-overriding: true
autoconfigure:
exclude:
- org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
- org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration
- org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration

@ -148,6 +148,40 @@ public class RegionController extends BladeController {
return R.data(list);
}
/**
* 根据区划编码批量查询
*/
@GetMapping("/list-by-codes")
@ApiOperationSupport(order = 10)
@ApiOperation(value = "批量查询", notes = "传入codes(逗号分隔)")
public R<List<Region>> listByCodes(@RequestParam String codes) {
List<Region> list = regionService.list(Wrappers.<Region>query().lambda().in(Region::getCode, codes.split(",")));
return R.data(list);
}
/**
* 获取区划编码的祖先链
*/
@GetMapping("/ancestor-codes")
@ApiOperationSupport(order = 11)
@ApiOperation(value = "祖先链", notes = "传入code")
public R<List<String>> ancestorCodes(@RequestParam String code) {
Region region = regionService.getOne(Wrappers.<Region>query().lambda().eq(Region::getCode, code));
if (region == null) {
return R.data(new ArrayList<>());
}
String ancestors = region.getAncestors();
String[] parts = ancestors.split(",");
List<String> result = new ArrayList<>();
for (String part : parts) {
if (!"0".equals(part) && !part.isEmpty()) {
result.add(part);
}
}
result.add(code);
return R.data(result);
}
/**
* 导入行政区划数据
*/

Loading…
Cancel
Save