添加了项目前景 绩效 审批配置做了一部分有点晕 我换换脑子继续这个 还有说明菜单

This commit is contained in:
2026-06-17 17:06:01 +08:00
parent 8ad3f2d7dd
commit 88c374952a
38 changed files with 4932 additions and 0 deletions

View File

@@ -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<Map<String, Object>> mine(@RequestParam(required = false) String period) {
return R.ok(service.myScore(LoginHelper.getUserId(), period));
}
/** 全员排名 */
@GetMapping("/rank")
public R<List<Map<String, Object>>> rank(@RequestParam(required = false) String period,
@RequestParam(required = false) String nameLike) {
return R.ok(service.rank(period, nameLike));
}
/** 看任意用户的绩效(管理员) */
@GetMapping("/of/{userId}")
public R<Map<String, Object>> of(@PathVariable Long userId,
@RequestParam(required = false) String period) {
return R.ok(service.myScore(userId, period));
}
/** 高总打主观分0-40权限由前端按 role/userId 控制 */
@PostMapping("/subjective")
public R<Void> 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<Void> 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();
}
}

View File

@@ -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<List<Map<String, Object>>> mine() {
return R.ok(service.listMine());
}
@PostMapping("/complete")
public R<Void> complete(@NotBlank @RequestParam String businessType,
@NotNull @RequestParam Long businessId) {
service.markComplete(businessType, businessId);
return R.ok();
}
@PostMapping("/cancel")
public R<Void> 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<Map<String, Object>> postpone(@Validated @RequestBody OaPostponeBo bo) {
return R.ok(service.postpone(bo));
}
}

View File

@@ -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<Map<String, Object>> overview(@PathVariable Long projectId) {
return R.ok(service.overview(projectId));
}
/** 集团驾驶舱:全公司项目一览 */
@GetMapping("/dashboard")
public R<Map<String, Object>> 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));
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<OaPerformanceDeduction> {
}

View File

@@ -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<OaPerformanceDeduction> 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("<script>SELECT CAST(u.user_id AS CHAR) AS user_id, u.nick_name AS user_nick, u.user_name, " +
" COALESCE(s.period, #{period}) AS period, " +
" COALESCE(s.base_score, 60) AS base_score, " +
" COALESCE(s.deduction, 0) AS deduction, " +
" COALESCE(s.reward, 0) AS reward, " +
" COALESCE(s.bonus, 0) AS bonus, " +
" COALESCE(s.subjective_set, 0) AS subjective_set, " +
" COALESCE(s.total_score, 80) AS total_score, " +
" COALESCE(s.grade, 'B+') AS grade " +
"FROM sys_user u " +
"LEFT JOIN oa_performance_score s ON s.user_id = u.user_id AND s.period = #{period} " +
"WHERE u.del_flag = '0' AND u.status = '0' " +
"<if test='nameLike != null and nameLike != \"\"'> AND (u.nick_name LIKE CONCAT('%', #{nameLike}, '%') OR u.user_name LIKE CONCAT('%', #{nameLike}, '%'))</if>" +
" ORDER BY COALESCE(s.total_score, 80) DESC, u.user_id ASC</script>")
List<Map<String, Object>> listAllScores(@Param("period") String period,
@Param("nameLike") String nameLike);
}

View File

@@ -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<OaPerformanceScore> {
}

View File

