This commit is contained in:
zhangzq
2026-08-11 10:47:41 +08:00
parent 57c1f4d8e9
commit 8d6458cdea
10 changed files with 307 additions and 296 deletions

View File

@@ -0,0 +1,46 @@
package org.nl.pms.planexception.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("plan_exception_log")
public class PlanExceptionLog {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
@TableField("project_id")
private String projectId;
@TableField("plan_detel_id")
private String planDetelId;
@TableField("exception_type")
private Integer exceptionType;
@TableField("found_time")
private LocalDateTime foundTime;
@TableField("delay_duration")
private String delayDuration;
@TableField("impact_scope")
private Integer impactScope;
private String description;
@TableField("root_cause")
private String rootCause;
@TableField("created_name")
private String createdName;
@TableField("created_time")
private LocalDateTime createdTime;
}

View File

@@ -0,0 +1,20 @@
package org.nl.pms.planexception.job;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.nl.pms.planexception.service.PlanExceptionCollectService;
import org.springframework.stereotype.Component;
@Slf4j
@Component("planExceptionCollectJob")
@RequiredArgsConstructor
public class PlanExceptionCollectJob {
private final PlanExceptionCollectService planExceptionCollectService;
public void collect() {
log.info("开始执行计划异常工作项收集任务");
planExceptionCollectService.collect();
log.info("计划异常工作项收集任务执行完成");
}
}

View File

@@ -0,0 +1,7 @@
package org.nl.pms.planexception.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.nl.pms.planexception.entity.PlanExceptionLog;
public interface PlanExceptionLogMapper extends BaseMapper<PlanExceptionLog> {
}

View File

@@ -0,0 +1,5 @@
package org.nl.pms.planexception.service;
public interface PlanExceptionCollectService {
void collect();
}

View File

