From 88c374952a5af0723703a40509c1dba453fa75fa Mon Sep 17 00:00:00 2001 From: wangyu <823267011@qq.com> Date: Wed, 17 Jun 2026 17:06:01 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=BA=86=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E5=89=8D=E6=99=AF=20=E7=BB=A9=E6=95=88=20=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=81=9A=E4=BA=86=E4=B8=80=E9=83=A8=E5=88=86?= =?UTF-8?q?=E6=9C=89=E7=82=B9=E6=99=95=20=E6=88=91=E6=8D=A2=E6=8D=A2?= =?UTF-8?q?=E8=84=91=E5=AD=90=E7=BB=A7=E7=BB=AD=E8=BF=99=E4=B8=AA=20?= =?UTF-8?q?=E8=BF=98=E6=9C=89=E8=AF=B4=E6=98=8E=E8=8F=9C=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/OaPerformanceController.java | 64 ++ .../oa/controller/OaPostponeController.java | 53 ++ .../OaProjectOverviewController.java | 38 ++ .../oa/domain/OaPerformanceDeduction.java | 37 ++ .../ruoyi/oa/domain/OaPerformanceScore.java | 35 + .../com/ruoyi/oa/domain/OaPostponeRecord.java | 46 ++ .../com/ruoyi/oa/domain/bo/OaPostponeBo.java | 25 + .../mapper/OaPerformanceDeductionMapper.java | 7 + .../ruoyi/oa/mapper/OaPerformanceMapper.java | 41 ++ .../oa/mapper/OaPerformanceScoreMapper.java | 7 + .../oa/mapper/OaPostponeRecordMapper.java | 99 +++ .../oa/mapper/OaProjectOverviewMapper.java | 226 +++++++ .../oa/service/IOaPerformanceService.java | 43 ++ .../ruoyi/oa/service/IOaPostponeService.java | 41 ++ .../oa/service/IOaProjectOverviewService.java | 21 + .../service/impl/OaApprovalServiceImpl.java | 14 + .../impl/OaPerformanceServiceImpl.java | 225 +++++++ .../service/impl/OaPostponeServiceImpl.java | 286 ++++++++ .../impl/OaProjectOverviewServiceImpl.java | 363 ++++++++++ .../ruoyi/oa/task/OverdueScanScheduler.java | 131 ++++ .../oa/task/PerformanceRewardScheduler.java | 277 ++++++++ ruoyi-ui/src/api/oa/performance.js | 25 + ruoyi-ui/src/api/oa/postpone.js | 24 + ruoyi-ui/src/api/oa/projectOverview.js | 18 + ruoyi-ui/src/components/PerfWidget/index.vue | 193 ++++++ .../components/Workbench/widgets/registry.js | 6 + .../src/layout/components/OverdueGuard.vue | 376 +++++++++++ ruoyi-ui/src/layout/index.vue | 3 + ruoyi-ui/src/views/oa/docs/panorama/index.vue | 227 +++++++ .../src/views/oa/docs/performance/index.vue | 258 ++++++++ .../src/views/oa/performance/mine/index.vue | 218 ++++++ .../src/views/oa/performance/rank/index.vue | 521 +++++++++++++++ .../oa/project/dashboardOverview/index.vue | 271 ++++++++ .../src/views/oa/project/panorama/index.vue | 624 ++++++++++++++++++ ruoyi-ui/src/views/oa/project/probox.vue | 6 + sql/oa_performance.sql | 40 ++ sql/oa_postpone.sql | 36 + sql/oa_project_panorama_menu.sql | 7 + 38 files changed, 4932 insertions(+) create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaPerformanceController.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaPostponeController.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaProjectOverviewController.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaPerformanceDeduction.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaPerformanceScore.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaPostponeRecord.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/domain/bo/OaPostponeBo.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaPerformanceDeductionMapper.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaPerformanceMapper.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaPerformanceScoreMapper.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaPostponeRecordMapper.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaProjectOverviewMapper.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaPerformanceService.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaPostponeService.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaProjectOverviewService.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaPerformanceServiceImpl.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaPostponeServiceImpl.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaProjectOverviewServiceImpl.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/task/OverdueScanScheduler.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/task/PerformanceRewardScheduler.java create mode 100644 ruoyi-ui/src/api/oa/performance.js create mode 100644 ruoyi-ui/src/api/oa/postpone.js create mode 100644 ruoyi-ui/src/api/oa/projectOverview.js create mode 100644 ruoyi-ui/src/components/PerfWidget/index.vue create mode 100644 ruoyi-ui/src/layout/components/OverdueGuard.vue create mode 100644 ruoyi-ui/src/views/oa/docs/panorama/index.vue create mode 100644 ruoyi-ui/src/views/oa/docs/performance/index.vue create mode 100644 ruoyi-ui/src/views/oa/performance/mine/index.vue create mode 100644 ruoyi-ui/src/views/oa/performance/rank/index.vue create mode 100644 ruoyi-ui/src/views/oa/project/dashboardOverview/index.vue create mode 100644 ruoyi-ui/src/views/oa/project/panorama/index.vue create mode 100644 sql/oa_performance.sql create mode 100644 sql/oa_postpone.sql create mode 100644 sql/oa_project_panorama_menu.sql diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaPerformanceController.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaPerformanceController.java new file mode 100644 index 0000000..a83b95c --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaPerformanceController.java @@ -0,0 +1,64 @@ +package com.ruoyi.oa.controller; + +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.R; +import com.ruoyi.common.helper.LoginHelper; +import com.ruoyi.oa.service.IOaPerformanceService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/oa/performance") +public class OaPerformanceController extends BaseController { + + private final IOaPerformanceService service; + + /** 我的绩效(默认本月) */ + @GetMapping("/mine") + public R> mine(@RequestParam(required = false) String period) { + return R.ok(service.myScore(LoginHelper.getUserId(), period)); + } + + /** 全员排名 */ + @GetMapping("/rank") + public R>> rank(@RequestParam(required = false) String period, + @RequestParam(required = false) String nameLike) { + return R.ok(service.rank(period, nameLike)); + } + + /** 看任意用户的绩效(管理员) */ + @GetMapping("/of/{userId}") + public R> of(@PathVariable Long userId, + @RequestParam(required = false) String period) { + return R.ok(service.myScore(userId, period)); + } + + /** 高总打主观分(0-40),权限由前端按 role/userId 控制 */ + @PostMapping("/subjective") + public R subjective(@RequestParam Long userId, + @RequestParam(required = false) String userName, + @RequestParam(required = false) String period, + @RequestParam Integer score) { + if (score == null) return R.fail("score 必填"); + service.setSubjectiveScore(userId, userName, period, score); + return R.ok(); + } + + /** 手动加分/扣分(高总或 admin)。points 正=扣,负=加 */ + @PostMapping("/manual") + public R manual(@RequestParam Long userId, + @RequestParam(required = false) String userName, + @RequestParam Integer points, + @RequestParam(required = false) String reason) { + if (points == null || points == 0) return R.fail("points 必填且 ≠ 0"); + // dedupKey 用时间戳+用户避免冲突 + String key = "manual_" + userId + "_" + System.currentTimeMillis(); + service.addDeduction(userId, userName, "manual", null, null, + points, reason == null ? "手动调整" : reason, key); + return R.ok(); + } +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaPostponeController.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaPostponeController.java new file mode 100644 index 0000000..46a8764 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaPostponeController.java @@ -0,0 +1,53 @@ +package com.ruoyi.oa.controller; + +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.R; +import com.ruoyi.oa.domain.bo.OaPostponeBo; +import com.ruoyi.oa.service.IOaPostponeService; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import java.util.List; +import java.util.Map; + +/** + * 顺延强制管理 + * 进系统就拉一次 /mine,有数据前端就强制弹窗 + */ +@Validated +@RestController +@RequiredArgsConstructor +@RequestMapping("/oa/postpone") +public class OaPostponeController extends BaseController { + + private final IOaPostponeService service; + + /** 我名下所有未处理的超期 */ + @GetMapping("/mine") + public R>> mine() { + return R.ok(service.listMine()); + } + + @PostMapping("/complete") + public R complete(@NotBlank @RequestParam String businessType, + @NotNull @RequestParam Long businessId) { + service.markComplete(businessType, businessId); + return R.ok(); + } + + @PostMapping("/cancel") + public R cancel(@NotBlank @RequestParam String businessType, + @NotNull @RequestParam Long businessId, + @NotBlank @RequestParam String reason) { + service.markCancel(businessType, businessId, reason); + return R.ok(); + } + + @PostMapping("/postpone") + public R> postpone(@Validated @RequestBody OaPostponeBo bo) { + return R.ok(service.postpone(bo)); + } +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaProjectOverviewController.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaProjectOverviewController.java new file mode 100644 index 0000000..683e269 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaProjectOverviewController.java @@ -0,0 +1,38 @@ +package com.ruoyi.oa.controller; + +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.R; +import com.ruoyi.oa.service.IOaProjectOverviewService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** + * 项目全景页 API。 + * 一次拉完项目的所有维度,避免前端 N 次轮询。 + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/oa/project/overview") +public class OaProjectOverviewController extends BaseController { + + private final IOaProjectOverviewService service; + + @GetMapping("/{projectId}") + public R> overview(@PathVariable Long projectId) { + return R.ok(service.overview(projectId)); + } + + /** 集团驾驶舱:全公司项目一览 */ + @GetMapping("/dashboard") + public R> dashboard( + @org.springframework.web.bind.annotation.RequestParam(required = false) String status, + @org.springframework.web.bind.annotation.RequestParam(required = false) String nameLike, + @org.springframework.web.bind.annotation.RequestParam(required = false, defaultValue = "50") Integer limit) { + return R.ok(service.dashboard(status, nameLike, limit)); + } +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaPerformanceDeduction.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaPerformanceDeduction.java new file mode 100644 index 0000000..a1f68b4 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaPerformanceDeduction.java @@ -0,0 +1,37 @@ +package com.ruoyi.oa.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +@Data +@TableName("oa_performance_deduction") +public class OaPerformanceDeduction implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id") + private Long id; + + private Long userId; + private String userName; + private String period; // YYYY-MM + + /** postpone / overdue30 / manual */ + private String source; + /** task / step / requirement */ + private String sourceType; + private Long sourceId; + + /** 正=扣 负=加 */ + private Integer points; + private String reason; + + /** 去重 key */ + private String dedupKey; + + private Date createTime; +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaPerformanceScore.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaPerformanceScore.java new file mode 100644 index 0000000..4cb9839 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaPerformanceScore.java @@ -0,0 +1,35 @@ +package com.ruoyi.oa.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +@Data +@TableName("oa_performance_score") +public class OaPerformanceScore implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id") + private Long id; + + private Long userId; + private String userName; + private String period; + + private Integer baseScore; + private Integer deduction; + private Integer bonus; + /** 客观自动加分(完成任务/步骤、出差、全勤) */ + private Integer reward; + private Integer totalScore; + private String grade; + /** 高总是否已打主观分:0未打(兜底用客观分等比放大100) 1已打 */ + private Integer subjectiveSet; + + private Date createTime; + private Date updateTime; +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaPostponeRecord.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaPostponeRecord.java new file mode 100644 index 0000000..b3e0e34 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaPostponeRecord.java @@ -0,0 +1,46 @@ +package com.ruoyi.oa.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.ruoyi.common.core.domain.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.Date; + +/** + * 顺延记录 oa_postpone_record + * 三类业务统一存:task / step / requirement + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("oa_postpone_record") +public class OaPostponeRecord extends BaseEntity { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id") + private Long id; + + private String businessType; + private Long businessId; + private String businessTitle; + + private Long ownerId; + private String ownerName; + + private Date originalDeadline; + private Date newDeadline; + private String reason; + + /** 这是第几次顺延(含本次) */ + private Integer postponeSeq; + /** 0直接生效 1需要审批 */ + private Integer needApproval; + private Long approvalInstanceId; + + /** 0待审批 1已生效 2被驳回 3已撤回 */ + private Integer status; + + private Integer delFlag; +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/bo/OaPostponeBo.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/bo/OaPostponeBo.java new file mode 100644 index 0000000..802b18f --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/bo/OaPostponeBo.java @@ -0,0 +1,25 @@ +package com.ruoyi.oa.domain.bo; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import java.io.Serializable; +import java.util.Date; + +@Data +public class OaPostponeBo implements Serializable { + + private static final long serialVersionUID = 1L; + + @NotBlank + private String businessType; + @NotNull + private Long businessId; + /** 提交时的标题(仅展示用) */ + private String businessTitle; + @NotNull + private Date newDeadline; + @NotBlank + private String reason; +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaPerformanceDeductionMapper.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaPerformanceDeductionMapper.java new file mode 100644 index 0000000..b06ef1e --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaPerformanceDeductionMapper.java @@ -0,0 +1,7 @@ +package com.ruoyi.oa.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.ruoyi.oa.domain.OaPerformanceDeduction; + +public interface OaPerformanceDeductionMapper extends BaseMapper { +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaPerformanceMapper.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaPerformanceMapper.java new file mode 100644 index 0000000..9a2e1e1 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaPerformanceMapper.java @@ -0,0 +1,41 @@ +package com.ruoyi.oa.mapper; + +import com.ruoyi.common.core.mapper.BaseMapperPlus; +import com.ruoyi.oa.domain.OaPerformanceDeduction; +import com.ruoyi.oa.domain.OaPerformanceScore; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; +import java.util.Map; + +public interface OaPerformanceMapper { + + // 不强行用 BaseMapperPlus —— 这俩表用法相对独立,单独写更清晰 + @Select("SELECT * FROM oa_performance_score WHERE user_id = #{uid} AND period = #{period} LIMIT 1") + OaPerformanceScore findScore(@Param("uid") Long uid, @Param("period") String period); + + @Select("SELECT * FROM oa_performance_deduction WHERE user_id = #{uid} AND period = #{period} ORDER BY id DESC") + List listDeductions(@Param("uid") Long uid, @Param("period") String period); + + /** + * 列全员:以 sys_user 为主表 LEFT JOIN 绩效; + * 没记录的员工返回默认值(base=60, ded=0, bonus=40, subjective_set=0, total=100, grade='A+') + */ + @Select("") + List> listAllScores(@Param("period") String period, + @Param("nameLike") String nameLike); +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaPerformanceScoreMapper.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaPerformanceScoreMapper.java new file mode 100644 index 0000000..67ffb12 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaPerformanceScoreMapper.java @@ -0,0 +1,7 @@ +package com.ruoyi.oa.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.ruoyi.oa.domain.OaPerformanceScore; + +public interface OaPerformanceScoreMapper extends BaseMapper { +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaPostponeRecordMapper.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaPostponeRecordMapper.java new file mode 100644 index 0000000..c7dff62 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaPostponeRecordMapper.java @@ -0,0 +1,99 @@ +package com.ruoyi.oa.mapper; + +import com.ruoyi.common.core.mapper.BaseMapperPlus; +import com.ruoyi.oa.domain.OaPostponeRecord; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; +import java.util.Map; + +public interface OaPostponeRecordMapper extends BaseMapperPlus { + + /** + * 列出当前 owner 名下所有 "已超期且没有正在走流程的顺延" 的业务条目。 + * 三类合并:sys_oa_task / oa_project_schedule_step / oa_requirements + * + * 排除规则: + * - 已存在 status=0 的顺延记录(说明在等审批) + * - 已存在 status=1 且 new_deadline > NOW() 的顺延(已经顺延过且新日期未到) + */ + @Select("SELECT * FROM (" + + // task + " SELECT 'task' AS business_type, t.task_id AS business_id, t.task_title AS business_title, " + + " t.finish_time AS deadline, t.create_user_id AS owner_id, t.worker_id AS owner_id2, " + + " t.project_id AS project_id " + + " FROM sys_oa_task t " + + " WHERE (t.del_flag = 0 OR t.del_flag IS NULL) " + + " AND t.state IN (0,15) AND t.finish_time IS NOT NULL AND t.finish_time < NOW() " + + " UNION ALL " + + // schedule step:进度负责人 steward 存的是「中文昵称」,需反查 sys_user; + // 取不到则回退到项目负责人 functionary(也是昵称) + " SELECT 'step' AS business_type, s.track_id AS business_id, s.step_name AS business_title, " + + " COALESCE(s.end_time, s.plan_end) AS deadline, " + + " COALESCE(" + + " (SELECT user_id FROM sys_user WHERE nick_name = sch.steward AND status='0' LIMIT 1)," + + " (SELECT user_id FROM sys_user WHERE nick_name = p.functionary AND status='0' LIMIT 1)" + + " ) AS owner_id, " + + " NULL AS owner_id2, sch.project_id AS project_id " + + " FROM oa_project_schedule_step s " + + " JOIN oa_project_schedule sch ON sch.schedule_id = s.schedule_id " + + " LEFT JOIN sys_oa_project p ON p.project_id = sch.project_id " + + " WHERE s.del_flag = '0' AND (s.status IS NULL OR s.status != 2) " + + " AND COALESCE(s.end_time, s.plan_end) IS NOT NULL " + + " AND COALESCE(s.end_time, s.plan_end) < NOW() " + + " UNION ALL " + + // requirement: create_by 存的是「登录名」,反查 sys_user.user_name;owner_id 已是 user_id + " SELECT 'requirement' AS business_type, r.requirement_id AS business_id, r.title AS business_title, " + + " r.deadline, " + + " (SELECT user_id FROM sys_user WHERE user_name = r.create_by AND status='0' LIMIT 1) AS owner_id, " + + " r.owner_id AS owner_id2, r.project_id AS project_id " + + " FROM oa_requirements r " + + " WHERE r.del_flag = 0 AND r.status IN (0,1) AND r.deadline < NOW() " + + ") x " + + "WHERE (x.owner_id = #{uid} OR x.owner_id2 = #{uid}) " + + " AND NOT EXISTS (SELECT 1 FROM oa_postpone_record p " + + " WHERE p.business_type = x.business_type AND p.business_id = x.business_id " + + " AND p.del_flag = 0 AND p.status = 0) " + + " AND NOT EXISTS (SELECT 1 FROM oa_postpone_record p2 " + + " WHERE p2.business_type = x.business_type AND p2.business_id = x.business_id " + + " AND p2.del_flag = 0 AND p2.status = 1 AND p2.new_deadline > NOW()) " + + "ORDER BY x.deadline ASC") + List> listOverdueForUser(@Param("uid") Long uid); + + @Select("SELECT COUNT(*) FROM oa_postpone_record " + + "WHERE business_type = #{type} AND business_id = #{bid} AND del_flag = 0 AND status IN (0,1)") + Integer countSeq(@Param("type") String type, @Param("bid") Long bid); + + /** 定时任务用:全员超期清单(不按 owner 过滤),逻辑同 listOverdueForUser */ + @Select("SELECT * FROM (" + + " SELECT 'task' AS business_type, t.task_id AS business_id, t.task_title AS business_title, " + + " t.finish_time AS deadline, t.create_user_id AS owner_id, t.worker_id AS owner_id2, " + + " t.project_id AS project_id " + + " FROM sys_oa_task t " + + " WHERE (t.del_flag = 0 OR t.del_flag IS NULL) " + + " AND t.state IN (0,15) AND t.finish_time IS NOT NULL AND t.finish_time < NOW() " + + " UNION ALL " + + " SELECT 'step', s.track_id, s.step_name, COALESCE(s.end_time, s.plan_end), " + + " COALESCE(" + + " (SELECT user_id FROM sys_user WHERE nick_name = sch.steward AND status='0' LIMIT 1)," + + " (SELECT user_id FROM sys_user WHERE nick_name = p.functionary AND status='0' LIMIT 1)" + + " ), NULL, sch.project_id " + + " FROM oa_project_schedule_step s " + + " JOIN oa_project_schedule sch ON sch.schedule_id = s.schedule_id " + + " LEFT JOIN sys_oa_project p ON p.project_id = sch.project_id " + + " WHERE s.del_flag = '0' AND (s.status IS NULL OR s.status != 2) " + + " AND COALESCE(s.end_time, s.plan_end) IS NOT NULL " + + " AND COALESCE(s.end_time, s.plan_end) < NOW() " + + " UNION ALL " + + " SELECT 'requirement', r.requirement_id, r.title, r.deadline, " + + " (SELECT user_id FROM sys_user WHERE user_name = r.create_by AND status='0' LIMIT 1), " + + " r.owner_id, r.project_id " + + " FROM oa_requirements r " + + " WHERE r.del_flag = 0 AND r.status IN (0,1) AND r.deadline < NOW() " + + ") x " + + "WHERE NOT EXISTS (SELECT 1 FROM oa_postpone_record p WHERE p.business_type=x.business_type AND p.business_id=x.business_id AND p.del_flag=0 AND p.status=0) " + + " AND NOT EXISTS (SELECT 1 FROM oa_postpone_record p2 WHERE p2.business_type=x.business_type AND p2.business_id=x.business_id AND p2.del_flag=0 AND p2.status=1 AND p2.new_deadline > NOW()) " + + "ORDER BY x.deadline ASC") + List> scanAllOverdue(); +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaProjectOverviewMapper.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaProjectOverviewMapper.java new file mode 100644 index 0000000..fde1fb6 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaProjectOverviewMapper.java @@ -0,0 +1,226 @@ +package com.ruoyi.oa.mapper; + +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +/** + * 项目全景页专用聚合查询 Mapper。 + * 集中存放所有跨域统计 SQL,避免到处建 entity。 + * 注意:各表 del_flag 类型不一致(char(1) vs tinyint),SQL 里手工处理。 + */ +public interface OaProjectOverviewMapper { + + // ============ 头信息 ============ + @Select("SELECT CAST(p.project_id AS CHAR) AS project_id, " + + " CAST(p.customer_id AS CHAR) AS customer_id, " + + " CAST(p.contract_id AS CHAR) AS contract_id, " + + " p.project_name, p.project_num, p.project_code, p.project_type, p.address, " + + " p.funds, p.functionary, p.begin_time, p.finish_time, p.original_finish_time, " + + " p.delivery, p.guarantee, p.introduction, p.project_grade, p.project_status, " + + " p.product_status, p.is_postpone, p.postpone_reason, p.postpone_time, p.postpone_count, " + + " p.color, p.trade_type, p.pre_pay, p.is_top, p.create_time, " + + " COALESCE((SELECT nick_name FROM sys_user WHERE nick_name = p.functionary LIMIT 1), p.functionary) AS functionary_nick " + + "FROM sys_oa_project p WHERE p.project_id = #{pid}") + Map getProject(@Param("pid") Long pid); + + @Select("SELECT CAST(customer_id AS CHAR) AS id, name, mobile, telephone, email, level, source " + + "FROM oa_customer WHERE customer_id = #{cid} AND del_flag = 0") + Map getCustomer(@Param("cid") Long cid); + + // ============ 合同 ============ + @Select("SELECT CAST(c.contract_id AS CHAR) AS contract_id, c.contract_num, c.contract_name, " + + "c.contract_price, c.contract_status, c.sign_time, c.create_time, " + + "i.status AS approval_status, CAST(i.id AS CHAR) AS approval_instance_id " + + "FROM sys_oa_contract c " + + "LEFT JOIN (SELECT business_id, MAX(id) max_id FROM oa_approval_instance " + + " WHERE business_type='contract' GROUP BY business_id) m ON m.business_id=c.contract_id " + + "LEFT JOIN oa_approval_instance i ON i.id=m.max_id " + + "WHERE c.project_id = #{pid} ORDER BY c.create_time DESC") + List> listContracts(@Param("pid") Long pid); + + // ============ 进度 ============ + @Select("SELECT CAST(schedule_id AS CHAR) AS schedule_id, CAST(template_id AS CHAR) AS template_id, " + + "current_step, start_time, end_time, status, steward " + + "FROM oa_project_schedule WHERE project_id = #{pid} AND del_flag = '0' " + + "ORDER BY create_time DESC LIMIT 1") + Map getSchedule(@Param("pid") Long pid); + + @Select("SELECT CAST(track_id AS CHAR) AS track_id, step_order, step_name, status, plan_start, plan_end, " + + "actual_start, actual_end, original_end_time, end_time " + + "FROM oa_project_schedule_step WHERE schedule_id = #{sid} AND del_flag = '0' " + + "ORDER BY step_order ASC") + List> listSteps(@Param("sid") Long sid); + + @Select("SELECT COUNT(*) FROM oa_project_schedule_delay opsd " + + "JOIN oa_project_schedule_step opss ON opss.track_id = opsd.track_id " + + "WHERE opss.schedule_id = #{sid} AND opsd.del_flag = 0") + Integer countDelays(@Param("sid") Long sid); + + // ============ 采购需求 + 到货 ============ + @Select("SELECT status, COUNT(*) c FROM oa_requirements " + + "WHERE project_id = #{pid} AND del_flag = 0 GROUP BY status") + List> requirementsByStatus(@Param("pid") Long pid); + + @Select("SELECT CAST(r.requirement_id AS CHAR) AS requirement_id, title, r.status, deadline, r.create_time, " + + "i.status AS approval_status, CAST(i.id AS CHAR) AS approval_instance_id " + + "FROM oa_requirements r " + + "LEFT JOIN (SELECT business_id, MAX(id) max_id FROM oa_approval_instance " + + " WHERE business_type='purchase_req' GROUP BY business_id) m ON m.business_id=r.requirement_id " + + "LEFT JOIN oa_approval_instance i ON i.id=m.max_id " + + "WHERE r.project_id = #{pid} AND r.del_flag = 0 " + + "ORDER BY r.create_time DESC LIMIT 20") + List> recentRequirements(@Param("pid") Long pid); + + @Select("SELECT detail_status, COUNT(*) c FROM oa_arrival_detail " + + "WHERE project_id = #{pid} AND del_flag = 0 GROUP BY detail_status") + List> arrivalsByStatus(@Param("pid") Long pid); + + // ============ 库房申请 ============ + @Select("SELECT status, COUNT(*) c FROM sys_oa_warehouse_request " + + "WHERE project_id = #{pid} AND del_flag = 0 GROUP BY status") + List> warehouseByStatus(@Param("pid") Long pid); + + @Select("SELECT request_id, item_name, quantity, status, owner, create_time " + + "FROM sys_oa_warehouse_request WHERE project_id = #{pid} AND del_flag = 0 " + + "ORDER BY create_time DESC LIMIT 10") + List> recentWarehouseRequests(@Param("pid") Long pid); + + // ============ 财务 ============ + @Select("SELECT COALESCE(SUM(amount), 0) FROM oa_payment_progress " + + "WHERE project_id = #{pid} AND del_flag = 0 AND complete = 1") + BigDecimal sumPaymentDone(@Param("pid") Long pid); + + @Select("SELECT COALESCE(SUM(amount), 0) FROM oa_payment_progress " + + "WHERE project_id = #{pid} AND del_flag = 0") + BigDecimal sumPaymentTotal(@Param("pid") Long pid); + + @Select("SELECT finance_type, COALESCE(SUM(CASE WHEN make_price REGEXP '^-?[0-9]+(\\\\.[0-9]+)?$' " + + "THEN CAST(make_price AS DECIMAL(20,2)) ELSE 0 END), 0) total " + + "FROM sys_oa_finance WHERE project_id = #{pid} GROUP BY finance_type") + List> financeByType(@Param("pid") Long pid); + + @Select("SELECT COALESCE(SUM(cost), 0) FROM sys_oa_cost " + + "WHERE project_id = #{pid} AND (del_flag = 0 OR del_flag IS NULL)") + BigDecimal sumCost(@Param("pid") Long pid); + + // ============ 任务 ============ + @Select("SELECT state, COUNT(*) c FROM sys_oa_task " + + "WHERE project_id = #{pid} AND (del_flag = 0 OR del_flag IS NULL) " + + "GROUP BY state") + List> tasksByState(@Param("pid") Long pid); + + @Select("SELECT CAST(t.task_id AS CHAR) AS task_id, t.task_title, t.state, " + + "t.begin_time, t.finish_time, t.completed_time, " + + "u.nick_name AS worker_nick " + + "FROM sys_oa_task t LEFT JOIN sys_user u ON u.user_id = t.worker_id " + + "WHERE t.project_id = #{pid} AND (t.del_flag = 0 OR t.del_flag IS NULL) " + + "ORDER BY t.create_time DESC LIMIT 20") + List> recentTasks(@Param("pid") Long pid); + + @Select("SELECT COUNT(*) FROM sys_oa_task " + + "WHERE project_id = #{pid} AND (del_flag = 0 OR del_flag IS NULL) " + + "AND state IN (0,15) AND finish_time IS NOT NULL AND finish_time < NOW()") + Integer countOverdueTasks(@Param("pid") Long pid); + + @Select("SELECT DISTINCT worker_id FROM sys_oa_task " + + "WHERE project_id = #{pid} AND (del_flag = 0 OR del_flag IS NULL) AND worker_id IS NOT NULL") + List taskWorkers(@Param("pid") Long pid); + + @Select("") + List> usersByIds(@Param("ids") List ids); + + // ============ 报告 / 会议 / 操作日志 ============ + @Select("SELECT r.report_id id, r.user_id, u.nick_name AS user_name, " + + "r.work_place, r.content, r.create_time " + + "FROM oa_project_report r LEFT JOIN sys_user u ON u.user_id = r.user_id " + + "WHERE r.project_id = #{pid} AND r.del_flag = 0 " + + "ORDER BY r.create_time DESC LIMIT 8") + List> recentReports(@Param("pid") Long pid); + + @Select("SELECT id, meeting_date, subject, location, meeting_type " + + "FROM oa_meeting_minutes WHERE project_id = #{pid} AND del_flag = '0' " + + "ORDER BY meeting_date DESC LIMIT 5") + List> recentMeetings(@Param("pid") Long pid); + + @Select("SELECT log_id id, target_type, target_name, operation_type, operation_desc, " + + "operator, operate_time FROM oa_project_operation_log " + + "WHERE project_id = #{pid} AND (del_flag = 0 OR del_flag IS NULL) " + + "ORDER BY operate_time DESC LIMIT 20") + List> recentOpLog(@Param("pid") Long pid); + + // ============ 审批时间线(跨业务汇总) ============ + @Select("") + List> approvalTimeline(@Param("filters") List> filters); + + // ============ fad_rm 制造主线(轧机厂三张关键表) ============ + @Select("SELECT progress_id, item_name, status, plan_start, plan_end, " + + "actual_start, actual_end, delay_reason " + + "FROM fad_rm_install_progress WHERE project_id = #{pid} AND del_flag = '0' " + + "ORDER BY plan_start ASC") + List> installProgress(@Param("pid") Long pid); + + @Select("SELECT record_id, record_date, record_type, param_name, param_value, result, issue_desc " + + "FROM fad_rm_commissioning_record WHERE project_id = #{pid} AND del_flag = '0' " + + "ORDER BY record_date DESC LIMIT 10") + List> commissioningRecords(@Param("pid") Long pid); + + @Select("SELECT check_id, item_text, is_checked, sort_order " + + "FROM fad_rm_acceptance_checklist WHERE project_id = #{pid} AND del_flag = '0' " + + "ORDER BY sort_order ASC") + List> acceptanceChecklist(@Param("pid") Long pid); + + @Select("SELECT accept_item_id, item_name, standard, result, inspector, inspect_date " + + "FROM fad_rm_acceptance_item WHERE project_id = #{pid} AND del_flag = '0' " + + "ORDER BY inspect_date DESC") + List> acceptanceItems(@Param("pid") Long pid); + + // ============ 集团驾驶舱:项目列表 + 健康度信号 ============ + // 注意:BIGINT 在 Map 里序列化可能丢精度,CAST 为字符串 + @Select("") + List> dashboardProjects(@Param("status") String status, + @Param("nameLike") String nameLike, + @Param("limit") int limit); + + @Select("SELECT " + + "(SELECT COUNT(*) FROM sys_oa_project WHERE project_status='0') AS running, " + + "(SELECT COUNT(*) FROM sys_oa_project WHERE project_status='1') AS finished, " + + "(SELECT COUNT(*) FROM sys_oa_project WHERE finish_time < NOW() AND project_status='0') AS overdue_running, " + + "(SELECT COUNT(*) FROM oa_approval_instance WHERE del_flag=0 AND status=0) AS total_pending_approvals, " + + "(SELECT COUNT(*) FROM sys_oa_task WHERE (del_flag=0 OR del_flag IS NULL) AND state IN (0,15) AND finish_time IS NOT NULL AND finish_time < NOW()) AS total_overdue_tasks, " + + "(SELECT COALESCE(SUM(funds),0) FROM sys_oa_project WHERE project_status='0') AS running_funds") + Map dashboardSummary(); + + // 同样的 functionary 是昵称问题,在 dashboardProjects 里 nick_name 直接用 p.functionary + // 但这里实际效果跟 dashboardProjects 里的 functionary_nick 字段保持兼容: +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaPerformanceService.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaPerformanceService.java new file mode 100644 index 0000000..0a9b035 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaPerformanceService.java @@ -0,0 +1,43 @@ +package com.ruoyi.oa.service; + +import com.ruoyi.oa.domain.OaPerformanceDeduction; +import com.ruoyi.oa.domain.OaPerformanceScore; + +import java.util.List; +import java.util.Map; + +public interface IOaPerformanceService { + + /** + * 扣分(已有同 dedup_key 则跳过;不存在则插入并刷新当月汇总)。 + * @param dedupKey 同一类扣分的唯一键;postpone 用 "postpone_{type}_{id}_seq{n}" 不冲突;overdue30 用 "overdue30_{type}_{id}" 同业务唯一 + * @return true=扣成功 false=已扣过/跳过 + */ + boolean addDeduction(Long userId, String userName, String source, String sourceType, + Long sourceId, int points, String reason, String dedupKey); + + /** 我的某月得分 + 流水 */ + Map myScore(Long userId, String period); + + /** 全员某月排名 */ + List> rank(String period, String nameLike); + + /** 当前周期 YYYY-MM */ + String currentPeriod(); + + /** 等级换算 */ + String gradeOf(int total); + + /** 高总给某员工某周期手动打主观分(0-40) */ + boolean setSubjectiveScore(Long userId, String userName, String period, int score); + + /** + * 尝试加分(受月度封顶限制) + * @param points 正数(加多少分);自动转成 -points 写到流水里 + * @param capPerPeriod 该 source 当月封顶 + * @return 实际加成的分数(0 表示已达封顶) + */ + int tryReward(Long userId, String userName, String source, + String sourceType, Long sourceId, int points, + String reason, String dedupKey, int capPerPeriod); +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaPostponeService.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaPostponeService.java new file mode 100644 index 0000000..afad6a8 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaPostponeService.java @@ -0,0 +1,41 @@ +package com.ruoyi.oa.service; + +import com.ruoyi.oa.domain.OaPostponeRecord; +import com.ruoyi.oa.domain.bo.OaPostponeBo; + +import java.util.List; +import java.util.Map; + +public interface IOaPostponeService { + + /** 当前用户的待处理超期清单 */ + List> listMine(); + + /** + * 直接完成:把业务记录的 status 置为完成 + */ + void markComplete(String businessType, Long businessId); + + /** + * 直接取消:把业务记录的 status 置为取消 + */ + void markCancel(String businessType, Long businessId, String reason); + + /** + * 申请顺延: + * - 第 1、2 次:直接写 oa_postpone_record + 立即应用到业务表(new_deadline)。返回 needApproval=false + * - 第 3 次及以后:写 oa_postpone_record(status=0, need_approval=1) + 提交审批。返回 needApproval=true + * 返回 map: { needApproval: bool, postponeSeq: N, recordId, instanceId? } + */ + Map postpone(OaPostponeBo bo); + + /** + * 由审批回调触发:根据已通过的 instanceId 找到对应 record,把 new_deadline 应用到业务表 + */ + void applyByInstance(Long instanceId); + + /** + * 由审批回调触发:驳回时把 record 标记为 status=2 + */ + void rejectByInstance(Long instanceId); +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaProjectOverviewService.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaProjectOverviewService.java new file mode 100644 index 0000000..8f02293 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaProjectOverviewService.java @@ -0,0 +1,21 @@ +package com.ruoyi.oa.service; + +import java.util.Map; + +/** + * 项目全景页聚合 service。 + * 返回一个大 Map(每个 key 对应一个前端卡片所需的数据),让前端一次拉完。 + */ +public interface IOaProjectOverviewService { + + /** + * 聚合一个项目的所有维度数据。 + * @param projectId 项目主键 + * @return 顶层 keys: header / customer / contracts / schedule / requirements / arrivals / + * warehouse / finance / tasks / team / reports / meetings / approvals / operationLog + */ + Map overview(Long projectId); + + /** 集团驾驶舱:所有项目一览 + 全局汇总 */ + Map dashboard(String status, String nameLike, Integer limit); +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaApprovalServiceImpl.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaApprovalServiceImpl.java index 614e5cc..0003080 100644 --- a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaApprovalServiceImpl.java +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaApprovalServiceImpl.java @@ -21,9 +21,12 @@ import com.ruoyi.oa.mapper.OaApprovalConfigMapper; import com.ruoyi.oa.mapper.OaApprovalInstanceMapper; import com.ruoyi.oa.mapper.OaApprovalRecordMapper; import com.ruoyi.oa.service.IOaApprovalService; +import com.ruoyi.oa.service.IOaPostponeService; import com.ruoyi.common.core.domain.entity.SysUser; import com.ruoyi.system.service.ISysUserService; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -39,6 +42,11 @@ public class OaApprovalServiceImpl implements IOaApprovalService { private final OaApprovalRecordMapper recordMapper; private final ISysUserService userService; + // 用 @Lazy 打破循环:postpone -> approval -> postpone + @Autowired + @Lazy + private IOaPostponeService postponeService; + // ===================== 配置 ===================== @Override @@ -156,6 +164,12 @@ public class OaApprovalServiceImpl implements IOaApprovalService { inst.setStatus(finalStatus); inst.setFinishTime(new Date()); instanceMapper.updateById(inst); + // 顺延审批的回调:通过 → 应用 new_deadline;驳回 → 标记 record + String bt = inst.getBusinessType(); + if (bt != null && bt.endsWith("_postpone")) { + if (finalStatus == 1) postponeService.applyByInstance(inst.getId()); + else if (finalStatus == 2) postponeService.rejectByInstance(inst.getId()); + } } return true; } diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaPerformanceServiceImpl.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaPerformanceServiceImpl.java new file mode 100644 index 0000000..c0960f4 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaPerformanceServiceImpl.java @@ -0,0 +1,225 @@ +package com.ruoyi.oa.service.impl; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.ruoyi.oa.domain.OaPerformanceDeduction; +import com.ruoyi.oa.domain.OaPerformanceScore; +import com.ruoyi.oa.mapper.OaPerformanceDeductionMapper; +import com.ruoyi.oa.mapper.OaPerformanceMapper; +import com.ruoyi.oa.mapper.OaPerformanceScoreMapper; +import com.ruoyi.oa.service.IOaPerformanceService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.text.SimpleDateFormat; +import java.util.*; + +@Slf4j +@Service +@RequiredArgsConstructor +public class OaPerformanceServiceImpl implements IOaPerformanceService { + + public static final int BASE_SCORE = 80; + public static final int SUBJECTIVE_MIN = -20; + public static final int SUBJECTIVE_MAX = 20; + public static final int TOTAL_CAP = 100; + + private final OaPerformanceMapper queryMapper; + private final OaPerformanceScoreMapper scoreMapper; + private final OaPerformanceDeductionMapper dedMapper; + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean addDeduction(Long userId, String userName, String source, String sourceType, + Long sourceId, int points, String reason, String dedupKey) { + if (userId == null) return false; + String period = currentPeriod(); + + // dedup_key 命中过就直接放弃 + if (dedupKey != null && !dedupKey.isEmpty()) { + Long exist = dedMapper.selectCount(Wrappers.lambdaQuery() + .eq(OaPerformanceDeduction::getDedupKey, dedupKey)); + if (exist != null && exist > 0) return false; + } + + OaPerformanceDeduction d = new OaPerformanceDeduction(); + d.setUserId(userId); + d.setUserName(userName); + d.setPeriod(period); + d.setSource(source); + d.setSourceType(sourceType); + d.setSourceId(sourceId); + d.setPoints(points); + d.setReason(reason); + d.setDedupKey(dedupKey); + try { + dedMapper.insert(d); + } catch (DuplicateKeyException e) { + return false; + } + + // 刷新当月汇总 + refreshScore(userId, userName, period); + return true; + } + + private void refreshScore(Long userId, String userName, String period) { + OaPerformanceScore s = queryMapper.findScore(userId, period); + boolean isNew = s == null; + if (isNew) { + s = new OaPerformanceScore(); + s.setUserId(userId); + s.setUserName(userName); + s.setPeriod(period); + s.setBaseScore(BASE_SCORE); + s.setBonus(0); // 高总主观分默认 0(未评价) + s.setSubjectiveSet(0); + } + List all = queryMapper.listDeductions(userId, period); + int dedSum = 0, rewardSum = 0; + for (OaPerformanceDeduction x : all) { + if (x.getPoints() == null) continue; + String src = x.getSource(); + if ("manual_subjective".equals(src)) continue; + // auto reward 类(按 source 区分):reward_task / reward_step / reward_trip / reward_attendance + if (src != null && src.startsWith("reward_")) { + rewardSum += Math.abs(x.getPoints()); + } else if (x.getPoints() >= 0) { + dedSum += x.getPoints(); + } else { + // 负点数(manual 加分)→ 计入 reward + rewardSum += -x.getPoints(); + } + } + s.setDeduction(dedSum); + s.setReward(rewardSum); + int bonus = s.getBonus() == null ? 0 : s.getBonus(); + boolean subSet = s.getSubjectiveSet() != null && s.getSubjectiveSet() == 1; + int total = computeTotal(BASE_SCORE, dedSum, rewardSum, bonus, subSet); + s.setTotalScore(total); + s.setGrade(gradeOf(total)); + if (s.getId() == null) scoreMapper.insert(s); + else scoreMapper.updateById(s); + } + + /** + * 总分 = clamp(base - 扣 + 加 + 主观, 0, 100) + * base=80; 主观分范围 -20~+20,默认 0;初始总分=80=B+ + */ + public static int computeTotal(int base, int ded, int reward, int bonus, boolean subjectiveSet) { + int total = base - ded + reward + bonus; + return Math.max(0, Math.min(TOTAL_CAP, total)); + } + + /** + * 高总(或管理员)给某员工某月手动打主观分 (-20 ~ +20) + */ + @Transactional(rollbackFor = Exception.class) + public boolean setSubjectiveScore(Long userId, String userName, String period, int score) { + if (score < SUBJECTIVE_MIN || score > SUBJECTIVE_MAX) { + throw new com.ruoyi.common.exception.ServiceException( + "主观分必须在 " + SUBJECTIVE_MIN + " ~ " + SUBJECTIVE_MAX + " 之间"); + } + if (period == null) period = currentPeriod(); + OaPerformanceScore s = queryMapper.findScore(userId, period); + if (s == null) { + s = new OaPerformanceScore(); + s.setUserId(userId); + s.setUserName(userName); + s.setPeriod(period); + s.setBaseScore(BASE_SCORE); + s.setDeduction(0); + } + s.setBonus(score); + s.setSubjectiveSet(1); + int total = computeTotal(BASE_SCORE, + s.getDeduction() == null ? 0 : s.getDeduction(), + s.getReward() == null ? 0 : s.getReward(), + score, true); + s.setTotalScore(total); + s.setGrade(gradeOf(total)); + if (s.getId() == null) scoreMapper.insert(s); + else scoreMapper.updateById(s); + return true; + } + + @Override + public Map myScore(Long userId, String period) { + if (period == null) period = currentPeriod(); + Map r = new LinkedHashMap<>(); + OaPerformanceScore s = queryMapper.findScore(userId, period); + if (s == null) { + // 没记录 = 默认满分(未打分用兜底,等比放大 = 100) + OaPerformanceScore empty = new OaPerformanceScore(); + empty.setUserId(userId); + empty.setPeriod(period); + empty.setBaseScore(BASE_SCORE); + empty.setDeduction(0); + empty.setBonus(0); // 未评价 = 主观 0 + empty.setReward(0); + empty.setSubjectiveSet(0); + int total = BASE_SCORE; // 初始 80 = B+ + empty.setTotalScore(total); + empty.setGrade(gradeOf(total)); + r.put("score", empty); + } else { + r.put("score", s); + } + r.put("deductions", queryMapper.listDeductions(userId, period)); + return r; + } + + @Override + public List> rank(String period, String nameLike) { + if (period == null) period = currentPeriod(); + return queryMapper.listAllScores(period, nameLike); + } + + @Override + public String currentPeriod() { + return new SimpleDateFormat("yyyy-MM").format(new Date()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int tryReward(Long userId, String userName, String source, + String sourceType, Long sourceId, int points, + String reason, String dedupKey, int capPerPeriod) { + if (userId == null || points <= 0) return 0; + String period = currentPeriod(); + // 该 source 当月已加多少 + Long existSum = dedMapper.selectCount(Wrappers.lambdaQuery() + .eq(OaPerformanceDeduction::getUserId, userId) + .eq(OaPerformanceDeduction::getPeriod, period) + .eq(OaPerformanceDeduction::getSource, source)); + // 用 SQL 拿到 sum 更准,但实现简单先用 count*points 估计;这里用 selectList 再 sum + java.util.List exist = dedMapper.selectList(Wrappers.lambdaQuery() + .eq(OaPerformanceDeduction::getUserId, userId) + .eq(OaPerformanceDeduction::getPeriod, period) + .eq(OaPerformanceDeduction::getSource, source)); + int currentSum = 0; + for (OaPerformanceDeduction d : exist) currentSum += Math.abs(d.getPoints() == null ? 0 : d.getPoints()); + int remain = capPerPeriod - currentSum; + if (remain <= 0) return 0; + int actual = Math.min(points, remain); + boolean ok = addDeduction(userId, userName, source, sourceType, sourceId, + -actual, reason, dedupKey); + return ok ? actual : 0; + } + + @Override + public String gradeOf(int total) { + if (total >= 95) return "A+"; + if (total >= 90) return "A"; + if (total >= 85) return "A-"; + if (total >= 80) return "B+"; + if (total >= 75) return "B"; + if (total >= 70) return "B-"; + if (total >= 65) return "C+"; + if (total >= 60) return "C"; + if (total >= 55) return "C-"; + return "D"; + } +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaPostponeServiceImpl.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaPostponeServiceImpl.java new file mode 100644 index 0000000..d974dcf --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaPostponeServiceImpl.java @@ -0,0 +1,286 @@ +package com.ruoyi.oa.service.impl; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.helper.LoginHelper; +import com.ruoyi.oa.domain.OaPostponeRecord; +import com.ruoyi.oa.domain.OaRequirements; +import com.ruoyi.oa.domain.OaProjectScheduleStep; +import com.ruoyi.oa.domain.SysOaTask; +import com.ruoyi.oa.domain.bo.OaPostponeBo; +import com.ruoyi.oa.mapper.OaPostponeRecordMapper; +import com.ruoyi.oa.mapper.OaProjectScheduleStepMapper; +import com.ruoyi.oa.mapper.OaRequirementsMapper; +import com.ruoyi.oa.mapper.SysOaTaskMapper; +import com.ruoyi.oa.service.IOaApprovalService; +import com.ruoyi.oa.service.IOaPerformanceService; +import com.ruoyi.oa.service.IOaPostponeService; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class OaPostponeServiceImpl implements IOaPostponeService { + + public static final int APPROVAL_THRESHOLD = 3; + + private final OaPostponeRecordMapper postponeMapper; + private final SysOaTaskMapper taskMapper; + private final OaProjectScheduleStepMapper stepMapper; + private final OaRequirementsMapper requirementsMapper; + + // 用 @Lazy 打破和 OaApprovalServiceImpl 的循环依赖 + @Autowired + @Lazy + private IOaApprovalService approvalService; + + @Autowired + private IOaPerformanceService performanceService; + + @Override + public List> listMine() { + Long uid = LoginHelper.getUserId(); + if (uid == null) return java.util.Collections.emptyList(); + return postponeMapper.listOverdueForUser(uid); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void markComplete(String businessType, Long businessId) { + ensureType(businessType); + switch (businessType) { + case "task": + SysOaTask task = taskMapper.selectById(businessId); + if (task == null) throw new ServiceException("任务不存在"); + task.setState(2L); + task.setCompletedTime(new Date()); + taskMapper.updateById(task); + break; + case "step": + OaProjectScheduleStep step = stepMapper.selectById(businessId); + if (step == null) throw new ServiceException("步骤不存在"); + step.setStatus(2L); + step.setActualEnd(new Date()); + stepMapper.updateById(step); + break; + case "requirement": + OaRequirements req = requirementsMapper.selectById(businessId); + if (req == null) throw new ServiceException("采购需求不存在"); + req.setStatus(2); + requirementsMapper.updateById(req); + break; + default: + throw new ServiceException("不支持的业务类型"); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void markCancel(String businessType, Long businessId, String reason) { + ensureType(businessType); + switch (businessType) { + case "task": + SysOaTask task = taskMapper.selectById(businessId); + if (task == null) throw new ServiceException("任务不存在"); + // 任务没有"取消"状态,用 state=2(完成) + remark 体现 + task.setState(2L); + task.setRemark(appendReason(task.getRemark(), "[取消] " + reason)); + taskMapper.updateById(task); + break; + case "step": + OaProjectScheduleStep step = stepMapper.selectById(businessId); + if (step == null) throw new ServiceException("步骤不存在"); + step.setStatus(2L); + step.setActualEnd(new Date()); + stepMapper.updateById(step); + break; + case "requirement": + OaRequirements req = requirementsMapper.selectById(businessId); + if (req == null) throw new ServiceException("采购需求不存在"); + req.setStatus(3); // 3=取消 + req.setRemark(appendReason(req.getRemark(), "[取消] " + reason)); + requirementsMapper.updateById(req); + break; + default: + throw new ServiceException("不支持的业务类型"); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Map postpone(OaPostponeBo bo) { + ensureType(bo.getBusinessType()); + if (bo.getNewDeadline() == null) throw new ServiceException("新截止日期必填"); + if (bo.getNewDeadline().before(new Date())) throw new ServiceException("新截止日期不能早于今天"); + + Date originalDeadline = readOriginalDeadline(bo.getBusinessType(), bo.getBusinessId()); + Integer prevSeq = postponeMapper.countSeq(bo.getBusinessType(), bo.getBusinessId()); + int newSeq = (prevSeq == null ? 0 : prevSeq) + 1; + boolean needApproval = newSeq >= APPROVAL_THRESHOLD; + + // 建 record + OaPostponeRecord rec = new OaPostponeRecord(); + rec.setBusinessType(bo.getBusinessType()); + rec.setBusinessId(bo.getBusinessId()); + rec.setBusinessTitle(bo.getBusinessTitle()); + rec.setOwnerId(LoginHelper.getUserId()); + rec.setOwnerName(LoginHelper.getNickName()); + rec.setOriginalDeadline(originalDeadline); + rec.setNewDeadline(bo.getNewDeadline()); + rec.setReason(bo.getReason()); + rec.setPostponeSeq(newSeq); + rec.setNeedApproval(needApproval ? 1 : 0); + rec.setStatus(needApproval ? 0 : 1); + postponeMapper.insert(rec); + + Map r = new HashMap<>(); + r.put("recordId", rec.getId()); + r.put("postponeSeq", newSeq); + r.put("needApproval", needApproval); + + if (needApproval) { + String approvalBizType = mapToApprovalType(bo.getBusinessType()); + // 用 record.id 作为审批的 business_id,确保 1 条审批单对应 1 条 postpone + Long instId = approvalService.submit(approvalBizType, rec.getId(), + "[" + label(bo.getBusinessType()) + "] " + (bo.getBusinessTitle() == null ? "" : bo.getBusinessTitle()) + + " 第 " + newSeq + " 次顺延"); + rec.setApprovalInstanceId(instId); + postponeMapper.updateById(rec); + r.put("instanceId", instId); + } else { + // 直接应用到业务表 + applyNewDeadline(bo.getBusinessType(), bo.getBusinessId(), bo.getNewDeadline()); + } + + // 绩效扣分(每次顺延 -1,dedupKey 含序号避免误判重复) + String dedupKey = "postpone_" + bo.getBusinessType() + "_" + bo.getBusinessId() + "_seq" + newSeq; + performanceService.addDeduction( + LoginHelper.getUserId(), LoginHelper.getNickName(), + "postpone", bo.getBusinessType(), bo.getBusinessId(), + 1, + "第 " + newSeq + " 次顺延:" + (bo.getBusinessTitle() == null ? "" : bo.getBusinessTitle()), + dedupKey); + + return r; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void applyByInstance(Long instanceId) { + OaPostponeRecord rec = postponeMapper.selectOne( + Wrappers.lambdaQuery() + .eq(OaPostponeRecord::getApprovalInstanceId, instanceId) + .last("LIMIT 1")); + if (rec == null || rec.getStatus() != 0) return; + rec.setStatus(1); + postponeMapper.updateById(rec); + applyNewDeadline(rec.getBusinessType(), rec.getBusinessId(), rec.getNewDeadline()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void rejectByInstance(Long instanceId) { + OaPostponeRecord rec = postponeMapper.selectOne( + Wrappers.lambdaQuery() + .eq(OaPostponeRecord::getApprovalInstanceId, instanceId) + .last("LIMIT 1")); + if (rec == null || rec.getStatus() != 0) return; + rec.setStatus(2); + postponeMapper.updateById(rec); + } + + // ============ 内部 ============ + + private void ensureType(String t) { + if (!"task".equals(t) && !"step".equals(t) && !"requirement".equals(t)) { + throw new ServiceException("不支持的业务类型: " + t); + } + } + + private String mapToApprovalType(String businessType) { + switch (businessType) { + case "task": return "task_postpone"; + case "step": return "step_postpone"; + case "requirement": return "req_postpone"; + default: throw new ServiceException("未知业务类型"); + } + } + + private String label(String businessType) { + switch (businessType) { + case "task": return "任务"; + case "step": return "进度步骤"; + case "requirement": return "采购需求"; + default: return businessType; + } + } + + private Date readOriginalDeadline(String businessType, Long businessId) { + switch (businessType) { + case "task": { + SysOaTask t = taskMapper.selectById(businessId); + return t == null ? null : t.getFinishTime(); + } + case "step": { + OaProjectScheduleStep s = stepMapper.selectById(businessId); + if (s == null) return null; + // endTime 是 LocalDateTime,planEnd 是 Date,转换后统一 + if (s.getEndTime() != null) { + return Date.from(s.getEndTime().atZone(ZoneId.systemDefault()).toInstant()); + } + return s.getPlanEnd(); + } + case "requirement": { + OaRequirements r = requirementsMapper.selectById(businessId); + return r == null ? null : r.getDeadline(); + } + default: return null; + } + } + + private void applyNewDeadline(String businessType, Long businessId, Date newDeadline) { + switch (businessType) { + case "task": { + SysOaTask t = taskMapper.selectById(businessId); + if (t == null) return; + t.setFinishTime(newDeadline); + Long p = t.getPostponements(); + t.setPostponements((p == null ? 0L : p) + 1L); + taskMapper.updateById(t); + break; + } + case "step": { + OaProjectScheduleStep s = stepMapper.selectById(businessId); + if (s == null) return; + LocalDateTime ldt = LocalDateTime.ofInstant(newDeadline.toInstant(), ZoneId.systemDefault()); + s.setEndTime(ldt); + s.setPlanEnd(newDeadline); + stepMapper.updateById(s); + break; + } + case "requirement": { + OaRequirements r = requirementsMapper.selectById(businessId); + if (r == null) return; + r.setDeadline(newDeadline); + requirementsMapper.updateById(r); + break; + } + default: // ignore + } + } + + private String appendReason(String existing, String add) { + if (existing == null || existing.isEmpty()) return add; + return existing + " | " + add; + } +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaProjectOverviewServiceImpl.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaProjectOverviewServiceImpl.java new file mode 100644 index 0000000..fc11d45 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaProjectOverviewServiceImpl.java @@ -0,0 +1,363 @@ +package com.ruoyi.oa.service.impl; + +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.oa.mapper.OaProjectOverviewMapper; +import com.ruoyi.oa.service.IOaProjectOverviewService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 项目全景页聚合实现。 + * 设计原则:宁可多查几次也保持代码简单,因为这个接口请求量很低(用户主动打开项目页才触发)。 + */ +@Service +@RequiredArgsConstructor +public class OaProjectOverviewServiceImpl implements IOaProjectOverviewService { + + private final OaProjectOverviewMapper mapper; + + @Override + public Map overview(Long projectId) { + if (projectId == null) throw new ServiceException("projectId 必填"); + + Map result = new LinkedHashMap<>(); + + // 1. 头信息 + 客户 + Map header = mapper.getProject(projectId); + if (header == null) throw new ServiceException("项目不存在:" + projectId); + result.put("header", header); + Long cid = parseLong(header.get("customer_id")); + if (cid != null) { + result.put("customer", mapper.getCustomer(cid)); + } + + // 2. 合同 + List> contracts = mapper.listContracts(projectId); + result.put("contracts", contracts); + + // 3. 进度 + Map schedule = mapper.getSchedule(projectId); + Map scheduleSection = new LinkedHashMap<>(); + if (schedule != null) { + Long sid = parseLong(schedule.get("schedule_id")); + if (sid != null) { + List> steps = mapper.listSteps(sid); + Integer delays = mapper.countDelays(sid); + scheduleSection.putAll(schedule); + scheduleSection.put("steps", steps); + scheduleSection.put("totalSteps", steps.size()); + scheduleSection.put("doneSteps", steps.stream() + .filter(s -> isStepDone(s.get("status"))).count()); + scheduleSection.put("delayCount", delays == null ? 0 : delays); + scheduleSection.put("overdue", isScheduleOverdue(schedule)); + } + } + result.put("schedule", scheduleSection); + + // 4. 采购需求 + 到货 + Map reqSection = new LinkedHashMap<>(); + reqSection.put("byStatus", toCountMap(mapper.requirementsByStatus(projectId), "status")); + reqSection.put("recent", mapper.recentRequirements(projectId)); + result.put("requirements", reqSection); + + Map arrivalSection = new LinkedHashMap<>(); + arrivalSection.put("byStatus", toCountMap(mapper.arrivalsByStatus(projectId), "detail_status")); + result.put("arrivals", arrivalSection); + + // 5. 库房 + Map whSection = new LinkedHashMap<>(); + whSection.put("byStatus", toCountMap(mapper.warehouseByStatus(projectId), "status")); + whSection.put("recent", mapper.recentWarehouseRequests(projectId)); + result.put("warehouse", whSection); + + // 6. 财务(不假装是会计利润,只做减法) + Map finSection = new LinkedHashMap<>(); + BigDecimal contractAmount = sumContractPrice(contracts); + BigDecimal paymentDone = nz(mapper.sumPaymentDone(projectId)); + BigDecimal paymentTotal = nz(mapper.sumPaymentTotal(projectId)); + BigDecimal cost = nz(mapper.sumCost(projectId)); + finSection.put("contractAmount", contractAmount); + finSection.put("paymentDone", paymentDone); + finSection.put("paymentTotal", paymentTotal); + finSection.put("paymentRemain", paymentTotal.subtract(paymentDone)); + finSection.put("cost", cost); + finSection.put("netCash", paymentDone.subtract(cost)); + finSection.put("byFinanceType", toCountMap(mapper.financeByType(projectId), "finance_type")); + result.put("finance", finSection); + + // 7. 任务 + Map taskSection = new LinkedHashMap<>(); + taskSection.put("byState", toCountMap(mapper.tasksByState(projectId), "state")); + Integer overdue = mapper.countOverdueTasks(projectId); + taskSection.put("overdueCount", overdue == null ? 0 : overdue); + taskSection.put("recent", mapper.recentTasks(projectId)); + result.put("tasks", taskSection); + + // 8. 团队 + Map teamSection = new LinkedHashMap<>(); + teamSection.put("functionary", header.get("functionary")); + teamSection.put("functionaryNick", header.get("functionary_nick")); + List workerIds = mapper.taskWorkers(projectId); + List> members = workerIds == null || workerIds.isEmpty() + ? Collections.emptyList() : mapper.usersByIds(workerIds); + teamSection.put("memberCount", members.size()); + teamSection.put("members", members); + result.put("team", teamSection); + + // 9. 报告 / 会议 / 操作日志 + result.put("reports", mapper.recentReports(projectId)); + result.put("meetings", mapper.recentMeetings(projectId)); + result.put("operationLog", mapper.recentOpLog(projectId)); + + // 9.5 fad_rm 制造主线 + List> install = mapper.installProgress(projectId); + List> commissioning = mapper.commissioningRecords(projectId); + List> checklist = mapper.acceptanceChecklist(projectId); + List> acceptItems = mapper.acceptanceItems(projectId); + if (!install.isEmpty() || !commissioning.isEmpty() || !checklist.isEmpty() || !acceptItems.isEmpty()) { + Map mfg = new LinkedHashMap<>(); + mfg.put("install", install); + mfg.put("commissioning", commissioning); + mfg.put("checklist", checklist); + mfg.put("checklistDone", checklist.stream() + .filter(c -> "Y".equals(String.valueOf(c.get("is_checked"))) || "1".equals(String.valueOf(c.get("is_checked")))) + .count()); + mfg.put("checklistTotal", checklist.size()); + mfg.put("acceptItems", acceptItems); + result.put("manufacturing", mfg); + } + + // 10. 审批时间线(合同 + 采购需求的所有审批单合并) + List contractIds = new ArrayList<>(); + for (Map c : contracts) { + Long id = parseLong(c.get("contract_id")); + if (id != null) contractIds.add(id); + } + List> recentReqs = (List>) reqSection.get("recent"); + List reqIds = new ArrayList<>(); + if (recentReqs != null) { + for (Map r : recentReqs) { + Long id = parseLong(r.get("requirement_id")); + if (id != null) reqIds.add(id); + } + } + List> filters = new ArrayList<>(); + if (!contractIds.isEmpty()) { + Map f = new HashMap<>(); + f.put("type", "contract"); + f.put("ids", contractIds); + filters.add(f); + } + if (!reqIds.isEmpty()) { + Map f = new HashMap<>(); + f.put("type", "purchase_req"); + f.put("ids", reqIds); + filters.add(f); + } + List> timeline = filters.isEmpty() + ? Collections.emptyList() : mapper.approvalTimeline(filters); + result.put("approvals", timeline); + + // 11. 健康度评分 + 一句话总结(傻子领导友好) + result.put("health", computeHealth(result)); + + return result; + } + + @Override + public Map dashboard(String status, String nameLike, Integer limit) { + Map r = new LinkedHashMap<>(); + r.put("summary", mapper.dashboardSummary()); + List> projects = mapper.dashboardProjects(status, nameLike, + limit == null ? 50 : Math.min(200, limit)); + // 每个项目算个轻量级健康度 + for (Map p : projects) { + p.put("health", computeDashboardRowHealth(p)); + } + r.put("projects", projects); + return r; + } + + /** 全景页健康度:根据多维度信号聚合 */ + @SuppressWarnings("unchecked") + private Map computeHealth(Map data) { + Map h = new LinkedHashMap<>(); + List red = new ArrayList<>(); + List yellow = new ArrayList<>(); + + Map header = (Map) data.get("header"); + Map schedule = (Map) data.get("schedule"); + Map finance = (Map) data.get("finance"); + Map tasks = (Map) data.get("tasks"); + List> approvals = (List>) data.get("approvals"); + + // 红灯:工期超期且未完结 + if (header != null) { + Object end = header.get("finish_time"); + Object pstatus = header.get("project_status"); + if (end instanceof Date && !"1".equals(String.valueOf(pstatus)) + && ((Date) end).before(new Date())) { + red.add("项目已过工期但未标记完结"); + } + Object postpone = header.get("postpone_count"); + if (postpone instanceof Number && ((Number) postpone).intValue() >= 2) { + yellow.add("项目已延期 " + postpone + " 次"); + } + } + // 进度超期 + if (schedule != null && Boolean.TRUE.equals(schedule.get("overdue"))) { + red.add("进度计划超期"); + } + // 任务超期 + if (tasks != null) { + Object overdueCount = tasks.get("overdueCount"); + if (overdueCount instanceof Number) { + int n = ((Number) overdueCount).intValue(); + if (n >= 5) red.add(n + " 个任务超期"); + else if (n >= 1) yellow.add(n + " 个任务超期"); + } + } + // 待审批堆积 + int pendingApprovals = 0; + if (approvals != null) { + for (Map a : approvals) { + Object s = a.get("status"); + if (s instanceof Number && ((Number) s).intValue() == 0) pendingApprovals++; + } + if (pendingApprovals >= 5) red.add(pendingApprovals + " 张审批单待办堆积"); + else if (pendingApprovals >= 1) yellow.add(pendingApprovals + " 张审批单待办"); + } + // 净现金为负 + if (finance != null) { + BigDecimal net = nzBd(finance.get("netCash")); + BigDecimal cost = nzBd(finance.get("cost")); + if (net.signum() < 0 && cost.signum() > 0) { + yellow.add("当前已支出多于已收款 ¥" + net.abs().toPlainString()); + } + } + + String level; + String oneLiner; + if (!red.isEmpty()) { + level = "red"; + oneLiner = String.join(";", red); + } else if (!yellow.isEmpty()) { + level = "yellow"; + oneLiner = String.join(";", yellow); + } else { + level = "green"; + oneLiner = ""; + } + h.put("level", level); + h.put("redSignals", red); + h.put("yellowSignals", yellow); + h.put("oneLiner", oneLiner); + return h; + } + + /** 集团驾驶舱单行的轻量健康度:只看几个核心信号 */ + private Map computeDashboardRowHealth(Map p) { + Map h = new LinkedHashMap<>(); + List reasons = new ArrayList<>(); + String level = "green"; + Object end = p.get("finish_time"); + Object pstatus = p.get("project_status"); + boolean running = !"1".equals(String.valueOf(pstatus)); + if (running && end instanceof Date && ((Date) end).before(new Date())) { + level = "red"; + reasons.add("已过工期"); + } + Object overdueTasks = p.get("overdue_tasks"); + if (overdueTasks instanceof Number && ((Number) overdueTasks).intValue() >= 5) { + if (!"red".equals(level)) level = "red"; + reasons.add(overdueTasks + " 个任务超期"); + } else if (overdueTasks instanceof Number && ((Number) overdueTasks).intValue() >= 1) { + if ("green".equals(level)) level = "yellow"; + reasons.add(overdueTasks + " 个任务超期"); + } + Object pendingApprovals = p.get("pending_approvals"); + if (pendingApprovals instanceof Number && ((Number) pendingApprovals).intValue() >= 5) { + if (!"red".equals(level)) level = "red"; + reasons.add(pendingApprovals + " 张审批堆积"); + } else if (pendingApprovals instanceof Number && ((Number) pendingApprovals).intValue() >= 1) { + if ("green".equals(level)) level = "yellow"; + reasons.add(pendingApprovals + " 张审批待办"); + } + BigDecimal paymentDone = nzBd(p.get("payment_done")); + BigDecimal cost = nzBd(p.get("cost")); + if (cost.signum() > 0 && paymentDone.subtract(cost).signum() < 0) { + if ("green".equals(level)) level = "yellow"; + reasons.add("成本>收款"); + } + h.put("level", level); + h.put("reasons", reasons); + return h; + } + + /** 把 Object 容错解析成 Long(数字 / 字符串都能) */ + private static Long parseLong(Object o) { + if (o == null) return null; + if (o instanceof Number) return ((Number) o).longValue(); + try { return Long.parseLong(o.toString().trim()); } catch (Exception e) { return null; } + } + + private static BigDecimal nzBd(Object o) { + if (o == null) return BigDecimal.ZERO; + if (o instanceof BigDecimal) return (BigDecimal) o; + if (o instanceof Number) return BigDecimal.valueOf(((Number) o).doubleValue()); + try { return new BigDecimal(o.toString()); } catch (Exception e) { return BigDecimal.ZERO; } + } + + // ============ 辅助 ============ + + private static BigDecimal nz(BigDecimal v) { + return v == null ? BigDecimal.ZERO : v; + } + + private static Map toCountMap(List> rows, String keyCol) { + Map r = new LinkedHashMap<>(); + if (rows == null) return r; + for (Map row : rows) { + r.put(row.get(keyCol), row.get("c")); + } + return r; + } + + private static BigDecimal sumContractPrice(List> contracts) { + if (contracts == null || contracts.isEmpty()) return BigDecimal.ZERO; + BigDecimal sum = BigDecimal.ZERO; + for (Map c : contracts) { + Object price = c.get("contract_price"); + if (price == null) continue; + try { + sum = sum.add(new BigDecimal(price.toString())); + } catch (NumberFormatException ignored) {} + } + return sum; + } + + private static boolean isStepDone(Object status) { + if (!(status instanceof Number)) return false; + int s = ((Number) status).intValue(); + // 约定:1 完成 / 2 完结(具体跟 oa_project_schedule_step 业务保持一致) + return s == 1 || s == 2; + } + + private static boolean isScheduleOverdue(Map schedule) { + Object end = schedule.get("end_time"); + if (!(end instanceof Date)) return false; + Object st = schedule.get("status"); + // 已完结的不算超期 + if (st instanceof Number && ((Number) st).intValue() == 2) return false; + return ((Date) end).before(new Date()); + } +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/task/OverdueScanScheduler.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/task/OverdueScanScheduler.java new file mode 100644 index 0000000..4bd98f9 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/task/OverdueScanScheduler.java @@ -0,0 +1,131 @@ +package com.ruoyi.oa.task; + +import com.ruoyi.oa.im.ImSendService; +import com.ruoyi.oa.mapper.OaPostponeRecordMapper; +import com.ruoyi.oa.service.IOaPerformanceService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 每天扫一次所有"我名下超期"事项: + * - 超期 ≥ 7 天 且 < 30 天 → 每天给 owner 发 IM 一次(提醒) + * - 超期 ≥ 30 天 → 给 owner 发 IM "已通知高总";给高伟发 IM 一次;扣 owner 20 分(dedup_key 唯一,一次性) + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class OverdueScanScheduler { + + // 高伟(老总) + private static final Long BOSS_USER_ID = 1859252208375152641L; + + private final OaPostponeRecordMapper postponeMapper; + private final ImSendService imSendService; + private final IOaPerformanceService performanceService; + + /** 每天上午 9:00 跑一次 */ + @Scheduled(cron = "0 0 9 * * ?") + public void run() { + log.info("[OverdueScan] start"); + int reminded = 0, escalated = 0, deducted = 0; + Set bossSentDedup = new HashSet<>(); // 避免单天多次提同一事项给老板 + + // 复用 mapper.listOverdueForUser 拿不到全员,单独写一个全员 SQL + // 直接在这里用现成 mapper 各类型的"任意人"超期查询 + List> all = scanAll(); + long now = System.currentTimeMillis(); + + for (Map item : all) { + Object ownerObj = item.get("owner_id"); + Object owner2Obj = item.get("owner_id2"); + String type = String.valueOf(item.get("business_type")); + Object idObj = item.get("business_id"); + String title = String.valueOf(item.get("business_title")); + Object deadlineObj = item.get("deadline"); + if (!(deadlineObj instanceof Date)) continue; + if (!(idObj instanceof Number)) continue; + long bizId = ((Number) idObj).longValue(); + long deadline = ((Date) deadlineObj).getTime(); + long days = Math.max(1, (now - deadline) / 86400000L); + + // owner 列表(去重) + Set owners = new HashSet<>(); + if (ownerObj instanceof Number) owners.add(((Number) ownerObj).longValue()); + if (owner2Obj instanceof Number) owners.add(((Number) owner2Obj).longValue()); + if (owners.isEmpty()) continue; + + String label = typeLabel(type); + for (Long owner : owners) { + if (days >= 30) { + // ① 给员工:你的事已超期 N 天,已通知高总 + imSendService.sendToOaUser(owner, + "你有事项超期 " + days + " 天", + "【" + label + "】" + safe(title) + " 已超期 " + days + " 天,已通知高总。请尽快处理。", + type, bizId, frontRoute(type)); + // ② 给高总:通报 + String bossKey = type + ":" + bizId; + if (bossSentDedup.add(bossKey)) { + imSendService.sendToOaUser(BOSS_USER_ID, + "[超期通报] " + label + " 超期 " + days + " 天", + "员工 #" + owner + " 名下的【" + label + "】" + safe(title) + + " 已超期 " + days + " 天,请尽快联系完成。", + type, bizId, frontRoute(type)); + escalated++; + } + // ③ 一次性扣 20 分,dedup_key 同业务只扣一次(不叠加) + boolean ok = performanceService.addDeduction(owner, null, + "overdue30", type, bizId, + 20, + "事项超期 ≥ 30 天:" + safe(title), + "overdue30_" + type + "_" + bizId); + if (ok) deducted++; + } else if (days >= 7) { + // 仅每日提醒 + imSendService.sendToOaUser(owner, + "事项超期提醒", + "【" + label + "】" + safe(title) + " 已超期 " + days + " 天,请尽快处理。" + + " 注:超过 30 天会自动通知高总并扣 20 分。", + type, bizId, frontRoute(type)); + reminded++; + } + } + } + log.info("[OverdueScan] done: reminded={} escalated={} deducted20={}", reminded, escalated, deducted); + } + + /** 复用 OaPostponeRecordMapper 的逻辑但去掉 owner 过滤 —— 直接执行一次模拟全量扫描 */ + private List> scanAll() { + return postponeMapper.scanAllOverdue(); + } + + private String typeLabel(String t) { + switch (t) { + case "task": return "任务"; + case "step": return "进度步骤"; + case "requirement": return "采购需求"; + default: return t; + } + } + + private String frontRoute(String t) { + switch (t) { + case "task": return "/task/task"; + case "step": return "/step/step"; + case "requirement": return "/hint/requirement"; + default: return "/"; + } + } + + private String safe(String s) { + if (s == null || "null".equals(s)) return ""; + return s.length() > 40 ? s.substring(0, 40) + "…" : s; + } +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/task/PerformanceRewardScheduler.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/task/PerformanceRewardScheduler.java new file mode 100644 index 0000000..ab3b8ca --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/task/PerformanceRewardScheduler.java @@ -0,0 +1,277 @@ +package com.ruoyi.oa.task; + +import com.ruoyi.oa.service.IOaPerformanceService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; +import java.util.List; +import java.util.Map; + +/** + * 自动加分(鼓励正向行为): + * - 完成任务 +1/条 上限 +10/月 + * - 完成步骤 +1/步 上限 +6/月 + * - 报工日报 +1/份 上限 +6/月 + * - 出差登记 +1/天 上限 +6/月 + * - 月度全勤 +3 上限 +3/月(月初算上月) + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PerformanceRewardScheduler { + + private final JdbcTemplate jdbc; + private final IOaPerformanceService perfService; + + /** 每天 9:30 扫昨天的事(加分 + 逾期日扣) */ + @Scheduled(cron = "0 30 9 * * ?") + public void runDaily() { + Date yesterday = yesterday(); + String dateStr = new SimpleDateFormat("yyyy-MM-dd").format(yesterday); + log.info("[Reward] daily scan for {}", dateStr); + + // 正向加分(看昨天完成 / 提交的) + scanTaskComplete(dateStr); + scanStepComplete(dateStr); + scanReport(dateStr); + scanTrip(dateStr); + + // 逾期日扣(基于"今天"判定还在逾期的) + String todayStr = new SimpleDateFormat("yyyy-MM-dd").format(new Date()); + scanOverdueDaily(todayStr); + } + + /** + * 当天 21:00 检测今天有没有报工。没报工的扣 1 分。 + * 粘性:dedup_key 含日期,事后补录不退分。 + */ + @Scheduled(cron = "0 0 21 * * ?") + public void runEveningReportCheck() { + Date today = new Date(); + String dateStr = new SimpleDateFormat("yyyy-MM-dd").format(today); + log.info("[Reward] 21:00 report check for {}", dateStr); + scanNoReport(dateStr, today); + } + + /** 每月 1 号 8 点算上个月全勤 */ + @Scheduled(cron = "0 0 8 1 * ?") + public void runMonthlyAttendance() { + Calendar c = Calendar.getInstance(); + c.add(Calendar.MONTH, -1); + String prevPeriod = new SimpleDateFormat("yyyy-MM").format(c.getTime()); + log.info("[Reward] monthly attendance for {}", prevPeriod); + scanAttendance(prevPeriod, c); + } + + private void scanTaskComplete(String dateStr) { + List> rows = jdbc.queryForList( + "SELECT task_id, worker_id, task_title FROM sys_oa_task " + + "WHERE state = 2 AND completed_time IS NOT NULL " + + "AND DATE(completed_time) = ? AND worker_id IS NOT NULL", + dateStr); + for (Map r : rows) { + Long uid = num(r.get("worker_id")); + Long tid = num(r.get("task_id")); + String title = String.valueOf(r.get("task_title")); + perfService.tryReward(uid, null, "reward_task", "task", tid, + 1, "完成任务:" + safe(title), + "reward_task_" + tid, 10); + } + } + + private void scanStepComplete(String dateStr) { + List> rows = jdbc.queryForList( + "SELECT s.track_id, s.step_name, " + + " COALESCE((SELECT user_id FROM sys_user WHERE nick_name = sch.steward AND status='0' LIMIT 1), " + + " (SELECT user_id FROM sys_user WHERE nick_name = p.functionary AND status='0' LIMIT 1)) AS owner_id " + + "FROM oa_project_schedule_step s " + + "JOIN oa_project_schedule sch ON sch.schedule_id = s.schedule_id " + + "LEFT JOIN sys_oa_project p ON p.project_id = sch.project_id " + + "WHERE s.status = 2 AND s.del_flag = '0' " + + "AND DATE(COALESCE(s.actual_end, s.update_time)) = ?", + dateStr); + for (Map r : rows) { + Long uid = num(r.get("owner_id")); + Long sid = num(r.get("track_id")); + String name = String.valueOf(r.get("step_name")); + if (uid == null || sid == null) continue; + perfService.tryReward(uid, null, "reward_step", "step", sid, + 1, "完成步骤:" + safe(name), + "reward_step_" + sid, 6); + } + } + + private void scanReport(String dateStr) { + List> rows = jdbc.queryForList( + "SELECT report_id, user_id, content FROM oa_project_report " + + "WHERE del_flag = 0 AND DATE(create_time) = ?", + dateStr); + for (Map r : rows) { + Long uid = num(r.get("user_id")); + Long rid = num(r.get("report_id")); + if (uid == null || rid == null) continue; + perfService.tryReward(uid, null, "reward_report", null, rid, + 1, "提交工作日报", + "reward_report_" + rid, 6); + } + } + + private void scanTrip(String dateStr) { + // 出差:oa_project_report.is_trip > 0 + // 同一人同一天最多算一次 + List> rows = jdbc.queryForList( + "SELECT DISTINCT user_id FROM oa_project_report " + + "WHERE del_flag = 0 AND is_trip > 0 AND DATE(create_time) = ?", + dateStr); + for (Map r : rows) { + Long uid = num(r.get("user_id")); + if (uid == null) continue; + perfService.tryReward(uid, null, "reward_trip", null, null, + 1, "当天出差 " + dateStr, + "reward_trip_" + uid + "_" + dateStr, 6); + } + } + + /** + * 「没报工」扣分: + * - 仅工作日触发(周一~周五) + * - 只扣"近 30 天提交过报工"的活跃用户(避免误伤不需要报工的人) + * - dedup_key 含日期,补录不能退回 + */ + private void scanNoReport(String dateStr, Date date) { + Calendar cal = Calendar.getInstance(); + cal.setTime(date); + int dow = cal.get(Calendar.DAY_OF_WEEK); + if (dow == Calendar.SATURDAY || dow == Calendar.SUNDAY) { + log.info("[Reward] skip noreport scan on weekend {}", dateStr); + return; + } + // 近 30 天交过报工 = 活跃用户 + List> active = jdbc.queryForList( + "SELECT DISTINCT user_id FROM oa_project_report " + + "WHERE del_flag = 0 AND create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)"); + for (Map a : active) { + Long uid = num(a.get("user_id")); + if (uid == null) continue; + // 当天有没有提交过 + Integer cnt = jdbc.queryForObject( + "SELECT COUNT(*) FROM oa_project_report " + + "WHERE del_flag = 0 AND user_id = ? AND DATE(create_time) = ?", + Integer.class, uid, dateStr); + if (cnt != null && cnt > 0) continue; + // 扣 1 分,dedup_key 唯一(补录不退) + perfService.addDeduction(uid, null, + "noreport", null, null, + 1, "未提交 " + dateStr + " 工作日报", + "noreport_" + uid + "_" + dateStr); + } + } + + /** + * 每日逾期扣分:扫当前仍处于逾期未解决状态的事项; + * 每个事项每天扣 owner -1,dedup_key=overdue_daily_{type}_{id}_{today} 同业务同日唯一。 + * 无月度封顶。 + */ + private void scanOverdueDaily(String todayDateStr) { + List> rows = jdbc.queryForList( + "SELECT * FROM ( " + + " SELECT 'task' AS biz, t.task_id AS bid, t.task_title AS title, " + + " t.create_user_id AS owner1, t.worker_id AS owner2 " + + " FROM sys_oa_task t WHERE (t.del_flag=0 OR t.del_flag IS NULL) " + + " AND t.state IN (0,15) AND t.finish_time IS NOT NULL AND t.finish_time < NOW() " + + " UNION ALL " + + " SELECT 'step', s.track_id, s.step_name, " + + " COALESCE( " + + " (SELECT user_id FROM sys_user WHERE nick_name=sch.steward AND status='0' LIMIT 1), " + + " (SELECT user_id FROM sys_user WHERE nick_name=p.functionary AND status='0' LIMIT 1) " + + " ), NULL " + + " FROM oa_project_schedule_step s " + + " JOIN oa_project_schedule sch ON sch.schedule_id=s.schedule_id " + + " LEFT JOIN sys_oa_project p ON p.project_id=sch.project_id " + + " WHERE s.del_flag='0' AND (s.status IS NULL OR s.status != 2) " + + " AND COALESCE(s.end_time, s.plan_end) IS NOT NULL " + + " AND COALESCE(s.end_time, s.plan_end) < NOW() " + + " UNION ALL " + + " SELECT 'requirement', r.requirement_id, r.title, " + + " (SELECT user_id FROM sys_user WHERE user_name=r.create_by AND status='0' LIMIT 1), " + + " r.owner_id " + + " FROM oa_requirements r WHERE r.del_flag=0 AND r.status IN (0,1) AND r.deadline < NOW() " + + ") x " + + // 排除已经在等顺延审批 或 已经顺延还未到期 的 + "WHERE NOT EXISTS (SELECT 1 FROM oa_postpone_record p WHERE p.business_type=x.biz AND p.business_id=x.bid AND p.del_flag=0 AND p.status=0) " + + " AND NOT EXISTS (SELECT 1 FROM oa_postpone_record p2 WHERE p2.business_type=x.biz AND p2.business_id=x.bid AND p2.del_flag=0 AND p2.status=1 AND p2.new_deadline > NOW())"); + for (Map r : rows) { + String biz = String.valueOf(r.get("biz")); + Long bid = num(r.get("bid")); + String title = String.valueOf(r.get("title")); + java.util.Set owners = new java.util.HashSet<>(); + Long o1 = num(r.get("owner1")); + Long o2 = num(r.get("owner2")); + if (o1 != null) owners.add(o1); + if (o2 != null) owners.add(o2); + if (bid == null) continue; + for (Long owner : owners) { + perfService.addDeduction(owner, null, + "overdue_daily", biz, bid, + 1, "[" + label(biz) + "] " + safe(title) + " 当日仍逾期", + "overdue_daily_" + biz + "_" + bid + "_" + todayDateStr); + } + } + } + + private String label(String t) { + switch (t) { + case "task": return "任务"; + case "step": return "进度步骤"; + case "requirement": return "采购需求"; + default: return t; + } + } + + private void scanAttendance(String period, Calendar monthCal) { + // 简化判定:当月 sys_oa_attendance 记录天数 >= 18 即视为全勤 + int year = monthCal.get(Calendar.YEAR); + int month = monthCal.get(Calendar.MONTH) + 1; + Integer min = year * 10000 + month * 100; + Integer max = year * 10000 + month * 100 + 31; + List> rows = jdbc.queryForList( + "SELECT user_id, COUNT(DISTINCT attendance_day) AS days FROM sys_oa_attendance " + + "WHERE del_flag = 0 AND attendance_day BETWEEN ? AND ? GROUP BY user_id", + min, max); + for (Map r : rows) { + Long uid = num(r.get("user_id")); + int days = ((Number) r.get("days")).intValue(); + if (uid == null || days < 18) continue; + perfService.tryReward(uid, null, "reward_attend", null, null, + 3, "月度全勤(出勤 " + days + " 天)", + "reward_attend_" + uid + "_" + period, 3); + } + } + + private static Long num(Object o) { + if (o == null) return null; + if (o instanceof Number) return ((Number) o).longValue(); + try { return Long.parseLong(o.toString()); } catch (Exception e) { return null; } + } + + private static Date yesterday() { + Calendar c = Calendar.getInstance(); + c.add(Calendar.DATE, -1); + c.set(Calendar.HOUR_OF_DAY, 0); c.set(Calendar.MINUTE, 0); + c.set(Calendar.SECOND, 0); c.set(Calendar.MILLISECOND, 0); + return c.getTime(); + } + + private static String safe(String s) { + if (s == null || "null".equals(s)) return ""; + return s.length() > 40 ? s.substring(0, 40) + "…" : s; + } +} diff --git a/ruoyi-ui/src/api/oa/performance.js b/ruoyi-ui/src/api/oa/performance.js new file mode 100644 index 0000000..5781cea --- /dev/null +++ b/ruoyi-ui/src/api/oa/performance.js @@ -0,0 +1,25 @@ +import request from '@/utils/request' + +export function myPerformance(period) { + return request({ url: '/oa/performance/mine', method: 'get', params: { period } }) +} +export function rankPerformance(query) { + return request({ url: '/oa/performance/rank', method: 'get', params: query }) +} +export function userPerformance(userId, period) { + return request({ url: '/oa/performance/of/' + userId, method: 'get', params: { period } }) +} +// 高总(或管理员)打主观分 0~40 +export function setSubjective(userId, userName, period, score) { + return request({ + url: '/oa/performance/subjective', method: 'post', + params: { userId, userName, period, score } + }) +} +// 手动加/扣分:points 正=扣 负=加 +export function manualAdjust(userId, userName, points, reason) { + return request({ + url: '/oa/performance/manual', method: 'post', + params: { userId, userName, points, reason } + }) +} diff --git a/ruoyi-ui/src/api/oa/postpone.js b/ruoyi-ui/src/api/oa/postpone.js new file mode 100644 index 0000000..d2fd7a8 --- /dev/null +++ b/ruoyi-ui/src/api/oa/postpone.js @@ -0,0 +1,24 @@ +import request from '@/utils/request' + +// 我名下所有未处理的超期 +export function listMyOverdue() { + return request({ url: '/oa/postpone/mine', method: 'get' }) +} + +export function postponeComplete(businessType, businessId) { + return request({ + url: '/oa/postpone/complete', method: 'post', + params: { businessType, businessId } + }) +} + +export function postponeCancel(businessType, businessId, reason) { + return request({ + url: '/oa/postpone/cancel', method: 'post', + params: { businessType, businessId, reason } + }) +} + +export function postpone(data) { + return request({ url: '/oa/postpone/postpone', method: 'post', data }) +} diff --git a/ruoyi-ui/src/api/oa/projectOverview.js b/ruoyi-ui/src/api/oa/projectOverview.js new file mode 100644 index 0000000..d16a786 --- /dev/null +++ b/ruoyi-ui/src/api/oa/projectOverview.js @@ -0,0 +1,18 @@ +import request from '@/utils/request' + +// 项目全景:一次拉完所有维度 +export function getProjectOverview(projectId) { + return request({ + url: '/oa/project/overview/' + projectId, + method: 'get' + }) +} + +// 集团驾驶舱:全公司项目一览 + 全局汇总 +export function getProjectDashboardOverview(query) { + return request({ + url: '/oa/project/overview/dashboard', + method: 'get', + params: query + }) +} diff --git a/ruoyi-ui/src/components/PerfWidget/index.vue b/ruoyi-ui/src/components/PerfWidget/index.vue new file mode 100644 index 0000000..fce4c3a --- /dev/null +++ b/ruoyi-ui/src/components/PerfWidget/index.vue @@ -0,0 +1,193 @@ + + + + + diff --git a/ruoyi-ui/src/components/Workbench/widgets/registry.js b/ruoyi-ui/src/components/Workbench/widgets/registry.js index 02f988f..1fec8fa 100644 --- a/ruoyi-ui/src/components/Workbench/widgets/registry.js +++ b/ruoyi-ui/src/components/Workbench/widgets/registry.js @@ -5,6 +5,7 @@ import Announcements from '@/components/Announcements/index.vue' import MiniCalendar from '@/components/MiniCalendar/index.vue' import QuickEntry from '@/components/QuickEntry/index.vue' +import PerfWidget from '@/components/PerfWidget/index.vue' import { ExpressQuestionList, FeedbackList, @@ -77,6 +78,11 @@ export const WIDGET_REGISTRY = { title: '快捷入口', component: QuickEntry, defaultSize: { w: 12, h: 4 } + }, + perf: { + title: '我的绩效', + component: PerfWidget, + defaultSize: { w: 4, h: 4 } } } diff --git a/ruoyi-ui/src/layout/components/OverdueGuard.vue b/ruoyi-ui/src/layout/components/OverdueGuard.vue new file mode 100644 index 0000000..9443d4f --- /dev/null +++ b/ruoyi-ui/src/layout/components/OverdueGuard.vue @@ -0,0 +1,376 @@ + + + + + diff --git a/ruoyi-ui/src/layout/index.vue b/ruoyi-ui/src/layout/index.vue index f3e845f..efe55da 100644 --- a/ruoyi-ui/src/layout/index.vue +++ b/ruoyi-ui/src/layout/index.vue @@ -13,6 +13,7 @@ + @@ -20,6 +21,7 @@ import RightPanel from '@/components/RightPanel' import { AppMain, Navbar, Settings, Sidebar, TagsView } from './components' import TutorialGuide from './components/TutorialGuide.vue' +import OverdueGuard from './components/OverdueGuard.vue' import ResizeMixin from './mixin/ResizeHandler' import { mapState } from 'vuex' import variables from '@/assets/styles/variables.scss' @@ -31,6 +33,7 @@ export default { Navbar, RightPanel, TutorialGuide, + OverdueGuard, Settings, Sidebar, TagsView, diff --git a/ruoyi-ui/src/views/oa/docs/panorama/index.vue b/ruoyi-ui/src/views/oa/docs/panorama/index.vue new file mode 100644 index 0000000..3ea4a26 --- /dev/null +++ b/ruoyi-ui/src/views/oa/docs/panorama/index.vue @@ -0,0 +1,227 @@ + + + + + diff --git a/ruoyi-ui/src/views/oa/docs/performance/index.vue b/ruoyi-ui/src/views/oa/docs/performance/index.vue new file mode 100644 index 0000000..8b3cd2a --- /dev/null +++ b/ruoyi-ui/src/views/oa/docs/performance/index.vue @@ -0,0 +1,258 @@ + + + + + diff --git a/ruoyi-ui/src/views/oa/performance/mine/index.vue b/ruoyi-ui/src/views/oa/performance/mine/index.vue new file mode 100644 index 0000000..a5eac0a --- /dev/null +++ b/ruoyi-ui/src/views/oa/performance/mine/index.vue @@ -0,0 +1,218 @@ + + + + + diff --git a/ruoyi-ui/src/views/oa/performance/rank/index.vue b/ruoyi-ui/src/views/oa/performance/rank/index.vue new file mode 100644 index 0000000..26db07e --- /dev/null +++ b/ruoyi-ui/src/views/oa/performance/rank/index.vue @@ -0,0 +1,521 @@ + + + + + diff --git a/ruoyi-ui/src/views/oa/project/dashboardOverview/index.vue b/ruoyi-ui/src/views/oa/project/dashboardOverview/index.vue new file mode 100644 index 0000000..660af3d --- /dev/null +++ b/ruoyi-ui/src/views/oa/project/dashboardOverview/index.vue @@ -0,0 +1,271 @@ + + + + + diff --git a/ruoyi-ui/src/views/oa/project/panorama/index.vue b/ruoyi-ui/src/views/oa/project/panorama/index.vue new file mode 100644 index 0000000..890ee69 --- /dev/null +++ b/ruoyi-ui/src/views/oa/project/panorama/index.vue @@ -0,0 +1,624 @@ + + + + + diff --git a/ruoyi-ui/src/views/oa/project/probox.vue b/ruoyi-ui/src/views/oa/project/probox.vue index 974708c..06682ea 100644 --- a/ruoyi-ui/src/views/oa/project/probox.vue +++ b/ruoyi-ui/src/views/oa/project/probox.vue @@ -179,6 +179,9 @@ 详情 + 全景 + 删除 @@ -922,6 +925,9 @@ export default { console.log(row) this.$router.push('/customer/detail/' + row.customerId); }, + handlePanorama (row) { + this.$router.push({ path: '/project/panorama', query: { projectId: row.projectId } }) + }, handleDetail (row) { this.loading = true; this.detailShow = true; diff --git a/sql/oa_performance.sql b/sql/oa_performance.sql new file mode 100644 index 0000000..2e2e777 --- /dev/null +++ b/sql/oa_performance.sql @@ -0,0 +1,40 @@ +-- ============================================================ +-- 个人绩效(与超期/顺延强制管理联动) +-- ============================================================ + +-- 月度绩效汇总(同一员工同一周期一行) +DROP TABLE IF EXISTS oa_performance_score; +CREATE TABLE oa_performance_score ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL, + user_name VARCHAR(64), + period VARCHAR(7) NOT NULL COMMENT '考核周期 YYYY-MM', + base_score INT NOT NULL DEFAULT 100, + deduction INT NOT NULL DEFAULT 0 COMMENT '本周期总扣分(正数)', + bonus INT NOT NULL DEFAULT 0 COMMENT '本周期加分(预留)', + total_score INT NOT NULL DEFAULT 100 COMMENT '= base - deduction + bonus', + grade VARCHAR(4) DEFAULT NULL COMMENT 'A+/A/A-/B+/B/B-/C+/C/C-/D', + create_time DATETIME DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_user_period (user_id, period) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='月度绩效汇总'; + +-- 每一笔扣/加分流水 +DROP TABLE IF EXISTS oa_performance_deduction; +CREATE TABLE oa_performance_deduction ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL, + user_name VARCHAR(64), + period VARCHAR(7) NOT NULL COMMENT 'YYYY-MM', + source VARCHAR(32) NOT NULL COMMENT 'postpone / overdue30 / manual', + source_type VARCHAR(32) DEFAULT NULL COMMENT 'task/step/requirement', + source_id BIGINT DEFAULT NULL COMMENT '业务表主键', + points INT NOT NULL COMMENT '正=扣 负=加', + reason VARCHAR(500), + create_time DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_user_period (user_id, period), + KEY idx_source (source_type, source_id), + UNIQUE KEY uk_dedup (user_id, source, source_type, source_id) COMMENT '同一来源同一业务只扣一次(针对 overdue30 等一次性扣分)' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='绩效流水'; diff --git a/sql/oa_postpone.sql b/sql/oa_postpone.sql new file mode 100644 index 0000000..3abc7ed --- /dev/null +++ b/sql/oa_postpone.sql @@ -0,0 +1,36 @@ +-- ============================================================ +-- 顺延强制管理 · 数据层 +-- ============================================================ + +DROP TABLE IF EXISTS oa_postpone_record; + +CREATE TABLE oa_postpone_record ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + business_type VARCHAR(64) NOT NULL COMMENT '业务类型 task / step / requirement', + business_id BIGINT NOT NULL COMMENT '业务表主键', + business_title VARCHAR(255) DEFAULT NULL COMMENT '业务名称(冗余展示)', + owner_id BIGINT NOT NULL COMMENT '事项 owner(顺延申请人)', + owner_name VARCHAR(64) DEFAULT NULL, + original_deadline DATETIME NOT NULL COMMENT '本次顺延前的截止时间', + new_deadline DATETIME NOT NULL COMMENT '申请的新截止时间', + reason VARCHAR(1024) NOT NULL COMMENT '顺延理由', + postpone_seq INT NOT NULL COMMENT '这是第几次顺延(含本次)', + need_approval TINYINT NOT NULL DEFAULT 0 COMMENT '0直接生效 1需要审批', + approval_instance_id BIGINT DEFAULT NULL COMMENT '审批单 id', + status TINYINT NOT NULL DEFAULT 1 COMMENT '0待审批 1已生效 2被驳回 3已撤回', + create_by VARCHAR(64) DEFAULT NULL, + create_time DATETIME DEFAULT CURRENT_TIMESTAMP, + update_by VARCHAR(64) DEFAULT NULL, + update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + del_flag TINYINT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + KEY idx_biz (business_type, business_id), + KEY idx_owner (owner_id), + KEY idx_inst (approval_instance_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='顺延记录(任务/步骤/采购需求 通用)'; + +-- 在审批配置里注册 3 个新业务(第 3 次及以后顺延会调 approvalService.submit 用到) +INSERT INTO oa_approval_config (business_type, business_name, approver_ids, sign_type, enabled, remark) VALUES + ('task_postpone', '任务顺延(第3次起)', '1', 1, 1, '默认 admin 占位,请改成项目负责人'), + ('step_postpone', '进度步骤顺延(第3次起)', '1', 1, 1, '默认 admin 占位,请改成项目负责人'), + ('req_postpone', '采购需求顺延(第3次起)', '1', 1, 1, '默认 admin 占位,请改成项目负责人'); diff --git a/sql/oa_project_panorama_menu.sql b/sql/oa_project_panorama_menu.sql new file mode 100644 index 0000000..bffed82 --- /dev/null +++ b/sql/oa_project_panorama_menu.sql @@ -0,0 +1,7 @@ +-- 项目全景 + 集团驾驶舱 菜单(部署到 prod 时执行) +INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query_param, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) VALUES +-- 项目顶级 (1744021537846177794) 下的两个新菜单 +(2063900003000001, '项目全景', 1744021537846177794, 5, 'panorama', 'oa/project/panorama/index', NULL, 1, 0, 'C', '0', '0', 'oa:project:panorama', 'chart', 'admin', NOW(), '项目全景页'), +(2063900003000002, '集团驾驶舱', 1744021537846177794, 1, 'cockpit', 'oa/project/dashboardOverview/index', NULL, 1, 0, 'C', '0', '0', 'oa:project:cockpit', 'monitor', 'admin', NOW(), '全公司项目一览'), +-- 说明 (2063900002000001) 下加项目全景流 +(2063900002000004, '项目全景流', 2063900002000001, 3, 'panorama', 'oa/docs/panorama/index', NULL, 1, 0, 'C', '0', '0', 'oa:docs:panorama', 'documentation', 'admin', NOW(), '项目全景使用说明');