yusm 5 hours ago
parent
commit
5f1c4e838e

+ 10 - 0
fhKeeper/formulahousekeeper/management-platform/src/main/java/com/management/platform/controller/ReportController.java

@@ -3761,6 +3761,16 @@ public class ReportController {
         return reportService.getWorkedProjectList(user.getCompanyId(), ymonth, startDate, endDate, pageIndex, pageSize);
     }
 
+    //各部门额外加班工时表(flag=true部门)
+    @RequestMapping("/getDashboardExtraOvertimeByDept")
+    public HttpRespMsg getDashboardExtraOvertimeByDept(String ymonth,
+            @RequestParam(required = false) String startDate,
+            @RequestParam(required = false) String endDate,
+            @RequestParam(required = false) String deptMode) {
+        User user = userMapper.selectById(request.getHeader("TOKEN"));
+        return reportService.getDashboardExtraOvertimeByDept(user.getCompanyId(), ymonth, startDate, endDate, deptMode);
+    }
+
     @RequestMapping("/getAllReport")
     public HttpRespMsg getAllReport(Integer companyId) {
         HttpRespMsg msg = new HttpRespMsg();

+ 4 - 0
fhKeeper/formulahousekeeper/management-platform/src/main/java/com/management/platform/mapper/UserCorpwxTimeMapper.java

@@ -30,4 +30,8 @@ public interface UserCorpwxTimeMapper extends BaseMapper<UserCorpwxTime> {
     List<Map<String, Object>> getMonthlyOvertimeByYear(@Param("year") String year, @Param("companyId") Integer companyId, @Param("corpwxUserIds") List<String> corpwxUserIds);
 
     List<Map<String, Object>> getMonthlyLeaveByYear(@Param("year") String year, @Param("companyId") Integer companyId, @Param("corpwxUserIds") List<String> corpwxUserIds);
+
+    List<Map<String, Object>> getOvertimeByCorpwxUserIdsInRange(@Param("companyId") Integer companyId,
+            @Param("startDate") String startDate, @Param("endDate") String endDate,
+            @Param("corpwxUserIds") List<String> corpwxUserIds);
 }

+ 4 - 0
fhKeeper/formulahousekeeper/management-platform/src/main/java/com/management/platform/service/ReportService.java

@@ -249,6 +249,10 @@ public interface ReportService extends IService<Report> {
 
     HttpRespMsg getWorkedProjectList(Integer companyId, String ymonth, String startDate, String endDate, Integer pageIndex, Integer pageSize);
 
+    HttpRespMsg getDashboardExtraOvertimeByDept(Integer companyId, String ymonth, String startDate, String endDate);
+
+    HttpRespMsg getDashboardExtraOvertimeByDept(Integer companyId, String ymonth, String startDate, String endDate, String deptMode);
+
     HttpRespMsg getAllReportListByToken(String json);
 
     HttpRespMsg batchDenyPassReport(String userId, String startDate, String endDate, String reason, HttpServletRequest request);

+ 212 - 0
fhKeeper/formulahousekeeper/management-platform/src/main/java/com/management/platform/service/impl/ReportServiceImpl.java

@@ -16543,6 +16543,198 @@ public class ReportServiceImpl extends ServiceImpl<ReportMapper, Report> impleme
         return msg;
     }
 
+    private static class FlagDepartmentScope {
+        private final List<Integer> scopedDeptIds;
+        private final Map<Integer, Integer> deptToFlagRoot;
+
+        private FlagDepartmentScope(List<Integer> scopedDeptIds, Map<Integer, Integer> deptToFlagRoot) {
+            this.scopedDeptIds = scopedDeptIds;
+            this.deptToFlagRoot = deptToFlagRoot;
+        }
+    }
+
+    private FlagDepartmentScope buildFlagDepartmentScope(Integer companyId) {
+        List<Department> allDeptList = departmentMapper.selectList(new QueryWrapper<Department>().eq("company_id", companyId));
+        List<Department> flagDepartments = allDeptList.stream()
+                .filter(department -> Boolean.TRUE.equals(department.getFlag()))
+                .collect(Collectors.toList());
+        Map<Integer, Integer> deptToFlagRoot = new HashMap<>();
+        List<Integer> scopedDeptIds = new ArrayList<>();
+        for (Department flagDept : flagDepartments) {
+            Integer flagDeptId = flagDept.getDepartmentId();
+            deptToFlagRoot.put(flagDeptId, flagDeptId);
+            scopedDeptIds.add(flagDeptId);
+            for (Department subDept : getSubDepts(flagDept, allDeptList)) {
+                deptToFlagRoot.put(subDept.getDepartmentId(), flagDeptId);
+                scopedDeptIds.add(subDept.getDepartmentId());
+            }
+        }
+        return new FlagDepartmentScope(scopedDeptIds, deptToFlagRoot);
+    }
+
+    @Override
+    public HttpRespMsg getDashboardExtraOvertimeByDept(Integer companyId, String ymonth, String startDate, String endDate) {
+        return getDashboardExtraOvertimeByDept(companyId, ymonth, startDate, endDate, null);
+    }
+
+    @Override
+    public HttpRespMsg getDashboardExtraOvertimeByDept(Integer companyId, String ymonth, String startDate, String endDate, String deptMode) {
+        HttpRespMsg msg = new HttpRespMsg();
+        DashboardDateFilter dateFilter = resolveDashboardDateFilter(ymonth, startDate, endDate, msg);
+        if (dateFilter == null) {
+            return msg;
+        }
+        boolean useTopLevel = "top".equals(deptMode);
+        FlagDepartmentScope scope = buildFlagDepartmentScope(companyId);
+        if (scope.scopedDeptIds.isEmpty()) {
+            msg.data = new ArrayList<>();
+            return msg;
+        }
+
+        List<HashMap> userList = userCorpwxTimeMapper.getExtraWorkHoursList(null, companyId, scope.scopedDeptIds, null);
+        if (userList.isEmpty()) {
+            msg.data = new ArrayList<>();
+            return msg;
+        }
+
+        List<String> corpwxUserIds = userList.stream()
+                .map(row -> row.get("corpwxUserid"))
+                .filter(Objects::nonNull)
+                .map(String::valueOf)
+                .filter(id -> !id.isEmpty() && !"null".equals(id))
+                .distinct()
+                .collect(Collectors.toList());
+        Map<String, Double> overtimeByCorpwxId = new HashMap<>();
+        if (!corpwxUserIds.isEmpty()) {
+            userCorpwxTimeMapper.getOvertimeByCorpwxUserIdsInRange(
+                    companyId,
+                    dateFilter.startDate.toString(),
+                    dateFilter.endDate.toString(),
+                    corpwxUserIds)
+                    .forEach(row -> overtimeByCorpwxId.put(
+                            String.valueOf(row.get("corpwxUserid")),
+                            toDouble(row.get("overtime"))));
+        }
+
+        List<Map<String, Object>> userRows = new ArrayList<>();
+        userList.forEach(row -> {
+            Integer deptId = toInteger(row.get("departmentId"));
+            if (deptId == null || !scope.deptToFlagRoot.containsKey(deptId)) {
+                return;
+            }
+            String userId = row.get("id") == null ? null : String.valueOf(row.get("id"));
+            String corpwxUserid = row.get("corpwxUserid") == null ? null : String.valueOf(row.get("corpwxUserid"));
+            double overtimeHours = corpwxUserid == null || "null".equals(corpwxUserid)
+                    ? 0D
+                    : overtimeByCorpwxId.getOrDefault(corpwxUserid, 0D);
+            Integer isActive = toInteger(row.get("isActive"));
+            HashMap<String, Object> userRow = new HashMap<>();
+            userRow.put("deptId", deptId);
+            userRow.put("userId", userId);
+            userRow.put("overtimeHours", overtimeHours);
+            userRow.put("countForAvg", shouldCountForExtraOvertimeAvg(
+                    isActive,
+                    toLocalDate(row.get("inactiveDate")),
+                    dateFilter.startDate));
+            userRows.add(userRow);
+        });
+
+        Map<Integer, Department> departmentMap = getDepartmentMap(companyId);
+        List<Map<String, Object>> aggregated;
+        if (useTopLevel) {
+            aggregated = rollupExtraOvertimeByFlagDept(userRows, scope.deptToFlagRoot);
+        } else {
+            aggregated = rollupExtraOvertimeByDirectDept(userRows);
+        }
+        msg.data = buildExtraOvertimeDeptResult(aggregated, departmentMap);
+        return msg;
+    }
+
+    private List<Map<String, Object>> rollupExtraOvertimeByFlagDept(List<Map<String, Object>> userRows, Map<Integer, Integer> deptToFlagRoot) {
+        Map<Integer, HashMap<String, Object>> grouped = new LinkedHashMap<>();
+        userRows.forEach(row -> {
+            Integer deptId = toInteger(row.get("deptId"));
+            Integer flagRootId = deptToFlagRoot.get(deptId);
+            if (flagRootId == null) {
+                return;
+            }
+            HashMap<String, Object> target = grouped.computeIfAbsent(flagRootId, key -> {
+                HashMap<String, Object> map = new HashMap<>();
+                map.put("deptId", flagRootId);
+                map.put("overtimeHours", 0D);
+                map.put("memberIds", new HashSet<String>());
+                return map;
+            });
+            target.put("overtimeHours", toDouble(target.get("overtimeHours")) + toDouble(row.get("overtimeHours")));
+            String userId = row.get("userId") == null ? null : String.valueOf(row.get("userId"));
+            if (!StringUtils.isEmpty(userId) && Boolean.TRUE.equals(row.get("countForAvg"))) {
+                ((Set<String>) target.get("memberIds")).add(userId);
+            }
+        });
+        grouped.values().forEach(row -> {
+            Set<String> memberIds = (Set<String>) row.remove("memberIds");
+            row.put("memberCount", memberIds != null ? memberIds.size() : 0);
+        });
+        return new ArrayList<>(grouped.values());
+    }
+
+    private List<Map<String, Object>> rollupExtraOvertimeByDirectDept(List<Map<String, Object>> userRows) {
+        Map<Integer, HashMap<String, Object>> grouped = new LinkedHashMap<>();
+        userRows.forEach(row -> {
+            Integer deptId = toInteger(row.get("deptId"));
+            HashMap<String, Object> target = grouped.computeIfAbsent(deptId, key -> {
+                HashMap<String, Object> map = new HashMap<>();
+                map.put("deptId", deptId);
+                map.put("overtimeHours", 0D);
+                map.put("memberIds", new HashSet<String>());
+                return map;
+            });
+            target.put("overtimeHours", toDouble(target.get("overtimeHours")) + toDouble(row.get("overtimeHours")));
+            String userId = row.get("userId") == null ? null : String.valueOf(row.get("userId"));
+            if (!StringUtils.isEmpty(userId) && Boolean.TRUE.equals(row.get("countForAvg"))) {
+                ((Set<String>) target.get("memberIds")).add(userId);
+            }
+        });
+        grouped.values().forEach(row -> {
+            Set<String> memberIds = (Set<String>) row.remove("memberIds");
+            row.put("memberCount", memberIds != null ? memberIds.size() : 0);
+        });
+        return new ArrayList<>(grouped.values());
+    }
+
+    /**
+     * 额外加班人均统计口径:查询区间内曾在职的员工计入。
+     * 在职员工全部计入;离职员工仅当离职日晚于等于区间开始日时计入。
+     */
+    private boolean shouldCountForExtraOvertimeAvg(Integer isActive, LocalDate inactiveDate, LocalDate rangeStart) {
+        if (isActive == null || isActive == 1) {
+            return true;
+        }
+        if (inactiveDate == null || rangeStart == null) {
+            return false;
+        }
+        return !inactiveDate.isBefore(rangeStart);
+    }
+
+    private List<HashMap> buildExtraOvertimeDeptResult(List<Map<String, Object>> deptReportList, Map<Integer, Department> departmentMap) {
+        List<HashMap> resultList = new ArrayList<>();
+        deptReportList.stream()
+                .sorted((left, right) -> Double.compare(toDouble(right.get("overtimeHours")), toDouble(left.get("overtimeHours"))))
+                .forEach(row -> {
+                    Integer deptId = toInteger(row.get("deptId"));
+                    Department department = departmentMap.get(deptId);
+                    double overtimeHours = roundHours(row.get("overtimeHours"));
+                    double memberCount = toDouble(row.get("memberCount"));
+                    HashMap<String, Object> map = new HashMap<>();
+                    fillDepartmentInfo(map, deptId, departmentMap);
+                    map.put("overtimeHours", overtimeHours);
+                    map.put("avgOvertimeHours", memberCount > 0D ? roundHours(overtimeHours / memberCount) : 0D);
+                    map.put("memberCount", memberCount);
+                    resultList.add(map);
+                });
+        return resultList;
+    }
+
     private String normalizeYearMonth(String ymonth) {
         if (StringUtils.isEmpty(ymonth)) {
             return LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM"));
@@ -16963,6 +17155,26 @@ public class ReportServiceImpl extends ServiceImpl<ReportMapper, Report> impleme
         return BigDecimal.valueOf(toDouble(value)).setScale(2, RoundingMode.HALF_UP).doubleValue();
     }
 
+    private LocalDate toLocalDate(Object value) {
+        if (value == null) {
+            return null;
+        }
+        if (value instanceof LocalDate) {
+            return (LocalDate) value;
+        }
+        if (value instanceof java.sql.Date) {
+            return ((java.sql.Date) value).toLocalDate();
+        }
+        if (value instanceof java.util.Date) {
+            return new java.sql.Date(((java.util.Date) value).getTime()).toLocalDate();
+        }
+        String str = String.valueOf(value);
+        if (StringUtils.isEmpty(str) || "null".equals(str)) {
+            return null;
+        }
+        return LocalDate.parse(str.length() > 10 ? str.substring(0, 10) : str);
+    }
+
     @Override
     public HttpRespMsg getAllReportListByToken(String json) {
         HttpRespMsg msg=new HttpRespMsg();

+ 1 - 1
fhKeeper/formulahousekeeper/management-platform/src/main/java/com/management/platform/util/CodeGenerator.java

@@ -92,7 +92,7 @@ public class CodeGenerator {
 
         // 数据源配置
         DataSourceConfig dsc = new DataSourceConfig();
-        dsc.setUrl("jdbc:mysql://1.94.62.58:17089/man_dev?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2B8");
+        dsc.setUrl("jdbc:mysql://1.94.62.58:17089/man_dev?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2B8&rewriteBatchedStatements=true&useSSL=false");
 //        dsc.setSchemaName("public");
         dsc.setDriverName("com.mysql.cj.jdbc.Driver");
         dsc.setUsername("root");

+ 16 - 1
fhKeeper/formulahousekeeper/management-platform/src/main/resources/mapper/UserCorpwxTimeMapper.xml

@@ -116,10 +116,25 @@
         group by corpwx_userid, DATE_FORMAT(create_date,'%m')
     </select>
 
+    <!--获取指定日期区间的额外加班工时汇总(按企微用户ID分组)-->
+    <select id="getOvertimeByCorpwxUserIdsInRange" resultType="java.util.Map">
+        select corpwx_userid as corpwxUserid, sum(ot_time) as overtime
+        from user_corpwx_time
+        where company_id = #{companyId}
+        and date(create_date) &gt;= #{startDate}
+        and date(create_date) &lt;= #{endDate}
+        and corpwx_userid in
+        <foreach item="item" collection="corpwxUserIds" separator="," open="(" close=")" index="">
+            #{item}
+        </foreach>
+        group by corpwx_userid
+    </select>
+
     <!--获取额外加班工时和调休工时表 - 用户基础信息查询 -->
     <select id="getExtraWorkHoursList" resultType="java.util.Map">
         select user.id, user.name, user.`department_id` as departmentId, department.`department_name` as departmentName,
-        user.`corpwx_userid` as corpwxUserid
+        user.`corpwx_userid` as corpwxUserid, user.`is_active` as isActive,
+        user.`inactive_date` as inactiveDate, user.`induction_date` as inductionDate
         from `user` left join department on department.`department_id` = user.`department_id`
         where user.`company_id` = #{companyId}
         <if test="userId != null and userId != ''">

+ 282 - 2
fhKeeper/formulahousekeeper/timesheet/src/views/dashboard/index.vue

@@ -202,6 +202,31 @@
         <div v-else ref="chart3" class="chart-body"></div>
       </div>
 
+      <!-- 图表10:各部门额外加班工时 -->
+      <div class="chart-card">
+        <div class="chart-title">
+          <span>各部门额外加班工时表</span>
+          <div class="chart-title-actions">
+            <el-radio-group
+              v-model="extraOvertimeTabMode"
+              size="mini"
+              :disabled="loading10"
+              @change="onExtraOvertimeTabChange"
+            >
+              <el-radio-button label="top">部门明细</el-radio-button>
+              <el-radio-button label="all">小组明细</el-radio-button>
+            </el-radio-group>
+          </div>
+        </div>
+        <div v-if="loading10" class="chart-loading">
+          <i class="el-icon-loading"></i> 加载中...
+        </div>
+        <div v-else-if="!hasExtraOvertimeData()" class="chart-empty">
+          暂无数据
+        </div>
+        <div v-else ref="chart10" class="chart-body"></div>
+      </div>
+
       <!-- 图表4:各部门参与项目数量 -->
       <div class="chart-card">
         <div class="chart-title">
@@ -422,11 +447,16 @@ export default {
       chart7: null,
       chart8: null,
       chart9: null,
+      chart10: null,
       top3PieMode: "working",
       top3DeptTabMode: "top",
       top3DeptDataAll: [],
       deptHoursTabMode: "top",
       deptHoursDataAll: [],
+      extraOvertimeTabMode: "top",
+      extraOvertimeDeptData: [],
+      extraOvertimeDeptDataAll: [],
+      loading10: false,
       userProjectTop10Data: [],
       loading9: false,
     };
@@ -448,7 +478,7 @@ export default {
       this.contentScroller.removeEventListener("scroll", this.handleContentScroll);
     }
     this.removeWorkedProjectScrollListener();
-    for (let i = 1; i <= 9; i++) {
+    for (let i = 1; i <= 10; i++) {
       const chart = this[`chart${i}`];
       if (chart) {
         chart.dispose();
@@ -827,13 +857,14 @@ export default {
       this.loadTop10Project();
       this.loadTop3ProjectDept();
       this.loadDeptHours();
+      this.loadExtraOvertimeByDept();
       this.loadDeptProjectCount();
       this.loadDashboardAnalysis();
       this.loadUserProjectTop10();
     },
 
     disposeDashboardCharts() {
-      for (let i = 1; i <= 9; i++) {
+      for (let i = 1; i <= 10; i++) {
         const chart = this[`chart${i}`];
         if (chart) {
           chart.dispose();
@@ -1004,6 +1035,71 @@ export default {
       });
     },
 
+    onExtraOvertimeTabChange() {
+      this.$nextTick(() => this.renderChart10());
+    },
+
+    hasExtraOvertimeData() {
+      const sourceData =
+        this.extraOvertimeTabMode === "top"
+          ? this.extraOvertimeDeptData
+          : this.extraOvertimeDeptDataAll;
+      return (sourceData || []).some((dept) => Number(dept.overtimeHours || 0) > 0);
+    },
+
+    loadExtraOvertimeByDept() {
+      const requestToken = this.getQueryToken();
+      this.loading10 = true;
+      this.extraOvertimeDeptData = [];
+      this.extraOvertimeDeptDataAll = [];
+
+      const loadMode = (deptMode, storeKey, callback) => {
+        this.http.post(
+          "/report/getDashboardExtraOvertimeByDept",
+          { ...this.getQueryParams(), deptMode },
+          (res) => {
+            if (!this.isCurrentQueryRequest(requestToken)) return;
+            if (res.code === "ok") {
+              const data = res.data || [];
+              const processData = (translated) => {
+                this[storeKey] = translated;
+                if (callback) callback();
+              };
+              if (this.needWxOpenData && data.length > 0) {
+                this.translateDeptNames(data, "departmentName", processData);
+              } else {
+                processData(data);
+              }
+            } else {
+              this.$message({ message: res.msg, type: "error" });
+            }
+          },
+          (err) => {
+            if (!this.isCurrentQueryRequest(requestToken)) return;
+            this.$message({ message: err, type: "error" });
+          },
+        );
+      };
+
+      let topDone = false;
+      let allDone = false;
+      const tryFinish = () => {
+        if (topDone && allDone) {
+          this.loading10 = false;
+          this.$nextTick(() => this.renderChart10());
+        }
+      };
+
+      loadMode("top", "extraOvertimeDeptData", () => {
+        topDone = true;
+        tryFinish();
+      });
+      loadMode(null, "extraOvertimeDeptDataAll", () => {
+        allDone = true;
+        tryFinish();
+      });
+    },
+
     loadDeptProjectCount() {
       const requestToken = this.getQueryToken();
       this.loading4 = true;
@@ -1802,6 +1898,189 @@ export default {
       this.chart3.resize({ width, height });
     },
 
+    renderChart10() {
+      if (!this.$refs.chart10) return;
+      if (this.chart10) {
+        this.chart10.dispose();
+        this.chart10 = null;
+      }
+      const el = this.$refs.chart10;
+      const parentEl = el.parentElement;
+      const width = parentEl ? parentEl.clientWidth - 32 : el.clientWidth;
+
+      const sourceData =
+        this.extraOvertimeTabMode === "top"
+          ? this.extraOvertimeDeptData
+          : this.extraOvertimeDeptDataAll;
+      const chartData = (sourceData || []).filter(
+        (item) => Number(item.overtimeHours || 0) > 0,
+      );
+
+      const depts = chartData.map((item) => item.departmentName);
+      const totalHours = chartData.map((item) =>
+        Number((item.overtimeHours || 0).toFixed(2)),
+      );
+      const avgHours = chartData.map((item) =>
+        Number((item.avgOvertimeHours || 0).toFixed(2)),
+      );
+
+      const deptOpenIdMap10 = {};
+      chartData.forEach((item) => {
+        deptOpenIdMap10[item.departmentName] = item._deptOpenId || null;
+      });
+
+      const rowHeight = 28;
+      const minHeight = 300;
+      const isGroupMode = this.extraOvertimeTabMode !== "top";
+
+      if (isGroupMode) {
+        el.innerHTML = "";
+        const height = Math.max(minHeight, chartData.length * rowHeight + 100);
+        el.style.height = height + "px";
+        this.chart10 = echarts.init(el, null, { width, height });
+
+        const barMax = totalHours.length ? Math.max(...totalHours) : 0;
+        const lineMax = avgHours.length ? Math.max(...avgHours) : 0;
+        const xAxis0Max = Math.ceil(barMax * 1.15) || 10;
+        const xAxis1Max = Math.ceil(lineMax * 1.15) || 10;
+        const needTranslate = this.needWxOpenData;
+        const deptNameHtml = this.deptNameHtml.bind(this);
+
+        const option = {
+          tooltip: {
+            trigger: "axis",
+            axisPointer: { type: "shadow" },
+            enterable: needTranslate,
+            formatter: (params) => {
+              const deptName = params[0].name;
+              const openId = deptOpenIdMap10[deptName] || null;
+              let result = deptNameHtml(deptName, openId) + "<br/>";
+              params.forEach((p) => {
+                result += p.marker + p.seriesName + ": " + p.value + " h<br/>";
+              });
+              return result;
+            },
+          },
+          legend: {
+            data: ["加班总工时", "人均加班工时"],
+            top: 10,
+            textStyle: { fontSize: 11 },
+          },
+          grid: {
+            left: 86,
+            right: 48,
+            top: 58,
+            bottom: 44,
+            containLabel: true,
+          },
+          xAxis: [
+            {
+              type: "value",
+              name: "加班总工时",
+              nameLocation: "middle",
+              nameGap: 30,
+              nameTextStyle: { fontSize: 11 },
+              max: xAxis0Max,
+              axisLabel: { formatter: "{value} h" },
+            },
+            {
+              type: "value",
+              name: "人均加班工时",
+              nameLocation: "middle",
+              nameGap: 30,
+              nameTextStyle: { fontSize: 11 },
+              max: xAxis1Max,
+              position: "top",
+              axisLabel: { formatter: "{value} h" },
+            },
+          ],
+          yAxis: {
+            type: "category",
+            data: depts,
+            inverse: true,
+            axisLabel: {
+              interval: 0,
+              fontSize: 11,
+              margin: 12,
+            },
+          },
+          series: [
+            {
+              name: "加班总工时",
+              type: "bar",
+              xAxisIndex: 0,
+              data: totalHours,
+              itemStyle: { color: "#5470C6" },
+              barMaxWidth: 18,
+              label: {
+                normal: {
+                  show: true,
+                  position: "right",
+                  distance: 4,
+                  formatter: (params) => {
+                    const val = Number(params.value || 0);
+                    return val === 0 ? "" : val + " h";
+                  },
+                  color: "#1f2d3d",
+                  fontSize: 10,
+                  fontWeight: "600",
+                  backgroundColor: "rgba(255,255,255,0.85)",
+                  borderColor: "#ddd",
+                  borderWidth: 1,
+                  borderRadius: 3,
+                  padding: [2, 4],
+                },
+              },
+            },
+            {
+              name: "人均加班工时",
+              type: "line",
+              xAxisIndex: 1,
+              data: avgHours,
+              itemStyle: { color: "#FF7F0E" },
+              lineStyle: { width: 2 },
+              symbol: "circle",
+              symbolSize: 6,
+              label: {
+                normal: {
+                  show: true,
+                  position: "right",
+                  distance: 6,
+                  formatter: (params) => {
+                    const val = Number(params.value || 0);
+                    return val === 0 ? "" : val + " h";
+                  },
+                  fontSize: 10,
+                  color: "#303133",
+                  backgroundColor: "rgba(255,255,255,0.78)",
+                  padding: [1, 3],
+                  borderRadius: 3,
+                },
+              },
+            },
+          ],
+        };
+        this.chart10.setOption(option, true);
+        this.chart10.resize({ width, height });
+        return;
+      }
+
+      const height = Math.max(minHeight, chartData.length * rowHeight + 100);
+      el.style.height = height + "px";
+      this.chart10 = echarts.init(el, null, { width, height });
+
+      const option = this.buildBarLineOption({
+        names: depts,
+        barSeries: [{ name: "加班总工时", data: totalHours, color: "#5470C6" }],
+        lineSeries: [{ name: "人均加班工时", data: avgHours, color: "#FF7F0E" }],
+        yAxisNames: ["加班总工时", "人均加班工时"],
+        visibleSize: depts.length || 8,
+        deptOpenIdMap: deptOpenIdMap10,
+      });
+      this.chart10.setOption(option);
+      this.chart10.resize({ width, height });
+    },
+
     renderChart4() {
       if (!this.$refs.chart4) return;
       if (this.chart4) {
@@ -2323,6 +2602,7 @@ export default {
       if (this.top10Data.length > 0) this.renderChart1();
       if (this.top3DeptData.length > 0) this.renderChart2();
       if (this.deptHoursData.length > 0) this.renderChart3();
+      if (this.hasExtraOvertimeData()) this.renderChart10();
       if (this.deptProjectCountData.length > 0) this.renderChart4();
       if (this.projectOvertimeTop5.length > 0) this.renderChart5();
       if (this.hasTop3OvertimeChartData()) this.renderChart6();