@@ -0,0 +1,142 @@
package org.nl.pms.planexception.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.nl.pms.planexception.entity.PlanExceptionLog;
import org.nl.pms.planexception.mapper.PlanExceptionLogMapper;
import org.nl.pms.planexception.service.PlanExceptionCollectService;
import org.nl.pms.project.entity.Project;
import org.nl.pms.project.mapper.ProjectMapper;
import org.nl.pms.projectPlan.entity.ProjectPlanDetail;
import org.nl.pms.projectPlan.mapper.ProjectPlanDetailMapper;
import org.nl.pms.projectProgressReport.entity.ProjectProgressReport;
import org.nl.pms.projectProgressReport.mapper.ProjectProgressReportMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class PlanExceptionCollectServiceImpl implements PlanExceptionCollectService {
private static final int EXCEPTION_DELAYED_REPORT = 1;
private static final int EXCEPTION_DELAYED_TASK = 2;
private static final int REPORT_CONFIRMED = 2;
private static final int TASK_COMPLETED = 3;
private static final String SYSTEM_CREATOR = "系统定时任务";
private final ProjectMapper projectMapper;
private final ProjectPlanDetailMapper detailMapper;
private final ProjectProgressReportMapper reportMapper;
private final PlanExceptionLogMapper exceptionLogMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public void collect() {
LocalDate today = LocalDate.now();
List<ProjectPlanDetail> details = detailMapper.selectList(new LambdaQueryWrapper<ProjectPlanDetail>()
.and(wrapper -> wrapper.ne(ProjectPlanDetail::getStatus, TASK_COMPLETED)
.or().isNull(ProjectPlanDetail::getStatus)));
if (details.isEmpty()) {
return;
}
Map<Long, Project> projects = projectMapper.selectBatchIds(details.stream()
.map(ProjectPlanDetail::getProjectId).distinct().toList())
.stream().collect(Collectors.toMap(Project::getId, project -> project));
Map<Long, LocalDate> latestConfirmedReportDates = latestConfirmedReportDates(details);
for (ProjectPlanDetail detail : details) {
Project project = projects.get(detail.getProjectId());
if (project == null) {
continue;
}
if (isTaskOverdue(detail, today)) {
saveDelayedTask(detail, today);
continue;
}
if (isSupportedReportRate(project.getReportRate())) {
saveDelayedReportWhenNeeded(detail, project, latestConfirmedReportDates.get(detail.getId()), today);
}
}
}
private Map<Long, LocalDate> latestConfirmedReportDates(List<ProjectPlanDetail> details) {
List<Long> detailIds = details.stream().map(ProjectPlanDetail::getId).toList();
if (detailIds.isEmpty()) {
return Map.of();
}
return reportMapper.selectList(new LambdaQueryWrapper<ProjectProgressReport>()
.in(ProjectProgressReport::getDetailId, detailIds)
.eq(ProjectProgressReport::getStatus, REPORT_CONFIRMED)
.isNotNull(ProjectProgressReport::getReportDate))
.stream().collect(Collectors.toMap(ProjectProgressReport::getDetailId, ProjectProgressReport::getReportDate,
(left, right) -> left.isAfter(right) ? left : right));
}
private boolean isTaskOverdue(ProjectPlanDetail detail, LocalDate today) {
return detail.getDevEndDate() != null && detail.getDevEndDate().isBefore(today);
}
private void saveDelayedTask(ProjectPlanDetail detail, LocalDate today) {
long overdueDays = ChronoUnit.DAYS.between(detail.getDevEndDate(), today);
String description = String.format("计划明细“%s”未完成计划结束日期为 %s当前已延期 %d 天。",
detail.getDetailName(), detail.getDevEndDate(), overdueDays);
save(detail, EXCEPTION_DELAYED_TASK, overdueDays, description,
"计划任务未在计划结束日期前完成");
}
private void saveDelayedReportWhenNeeded(ProjectPlanDetail detail, Project project, LocalDate lastReportDate, LocalDate today) {
if (detail.getDevStartDate() == null) {
return;
}
LocalDate calculationBaseDate = lastReportDate == null ? detail.getDevStartDate() : lastReportDate;
LocalDate dueReportDate = calculationBaseDate.plusDays(project.getReportRate());
if (!today.isAfter(dueReportDate)) {
return;
}
long overdueDays = ChronoUnit.DAYS.between(dueReportDate, today);
String reportBaseText = lastReportDate == null ? "暂无已确认报工,按计划开始日期" : "上次已确认报工日期";
String description = String.format("计划明细“%s”%s %s 计算,应报日期为 %s当前已逾期 %d 天未报工。",
detail.getDetailName(), reportBaseText, calculationBaseDate, dueReportDate, overdueDays);
save(detail, EXCEPTION_DELAYED_REPORT, overdueDays, description,
"超过项目规定报工频率未提交已确认报工");
}
private void save(ProjectPlanDetail detail, int exceptionType, long overdueDays,
String description, String rootCause) {
PlanExceptionLog record = new PlanExceptionLog();
record.setProjectId(String.valueOf(detail.getProjectId()));
record.setPlanDetelId(String.valueOf(detail.getId()));
record.setExceptionType(exceptionType);
LocalDateTime now = LocalDateTime.now();
record.setFoundTime(now);
record.setDelayDuration(overdueDays + "d");
record.setImpactScope(impactScope(overdueDays));
record.setDescription(description);
record.setRootCause(rootCause);
record.setCreatedName(SYSTEM_CREATOR);
record.setCreatedTime(LocalDateTime.now());
exceptionLogMapper.insert(record);
log.info("记录计划异常detailId={}, exceptionType={}, overdueDays={}", detail.getId(), exceptionType, overdueDays);
}
private boolean isSupportedReportRate(Integer reportRate) {
return Integer.valueOf(1).equals(reportRate)
|| Integer.valueOf(7).equals(reportRate)
|| Integer.valueOf(15).equals(reportRate);
}
private int impactScope(long overdueDays) {
if (overdueDays <= 1) {
return 1;
}
return overdueDays <= 3 ? 2 : 3;
}
}