@@ -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<OaPostponeRecordMapper, OaPostponeRecord, OaPostponeRecord> {
/**
* 列出当前 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_nameowner_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<Map<String, Object>> 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<Map<String, Object>> scanAllOverdue();
}

View File

@@ -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 tinyintSQL 里手工处理。
*/
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<String, Object> 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<String, Object> 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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Long> taskWorkers(@Param("pid") Long pid);
@Select("<script>SELECT user_id, nick_name FROM sys_user WHERE user_id IN " +
"<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
List<Map<String, Object>> usersByIds(@Param("ids") List<Long> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> recentOpLog(@Param("pid") Long pid);
// ============ 审批时间线(跨业务汇总) ============
@Select("<script>SELECT CAST(i.id AS CHAR) AS id, i.business_type, CAST(i.business_id AS CHAR) AS business_id, " +
"i.business_title, i.apply_user_name, i.apply_time, i.status, i.finish_time, i.sign_type " +
"FROM oa_approval_instance i WHERE i.del_flag = 0 AND " +
"<foreach collection='filters' item='f' separator=' OR ' open='(' close=')'>" +
"(i.business_type = #{f.type} AND i.business_id IN " +
"<foreach collection='f.ids' item='bid' open='(' separator=',' close=')'>#{bid}</foreach>)" +
"</foreach> ORDER BY i.id DESC LIMIT 30</script>")
List<Map<String, Object>> approvalTimeline(@Param("filters") List<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> acceptanceItems(@Param("pid") Long pid);
// ============ 集团驾驶舱:项目列表 + 健康度信号 ============
// 注意BIGINT 在 Map 里序列化可能丢精度CAST 为字符串
@Select("<script>SELECT CAST(p.project_id AS CHAR) AS project_id, " +
"p.project_name, p.project_code, p.project_status, " +
"p.funds, p.begin_time, p.finish_time, p.postpone_count, p.functionary, " +
"p.functionary AS functionary_nick, " +
"(SELECT COUNT(*) FROM sys_oa_contract c WHERE c.project_id = p.project_id) AS contract_count, " +
"(SELECT COALESCE(SUM(amount),0) FROM oa_payment_progress " +
" WHERE project_id = p.project_id AND del_flag=0 AND complete=1) AS payment_done, " +
"(SELECT COALESCE(SUM(cost),0) FROM sys_oa_cost " +
" WHERE project_id = p.project_id AND (del_flag=0 OR del_flag IS NULL)) AS cost, " +
"(SELECT COUNT(*) FROM sys_oa_task " +
" WHERE project_id = p.project_id AND (del_flag=0 OR del_flag IS NULL) " +
" AND state IN (0,15) AND finish_time IS NOT NULL AND finish_time &lt; NOW()) AS overdue_tasks, " +
"(SELECT COUNT(*) FROM oa_approval_instance " +
" WHERE del_flag=0 AND status=0 AND " +
" ((business_type='contract' AND business_id IN (SELECT contract_id FROM sys_oa_contract WHERE project_id=p.project_id)) OR " +
" (business_type='purchase_req' AND business_id IN (SELECT requirement_id FROM oa_requirements WHERE project_id=p.project_id AND del_flag=0)))) AS pending_approvals " +
"FROM sys_oa_project p " +
"WHERE 1=1 " +
"<if test='status != null and status != \"\"'> AND p.project_status = #{status}</if>" +
"<if test='nameLike != null and nameLike != \"\"'> AND (p.project_name LIKE CONCAT('%',#{nameLike},'%') OR p.project_code LIKE CONCAT('%',#{nameLike},'%'))</if>" +
" ORDER BY p.is_top DESC, p.create_time DESC LIMIT #{limit}</script>")
List<Map<String, Object>> 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<String, Object> dashboardSummary();
// 同样的 functionary 是昵称问题,在 dashboardProjects 里 nick_name 直接用 p.functionary
// 但这里实际效果跟 dashboardProjects 里的 functionary_nick 字段保持兼容:
}

View File

@@ -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<String, Object> myScore(Long userId, String period);
/** 全员某月排名 */
List<Map<String, Object>> 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);
}

View File

@@ -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<Map<String, Object>> 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<String, Object> postpone(OaPostponeBo bo);
/**
* 由审批回调触发:根据已通过的 instanceId 找到对应 record把 new_deadline 应用到业务表
*/
void applyByInstance(Long instanceId);
/**
* 由审批回调触发:驳回时把 record 标记为 status=2
*/
void rejectByInstance(Long instanceId);
}

