feat: 添加每日进度更新检查功能

- 设计师大厅排行榜默认折叠,点击展开
- 系统设置添加每日未更新扣分配置项
- 每天18点检查设计师是否更新进度,未更新则扣分并发钉钉通知
- 扣分同步到数据分析页面设计师绩效的延误扣分
This commit is contained in:
Your Name 2026-01-08 14:46:41 +08:00
parent ca4ccecc8b
commit dceff515b7
6 changed files with 363 additions and 18 deletions

View File

@ -0,0 +1,62 @@
package com.designhub.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 进度未更新扣分记录实体
*/
@Data
@TableName("progress_deduction_log")
public class ProgressDeductionLog {
@TableId(type = IdType.AUTO)
private Long id;
/**
* 设计师用户ID
*/
private Long userId;
/**
* 任务ID
*/
private Long taskId;
/**
* 子任务ID(如果是子任务)
*/
private Long taskItemId;
/**
* 扣分日期
*/
private LocalDate deductionDate;
/**
* 扣除分数
*/
private BigDecimal deductionScore;
/**
* 扣分原因
*/
private String reason;
/**
* 是否已通知
*/
private Integer notified;
/**
* 创建时间
*/
private LocalDateTime createTime;
}

View File

@ -0,0 +1,34 @@
package com.designhub.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.designhub.entity.ProgressDeductionLog;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.math.BigDecimal;
/**
* 进度未更新扣分记录Mapper
*/
@Mapper
public interface ProgressDeductionLogMapper extends BaseMapper<ProgressDeductionLog> {
/**
* 查询用户指定月份的扣分总额
*/
@Select("SELECT COALESCE(SUM(deduction_score), 0) FROM progress_deduction_log " +
"WHERE user_id = #{userId} " +
"AND YEAR(deduction_date) = #{year} " +
"AND MONTH(deduction_date) = #{month}")
BigDecimal sumMonthlyDeduction(@Param("userId") Long userId,
@Param("year") int year,
@Param("month") int month);
/**
* 查询用户总扣分
*/
@Select("SELECT COALESCE(SUM(deduction_score), 0) FROM progress_deduction_log WHERE user_id = #{userId}")
BigDecimal sumTotalDeduction(@Param("userId") Long userId);
}

View File