View File

@@ -33,5 +33,8 @@ public class JobRunner implements ApplicationRunner {
*/
@Override
public void run(ApplicationArguments applicationArguments) {
java.util.List<org.nl.pms.system.service.quartz.dao.SysQuartzJob> activeJobs = quartzJobService.findByIsPauseIsFalse();
activeJobs.forEach(quartzManage::addJob);
log.info("已恢复 {} 个启用的定时任务", activeJobs.size());
}
}

View File

@@ -0,0 +1,42 @@
-- 计划异常工作项记录表
CREATE TABLE IF NOT EXISTS plan_exception_log (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增主键',
project_id VARCHAR(50) NOT NULL COMMENT '项目ID',
plan_detel_id VARCHAR(50) NOT NULL COMMENT '计划ID (关联WBS或里程碑)',
exception_type TINYINT NOT NULL COMMENT '异常类型: 1-延期报工, 2-延期任务, 3-重新排期',
found_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '异常发现时间',
delay_duration VARCHAR(20) DEFAULT NULL COMMENT '延期时间 (如: 4h, 2d)类型为3时置NULL',
impact_scope TINYINT NOT NULL COMMENT '影响范围: 1-小, 2-中, 3-大',
description TEXT NOT NULL COMMENT '详细说明',
root_cause TEXT DEFAULT NULL COMMENT '根本原因',
created_name VARCHAR(50) NOT NULL COMMENT '创建人',
created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间',
PRIMARY KEY (id),
KEY idx_exception_project_detail (project_id, plan_detel_id),
KEY idx_exception_found_time (found_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='人员异常工作项记录表';
-- 每天 23:00 收集未完成计划的延期任务和延期报工异常。
-- Quartz 通过 bean_name + method_name 反射调用该 Spring Bean 的无参方法。
INSERT INTO sys_quartz_job (
job_id, bean_name, cron_expression, is_pause, job_name, method_name, params,
description, person_in_charge, email, sub_task, pause_after_failure,
create_id, create_name, create_time, update_id, update_name, update_time
) VALUES (
'plan_exception_collect', 'planExceptionCollectJob', '0 0 23 * * ?', 0,
'计划异常工作项收集', 'collect', NULL,
'每日收集延期任务和延期报工异常记录', '系统', NULL, NULL, 0,
'system', '系统', NOW(), 'system', '系统', NOW()
)
ON DUPLICATE KEY UPDATE
bean_name = VALUES(bean_name),
cron_expression = VALUES(cron_expression),
is_pause = VALUES(is_pause),
job_name = VALUES(job_name),
method_name = VALUES(method_name),
params = VALUES(params),
description = VALUES(description),
pause_after_failure = VALUES(pause_after_failure),
update_id = VALUES(update_id),
update_name = VALUES(update_name),
update_time = NOW();

View File

@@ -1,319 +1,65 @@
import router from './routers'
import store from '@/store'
import NProgress from 'nprogress' // progress bar
import 'nprogress/nprogress.css' // progress bar style
import { getToken } from '@/utils/auth' // getToken from cookie
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
import { getToken } from '@/utils/auth'
import { filterAsyncRouter } from '@/store/modules/permission'
import { buildMenus } from '@/views/system/menu/menu'
const staticMenuData = [
{
name: '项目管理',
path: '/pms',
hidden: false,
redirect: 'noredirect',
component: 'Layout',
alwaysShow: true,
meta: {
title: '项目管理',
icon: 'system',
noCache: true
},
children: [
{
name: 'Project',
path: 'project',
hidden: false,
component: 'project/index',
meta: {
title: '项目管理',
icon: 'clipboard',
noCache: true
}
},
{
name: 'ProjectPlan',
path: 'project-plan',
hidden: false,
component: 'project/planIndex',
meta: {
title: '项目开发计划表',
icon: 'clipboard',
noCache: true
}
},
{
name: 'ProjectDelayRecord',
path: 'project-delay-records',
hidden: false,
component: 'project/delayRecordIndex',
meta: {
title: '项目延期记录管理',
icon: 'clipboard',
noCache: true
}
},
{
name: 'TemporaryWork',
path: 'temporary-work',
hidden: false,
component: 'project/temporaryWorkIndex',
meta: {
title: '我的临时工作管理',
icon: 'clipboard',
noCache: true
}
},
{
name: 'ProgressReport',
path: 'progress-report',
hidden: false,
component: 'project/progressReportIndex',
meta: {
title: '报工作业',
icon: 'clipboard',
noCache: true
}
}
]
},
{
name: '审批管理',
path: '/approval',
hidden: false,
redirect: 'noredirect',
component: 'Layout',
alwaysShow: true,
meta: {
title: '审批管理',
icon: 'system',
noCache: true
},
children: [
{
name: 'ProgressReportAudit',
path: 'progress-report-audit',
hidden: false,
component: 'project/progressReportAuditIndex',
meta: {
title: '报工审核',
icon: 'clipboard',
noCache: true
}
}
]
},
{
name: '系统管理',
path: '/system',
hidden: false,
redirect: 'noredirect',
component: 'Layout',
alwaysShow: true,
meta: {
title: '系统管理',
icon: 'system',
noCache: true
},
children: [
{
name: 'User',
path: 'user',
hidden: false,
component: 'system/user/index',
meta: {
title: '用户管理',
icon: 'peoples',
noCache: true
}
},
{
name: 'Role',
path: 'role',
hidden: false,
component: 'system/role/index',
meta: {
title: '角色管理',
icon: 'role',
noCache: true
}
},
{
name: 'Menu',
path: 'menu',
hidden: false,
component: 'system/menu/index',
meta: {
title: '菜单管理',
icon: 'menu',
noCache: true
}
},
{
name: 'Dept',
path: 'dept',
hidden: false,
component: 'system/dept/index',
meta: {
title: '部门管理',
icon: 'dept',
noCache: true
}
},
{
name: 'Dict',
path: 'dict',
hidden: false,
component: 'system/dict/index',
meta: {
title: '字典管理',
icon: 'dictionary',
noCache: true
}
},
{
name: 'Mock',
path: 'mock',
hidden: false,
component: 'system/mock/index',
meta: {
title: '接口mock',
icon: 'backup',
noCache: true
}
},
{
name: 'Param',
path: 'param',
hidden: false,
component: 'system/param/index',
meta: {
title: '系统参数',
icon: 'Steve-Jobs',
noCache: true
}
},
{
name: 'DataPermission',
path: 'dataPermission',
hidden: false,
component: 'system/dataPermission/index',
meta: {
title: '数据权限',
icon: 'Steve-Jobs',
noCache: true
}
},
{
name: 'Timing',
path: 'timing',
hidden: false,
component: 'system/timing/index',
meta: {
title: '任务调度',
icon: 'timing',
noCache: true
}
}
]
},
{
name: '系统监控',
path: '/monitor',
hidden: false,
redirect: 'noredirect',
component: 'Layout',
alwaysShow: true,
meta: {
title: '系统监控',
icon: 'monitor',
noCache: true
},
children: [
{
name: 'Redis',
path: 'redis',
hidden: false,
component: 'system/redis/index',
meta: {
title: 'Redis监控',
icon: 'radio',
noCache: true
}
},
{
name: 'SysNotice',
path: 'sysNotice',
hidden: false,
component: 'system/notice/index',
meta: {
title: '通知管理',
icon: 'develop',
noCache: true
}
}
]
}
]
const SYSTEM_TYPE = 1
NProgress.configure({ showSpinner: false })// NProgress Configuration
NProgress.configure({ showSpinner: false })
const whiteList = ['/login']// no redirect whitelist
const whiteList = ['/login']
router.beforeEach((to, from, next) => {
if (to.meta.title) {
// document.title = to.meta.title + ' - ' + Config.title
document.title = to.meta.title
}
NProgress.start()
if (getToken()) {
// 已登录且要跳转的页面是登录页
if (to.path === '/login') {
next({ path: '/' })
NProgress.done()
} else {
if (store.getters.roles.length === 0) { // 判断当前用户是否已拉取完user_info信息
store.dispatch('GetInfo').then(() => { // 拉取user_info
// 动态路由,拉取菜单
loadMenus(next, to)
}).catch(() => {
store.dispatch('LogOut').then(() => {
location.reload() // 为了重新实例化vue-router对象 避免bug
})
})
// 登录时未拉取 菜单,在此处拉取
} else if (store.getters.loadMenus) {
// 修改成false防止死循环
store.dispatch('updateLoadMenus')
loadMenus(next, to)
} else {
next()
}
}
} else {
/* has no token*/
if (whiteList.indexOf(to.path) !== -1) { // 在免登录白名单,直接进入
if (!getToken()) {
if (whiteList.includes(to.path)) {
next()
} else {
next(`/login?redirect=${to.fullPath}`) // 否则全部重定向到登录页
next(`/login?redirect=${to.fullPath}`)
NProgress.done()
}
return
}
if (to.path === '/login') {
next({ path: '/' })
NProgress.done()
return
}
if (store.getters.roles.length === 0) {
store.dispatch('GetInfo').then(() => loadMenus(next, to)).catch(() => {
store.dispatch('LogOut').then(() => location.reload())
})
} else if (store.getters.loadMenus) {
store.dispatch('updateLoadMenus')
loadMenus(next, to)
} else {
next()
}
})
export const loadMenus = (next, to) => {
const sdata = JSON.parse(JSON.stringify(staticMenuData))
const rdata = JSON.parse(JSON.stringify(staticMenuData))
const sidebarRoutes = filterAsyncRouter(sdata)
const rewriteRoutes = filterAsyncRouter(rdata, false, true)
rewriteRoutes.push({ path: '*', redirect: '/404', hidden: true })
store.dispatch('GenerateRoutes', rewriteRoutes).then(() => {
router.addRoutes(rewriteRoutes)
next({ ...to, replace: true })
buildMenus(SYSTEM_TYPE).then(res => {
const menus = Array.isArray(res) ? res : (res.data || [])
const sidebarRoutes = filterAsyncRouter(JSON.parse(JSON.stringify(menus)))
const rewriteRoutes = filterAsyncRouter(JSON.parse(JSON.stringify(menus)), false, true)
rewriteRoutes.push({ path: '*', redirect: '/404', hidden: true })
store.dispatch('GenerateRoutes', rewriteRoutes).then(() => {
router.addRoutes(rewriteRoutes)
next({ ...to, replace: true })
})
store.dispatch('SetSidebarRouters', sidebarRoutes)
}).catch(() => {
NProgress.done()
next(false)
})
store.dispatch('SetSidebarRouters', sidebarRoutes)
}
router.afterEach(() => {
NProgress.done() // finish progress bar
NProgress.done()
})

View File

@@ -852,7 +852,7 @@ export default {
return match ? match.label : '未知'
},
reportRateLabel(rate) {
return ({ 1: '每日', 7: '每周', 30: '每月' })[rate] || '-'
return ({ 1: '每日', 7: '每周', 15: '每月' })[rate] || '-'
},
statusTagType(status) {
const typeMap = {

View File

@@ -361,7 +361,7 @@ export default {
return plan
},
reportRateLabel(rate) {
return ({ 1: '每日', 7: '每周', 30: '每月' })[rate] || '-'
return ({ 1: '每日', 7: '每周', 15: '每月' })[rate] || '-'
},
addMaster() {
this.plan.masters.push(this.createMaster())