View File

@@ -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<String, Object> overview(Long projectId);
/** 集团驾驶舱:所有项目一览 + 全局汇总 */
Map<String, Object> dashboard(String status, String nameLike, Integer limit);
}

View File

@@ -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;
}

View File

@@ -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.<OaPerformanceDeduction>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<OaPerformanceDeduction> 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<String, Object> myScore(Long userId, String period) {
if (period == null) period = currentPeriod();
Map<String, Object> 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<Map<String, Object>> 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.<OaPerformanceDeduction>lambdaQuery()
.eq(OaPerformanceDeduction::getUserId, userId)
.eq(OaPerformanceDeduction::getPeriod, period)
.eq(OaPerformanceDeduction::getSource, source));
// 用 SQL 拿到 sum 更准,但实现简单先用 count*points 估计;这里用 selectList 再 sum
java.util.List<OaPerformanceDeduction> exist = dedMapper.selectList(Wrappers.<OaPerformanceDeduction>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";
}
}

View File

@@ -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<Map<String, Object>> 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<String, Object> 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<String, Object> 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());
}
// 绩效扣分(每次顺延 -1dedupKey 含序号避免误判重复)
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.<OaPostponeRecord>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.<OaPostponeRecord>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 是 LocalDateTimeplanEnd 是 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;
}
}

View File