@ -38,6 +38,7 @@ public class AnalyticsService {
private final RoleService roleService;
private final DesignerTargetScoreMapper designerTargetScoreMapper;
private final DemandTypeMapper demandTypeMapper;
private final ProgressDeductionLogMapper progressDeductionLogMapper;
/**
* 获取需求统计数据(根据角色返回不同维度的数据)
@ -749,6 +750,25 @@ public class AnalyticsService {
}
}
// 添加进度未更新扣分
try {
if (progressDeductionLogMapper != null) {
// 解析月份
String[] monthParts = currentMonth.split("-");
if (monthParts.length == 2) {
int year = Integer.parseInt(monthParts[0]);
int month = Integer.parseInt(monthParts[1]);
BigDecimal progressDeduction = progressDeductionLogMapper.sumMonthlyDeduction(designer.getId(), year, month);
if (progressDeduction != null && progressDeduction.compareTo(BigDecimal.ZERO) > 0) {
deductionScore = deductionScore.add(progressDeduction);
log.debug("设计师 {} 本月进度未更新扣分: {}", designer.getRealName(), progressDeduction);
}
}
}
} catch (Exception e) {
log.warn("获取进度未更新扣分失败: {}", e.getMessage());
}
// 净积分 = 总积分 - 扣分
BigDecimal netScore = totalScore.subtract(deductionScore);
if (netScore.compareTo(BigDecimal.ZERO) < 0) {

View File

@ -0,0 +1,215 @@
package com.designhub.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.designhub.entity.*;
import com.designhub.mapper.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.*;
/**
* 进度检查服务 - 每日检查设计师是否更新了进度
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ProgressCheckService {
private final TaskMapper taskMapper;
private final TaskItemMapper taskItemMapper;
private final UserMapper userMapper;
private final ProgressDeductionLogMapper progressDeductionLogMapper;
private final SystemConfigService systemConfigService;
private final DingTalkService dingTalkService;
private final DepartmentMapper departmentMapper;
/**
* 每天18点执行进度检查
* cron: 秒 分 时 日 月 周
*/
@Scheduled(cron = "0 0 18 * * ?")
public void checkDailyProgressUpdate() {
log.info("========== 开始每日进度更新检查 ==========");
try {
// 检查是否启用
String enabledStr = systemConfigService.getConfigValue("progress.daily_check_enabled");
if (!"true".equalsIgnoreCase(enabledStr)) {
log.info("每日进度检查功能已禁用,跳过检查");
return;
}
// 获取扣分配置
String deductionStr = systemConfigService.getConfigValue("progress.no_update_deduction");
BigDecimal deductionScore = new BigDecimal(deductionStr != null ? deductionStr : "1");
String notifyStr = systemConfigService.getConfigValue("progress.notify_on_deduction");
boolean notifyEnabled = "true".equalsIgnoreCase(notifyStr);
LocalDate today = LocalDate.now();
LocalDateTime startOfDay = today.atStartOfDay();
LocalDateTime endOfDay = today.atTime(LocalTime.MAX);
// 1. 检查主任务
List<Task> inProgressTasks = taskMapper.selectList(
new LambdaQueryWrapper<Task>()
.eq(Task::getStatus, "in_progress")
.eq(Task::getDeleted, 0)
.isNotNull(Task::getReceiverId)
);
Map<Long, List<String>> userMissedTasks = new HashMap<>();
for (Task task : inProgressTasks) {
// 检查今天是否更新过进度
boolean updatedToday = task.getUpdateTime() != null &&
task.getUpdateTime().isAfter(startOfDay) &&
task.getUpdateTime().isBefore(endOfDay);
if (!updatedToday) {
Long userId = task.getReceiverId();
// 检查今天是否已经对该任务扣过分
Long existingCount = progressDeductionLogMapper.selectCount(
new LambdaQueryWrapper<ProgressDeductionLog>()
.eq(ProgressDeductionLog::getUserId, userId)
.eq(ProgressDeductionLog::getTaskId, task.getId())
.isNull(ProgressDeductionLog::getTaskItemId)
.eq(ProgressDeductionLog::getDeductionDate, today)
);
if (existingCount == 0) {
// 记录扣分
ProgressDeductionLog deductionLog = new ProgressDeductionLog();
deductionLog.setUserId(userId);
deductionLog.setTaskId(task.getId());
deductionLog.setDeductionDate(today);
deductionLog.setDeductionScore(deductionScore);
deductionLog.setReason("当日未更新任务进度");
deductionLog.setNotified(notifyEnabled ? 1 : 0);
deductionLog.setCreateTime(LocalDateTime.now());
progressDeductionLogMapper.insert(deductionLog);
// 记录待通知
userMissedTasks.computeIfAbsent(userId, k -> new ArrayList<>())
.add(task.getTitle());
log.info("用户 {} 任务【{}】今日未更新进度,扣除 {} 分",
userId, task.getTitle(), deductionScore);
}
}
}
// 2. 检查子任务
List<TaskItem> inProgressItems = taskItemMapper.selectList(
new LambdaQueryWrapper<TaskItem>()
.eq(TaskItem::getStatus, "in_progress")
.isNotNull(TaskItem::getReceiverId)
);
for (TaskItem item : inProgressItems) {
boolean updatedToday = item.getUpdateTime() != null &&
item.getUpdateTime().isAfter(startOfDay) &&
item.getUpdateTime().isBefore(endOfDay);
if (!updatedToday) {
Long userId = item.getReceiverId();
// 检查今天是否已经对该子任务扣过分
Long existingCount = progressDeductionLogMapper.selectCount(
new LambdaQueryWrapper<ProgressDeductionLog>()
.eq(ProgressDeductionLog::getUserId, userId)
.eq(ProgressDeductionLog::getTaskItemId, item.getId())
.eq(ProgressDeductionLog::getDeductionDate, today)
);
if (existingCount == 0) {
// 记录扣分
ProgressDeductionLog deductionLog = new ProgressDeductionLog();
deductionLog.setUserId(userId);
deductionLog.setTaskId(item.getTaskId());
deductionLog.setTaskItemId(item.getId());
deductionLog.setDeductionDate(today);
deductionLog.setDeductionScore(deductionScore);
deductionLog.setReason("当日未更新子任务进度");
deductionLog.setNotified(notifyEnabled ? 1 : 0);
deductionLog.setCreateTime(LocalDateTime.now());
progressDeductionLogMapper.insert(deductionLog);
// 记录待通知
userMissedTasks.computeIfAbsent(userId, k -> new ArrayList<>())
.add("子任务#" + item.getGroupNo());
log.info("用户 {} 子任务【#{}】今日未更新进度,扣除 {} 分",
userId, item.getGroupNo(), deductionScore);
}
}
}
// 3. 发送钉钉通知
if (notifyEnabled && !userMissedTasks.isEmpty()) {
sendDingTalkNotifications(userMissedTasks, deductionScore);
}
log.info("========== 每日进度检查完成,共处理 {} 位设计师 ==========", userMissedTasks.size());
} catch (Exception e) {
log.error("每日进度检查执行失败", e);
}
}
/**
* 发送钉钉通知
*/
private void sendDingTalkNotifications(Map<Long, List<String>> userMissedTasks, BigDecimal deductionScore) {
for (Map.Entry<Long, List<String>> entry : userMissedTasks.entrySet()) {
Long userId = entry.getKey();
List<String> tasks = entry.getValue();
try {
User user = userMapper.selectById(userId);
if (user == null || user.getDingtalkUserId() == null) {
continue;
}
StringBuilder content = new StringBuilder();
content.append("⚠️ 进度更新提醒\n\n");
content.append("您有 ").append(tasks.size()).append(" 个进行中的任务今日未更新进度:\n");
for (int i = 0; i < Math.min(tasks.size(), 5); i++) {
content.append("• ").append(tasks.get(i)).append("\n");
}
if (tasks.size() > 5) {
content.append("• ...等共 ").append(tasks.size()).append(" 个任务\n");
}
content.append("\n已扣除积分:").append(deductionScore.multiply(BigDecimal.valueOf(tasks.size()))).append(" 分");
content.append("\n\n💡 温馨提示:请每日下班前更新任务进度,以便跟进项目状态。");
dingTalkService.sendPersonalMessage(user.getDingtalkUserId(), content.toString());
log.info("已向用户 {} 发送进度提醒通知", user.getRealName());
} catch (Exception e) {
log.error("向用户 {} 发送钉钉通知失败", userId, e);
}
}
}
/**
* 手动触发进度检查(用于测试)
*/
@Transactional(rollbackFor = Exception.class)
public void manualCheckProgress() {
log.info("手动触发进度检查");
checkDailyProgressUpdate();
}
}

View File

@ -211,6 +211,12 @@ public class SystemConfigService {
createConfigIfNotExists("score.delay_penalty_rate", "10", "score", "延误扣分比例(%)", "任务延误时的扣分比例", "number", 1);
createConfigIfNotExists("score.delay_penalty_per_hour", "0.5", "score", "延误每小时扣分", "任务延误完结时每小时扣除的分数", "number", 1);
createConfigIfNotExists("score.monthly_reset_enabled", "true", "score", "月度积分清零", "接单部门积分是否按月清零重新统计", "boolean", 1);
// 每日进度更新检查配置
createConfigIfNotExists("progress.daily_check_enabled", "true", "progress", "启用每日进度检查", "是否启用每天18点检查设计师进度更新", "boolean", 1);
createConfigIfNotExists("progress.daily_check_hour", "18", "progress", "每日检查时间(小时)", "每天检查进度的时间,24小时制", "number", 1);
createConfigIfNotExists("progress.no_update_deduction", "1", "progress", "未更新进度扣分", "设计师当日未更新进度时扣除的分数", "number", 1);
createConfigIfNotExists("progress.notify_on_deduction", "true", "progress", "扣分时发送钉钉通知", "未更新进度扣分时是否发送钉钉提醒", "boolean", 1);
// 通知相关配置
createConfigIfNotExists("notification.dingtalk_enabled", "false", "notification", "启用钉钉通知", "是否启用钉钉消息推送", "boolean", 1);

View File

@ -44,39 +44,46 @@
<!-- 排行榜Tab -->
<div class="leaderboard-tabs chart-card">
<div class="tabs-header-row">
<div class="tabs-header-row" @click="leaderboardExpanded = !leaderboardExpanded" style="cursor: pointer;">
<div class="tabs-header">
<button
v-for="tab in leaderboardTabs"
:key="tab.key"
:class="['tab-button', { active: activeLeaderboard === tab.key }]"
@click="switchLeaderboard(tab.key)"
@click.stop="switchLeaderboard(tab.key)"
>
<Icon :name="tab.iconName" size="sm" color="#31f08b" style="margin-right: 4px; vertical-align: middle;" />
{{ tab.label }}
</button>
</div>
<!-- 月份选择器 -->
<div class="month-selector">
<a-select
v-model:value="selectedMonth"
placeholder="选择月份"
style="width: 150px"
@change="handleMonthChange"
>
<a-select-option
v-for="month in availableMonths"
:key="month.value"
:value="month.value"
<div style="display: flex; align-items: center; gap: 12px;">
<!-- 月份选择器 -->
<div class="month-selector" @click.stop>
<a-select
v-model:value="selectedMonth"
placeholder="选择月份"
style="width: 150px"
@change="handleMonthChange"
>
{{ month.label }}
</a-select-option>
</a-select>
<a-select-option
v-for="month in availableMonths"
:key="month.value"
:value="month.value"
>
{{ month.label }}
</a-select-option>
</a-select>
</div>
<!-- 展开/折叠按钮 -->
<a-button type="link" size="small" style="color: #31f08b;">
{{ leaderboardExpanded ? '收起' : '展开' }}
<Icon :name="leaderboardExpanded ? 'chevronUp' : 'chevronDown'" size="sm" style="margin-left: 4px;" />
</a-button>
</div>
</div>
<div class="leaderboard-content">
<div v-show="leaderboardExpanded" class="leaderboard-content">
<div
v-for="(designer, index) in leaderboardData"
:key="designer.userId"
@ -274,6 +281,7 @@ const designersList = ref<DesignerStats[]>([])
const leaderboardData = ref<DesignerStats[]>([])
const activeLeaderboard = ref('score')
const selectedMonth = ref('') // 选中的月份
const leaderboardExpanded = ref(false) // 排行榜默认折叠
// 排行榜Tab配置
const leaderboardTabs = [