@@ -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<String, Object> overview(Long projectId) {
if (projectId == null) throw new ServiceException("projectId 必填");
Map<String, Object> result = new LinkedHashMap<>();
// 1. 头信息 + 客户
Map<String, Object> 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<Map<String, Object>> contracts = mapper.listContracts(projectId);
result.put("contracts", contracts);
// 3. 进度
Map<String, Object> schedule = mapper.getSchedule(projectId);
Map<String, Object> scheduleSection = new LinkedHashMap<>();
if (schedule != null) {
Long sid = parseLong(schedule.get("schedule_id"));
if (sid != null) {
List<Map<String, Object>> 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<String, Object> reqSection = new LinkedHashMap<>();
reqSection.put("byStatus", toCountMap(mapper.requirementsByStatus(projectId), "status"));
reqSection.put("recent", mapper.recentRequirements(projectId));
result.put("requirements", reqSection);
Map<String, Object> arrivalSection = new LinkedHashMap<>();
arrivalSection.put("byStatus", toCountMap(mapper.arrivalsByStatus(projectId), "detail_status"));
result.put("arrivals", arrivalSection);
// 5. 库房
Map<String, Object> whSection = new LinkedHashMap<>();
whSection.put("byStatus", toCountMap(mapper.warehouseByStatus(projectId), "status"));
whSection.put("recent", mapper.recentWarehouseRequests(projectId));
result.put("warehouse", whSection);
// 6. 财务(不假装是会计利润,只做减法)
Map<String, Object> 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<String, Object> 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<String, Object> teamSection = new LinkedHashMap<>();
teamSection.put("functionary", header.get("functionary"));
teamSection.put("functionaryNick", header.get("functionary_nick"));
List<Long> workerIds = mapper.taskWorkers(projectId);
List<Map<String, Object>> 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<Map<String, Object>> install = mapper.installProgress(projectId);
List<Map<String, Object>> commissioning = mapper.commissioningRecords(projectId);
List<Map<String, Object>> checklist = mapper.acceptanceChecklist(projectId);
List<Map<String, Object>> acceptItems = mapper.acceptanceItems(projectId);
if (!install.isEmpty() || !commissioning.isEmpty() || !checklist.isEmpty() || !acceptItems.isEmpty()) {
Map<String, Object> 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<Long> contractIds = new ArrayList<>();
for (Map<String, Object> c : contracts) {
Long id = parseLong(c.get("contract_id"));
if (id != null) contractIds.add(id);
}
List<Map<String, Object>> recentReqs = (List<Map<String, Object>>) reqSection.get("recent");
List<Long> reqIds = new ArrayList<>();
if (recentReqs != null) {
for (Map<String, Object> r : recentReqs) {
Long id = parseLong(r.get("requirement_id"));
if (id != null) reqIds.add(id);
}
}
List<Map<String, Object>> filters = new ArrayList<>();
if (!contractIds.isEmpty()) {
Map<String, Object> f = new HashMap<>();
f.put("type", "contract");
f.put("ids", contractIds);
filters.add(f);
}
if (!reqIds.isEmpty()) {
Map<String, Object> f = new HashMap<>();
f.put("type", "purchase_req");
f.put("ids", reqIds);
filters.add(f);
}
List<Map<String, Object>> timeline = filters.isEmpty()
? Collections.emptyList() : mapper.approvalTimeline(filters);
result.put("approvals", timeline);
// 11. 健康度评分 + 一句话总结(傻子领导友好)
result.put("health", computeHealth(result));
return result;
}
@Override
public Map<String, Object> dashboard(String status, String nameLike, Integer limit) {
Map<String, Object> r = new LinkedHashMap<>();
r.put("summary", mapper.dashboardSummary());
List<Map<String, Object>> projects = mapper.dashboardProjects(status, nameLike,
limit == null ? 50 : Math.min(200, limit));
// 每个项目算个轻量级健康度
for (Map<String, Object> p : projects) {
p.put("health", computeDashboardRowHealth(p));
}
r.put("projects", projects);
return r;
}
/** 全景页健康度:根据多维度信号聚合 */
@SuppressWarnings("unchecked")
private Map<String, Object> computeHealth(Map<String, Object> data) {
Map<String, Object> h = new LinkedHashMap<>();
List<String> red = new ArrayList<>();
List<String> yellow = new ArrayList<>();
Map<String, Object> header = (Map<String, Object>) data.get("header");
Map<String, Object> schedule = (Map<String, Object>) data.get("schedule");
Map<String, Object> finance = (Map<String, Object>) data.get("finance");
Map<String, Object> tasks = (Map<String, Object>) data.get("tasks");
List<Map<String, Object>> approvals = (List<Map<String, Object>>) 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<String, Object> 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<String, Object> computeDashboardRowHealth(Map<String, Object> p) {
Map<String, Object> h = new LinkedHashMap<>();
List<String> 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<Object, Object> toCountMap(List<Map<String, Object>> rows, String keyCol) {
Map<Object, Object> r = new LinkedHashMap<>();
if (rows == null) return r;
for (Map<String, Object> row : rows) {
r.put(row.get(keyCol), row.get("c"));
}
return r;
}
private static BigDecimal sumContractPrice(List<Map<String, Object>> contracts) {
if (contracts == null || contracts.isEmpty()) return BigDecimal.ZERO;
BigDecimal sum = BigDecimal.ZERO;
for (Map<String, Object> 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<String, Object> 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());
}
}

View File

@@ -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<String> bossSentDedup = new HashSet<>(); // 避免单天多次提同一事项给老板
// 复用 mapper.listOverdueForUser 拿不到全员,单独写一个全员 SQL
// 直接在这里用现成 mapper 各类型的"任意人"超期查询
List<Map<String, Object>> all = scanAll();
long now = System.currentTimeMillis();
for (Map<String, Object> 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<Long> 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<Map<String, Object>> 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;
}
}

View File

@@ -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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> rows = jdbc.queryForList(
"SELECT report_id, user_id, content FROM oa_project_report " +
"WHERE del_flag = 0 AND DATE(create_time) = ?",
dateStr);
for (Map<String, Object> 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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<String, Object> 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 -1dedup_key=overdue_daily_{type}_{id}_{today} 同业务同日唯一。
* 无月度封顶。
*/
private void scanOverdueDaily(String todayDateStr) {
List<Map<String, Object>> 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<String, Object> 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<Long> 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<Map<String, Object>> 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<String, Object> 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;
}
}