Browse Source

feat(s8): add lifecycle metrics for overview cards

YY968XX 1 tuần trước cách đây
mục cha
commit
62a2f4ec1e

+ 1 - 1
Web/package.json

@@ -1,7 +1,7 @@
 {
 	"name": "admin.net",
 	"type": "module",
-	"version": "2.4.144",
+	"version": "2.4.145",
 	"packageManager": "pnpm@10.32.1",
 	"lastBuildTime": "2026.03.15",
 	"description": "Admin.NET 站在巨人肩膀上的 .NET 通用权限开发框架",

+ 4 - 0
Web/src/views/aidop/s8/api/s8DashboardApi.ts

@@ -53,6 +53,10 @@ export interface S8DeptBacklogItem {
 	// TASK-013-DEPT-SERIOUS-CARD-1:按 severity 归一化切两桶,与 status 维度正交;首期 UI 仅消费 seriousCount。
 	seriousCount: number;
 	followCount: number;
+	// TASK-013-P2-AVG-CLOSE-FOLLOW-1:dept 级闭环指标,缺样本时 avgProcessHours=null → 前端显示 '--'。
+	closedCount?: number;
+	closeRate?: number;
+	avgProcessHours?: number | null;
 }
 
 export interface S8QuickException {

+ 102 - 54
Web/src/views/aidop/s8/monitoring/components/S8DeptSeverityTabsCard.vue

@@ -3,51 +3,34 @@
 		<div class="s8-dept-severity__header">
 			<div class="s8-dept-severity__title-wrap">
 				<span class="s8-dept-severity__code">S8</span>
-				<span class="s8-dept-severity__title">部门严重异常</span>
+				<span class="s8-dept-severity__title">部门异常</span>
 			</div>
 		</div>
 		<el-tabs v-model="activeTab" class="s8-dept-severity__tabs">
 			<el-tab-pane label="严重" name="serious">
-				<div v-if="loading" class="s8-dept-severity__empty">加载中…</div>
-				<div v-else-if="!sortedItems.length" class="s8-dept-severity__empty">暂无严重异常</div>
-				<div v-else class="s8-dept-severity__table" role="table">
-					<div class="s8-dept-severity__thead" role="row">
-						<span class="s8-dept-severity__th s8-dept-severity__th--name" role="columnheader">部门</span>
-						<span class="s8-dept-severity__th s8-dept-severity__th--count" role="columnheader">严重异常数</span>
-						<span class="s8-dept-severity__th s8-dept-severity__th--metric" role="columnheader">平均处理时间</span>
-						<span class="s8-dept-severity__th s8-dept-severity__th--metric" role="columnheader">关闭率</span>
-					</div>
-					<div class="s8-dept-severity__tbody">
-						<div
-							v-for="row in sortedItems"
-							:key="row.deptId"
-							class="s8-dept-severity__row"
-							role="row"
-						>
-							<span class="s8-dept-severity__cell s8-dept-severity__cell--name" :title="row.deptName" role="cell">{{ row.deptName }}</span>
-							<span
-								class="s8-dept-severity__cell s8-dept-severity__cell--count"
-								:class="row.seriousCount > 0 ? 's8-dept-severity__cell--hot' : 's8-dept-severity__cell--zero'"
-								role="cell"
-							>
-								{{ row.seriousCount }}
-							</span>
-							<span class="s8-dept-severity__cell s8-dept-severity__cell--metric s8-dept-severity__cell--placeholder" role="cell">
-								{{ DISPLAY_PLACEHOLDER }}
-							</span>
-							<span class="s8-dept-severity__cell s8-dept-severity__cell--metric s8-dept-severity__cell--placeholder" role="cell">
-								{{ DISPLAY_PLACEHOLDER }}
-							</span>
-						</div>
-					</div>
-				</div>
+				<DeptTable
+					:loading="loading"
+					:rows="seriousRows"
+					count-label="严重异常数"
+					empty-text="暂无严重异常"
+					:count-key="'seriousCount'"
+				/>
+			</el-tab-pane>
+			<el-tab-pane label="关注" name="follow">
+				<DeptTable
+					:loading="loading"
+					:rows="followRows"
+					count-label="关注异常数"
+					empty-text="暂无关注异常"
+					:count-key="'followCount'"
+				/>
 			</el-tab-pane>
 		</el-tabs>
 	</div>
 </template>
 
 <script setup lang="ts">
-import { computed, shallowRef, type CSSProperties } from 'vue';
+import { computed, h, shallowRef, type CSSProperties, type PropType } from 'vue';
 import type { S8DeptBacklogItem } from '../../api/s8DashboardApi';
 
 const props = defineProps<{
@@ -56,26 +39,95 @@ const props = defineProps<{
 	minHeight?: number;
 }>();
 
-const activeTab = shallowRef<'serious'>('serious');
-
-// TASK-013-DEPT-CARD-UI-COMPLETE-1:平均处理时间 / 关闭率后端未返回,统一占位 "--",
-// 不伪造 0 或 fake 计算;后续 P2 任务 TASK-013-P2-AVG-CLOSE-FOLLOW-1 接通真实计算后替换。
-const DISPLAY_PLACEHOLDER = '--';
-
-// TASK-013-DEPT-SERIOUS-CARD-1:seriousCount desc, total desc 兜底;
-// 全员 seriousCount=0 视为空态(模板里 empty 判定)。
-const sortedItems = computed(() => {
-	const filtered = (props.items ?? []).filter((item) => item && item.seriousCount > 0);
-	return [...filtered].sort((a, b) => {
-		if (b.seriousCount !== a.seriousCount) return b.seriousCount - a.seriousCount;
-		return b.total - a.total;
-	});
-});
+type Tab = 'serious' | 'follow';
+const activeTab = shallowRef<Tab>('serious');
+
+// TASK-013-P2-AVG-CLOSE-FOLLOW-1:严重 / 关注两个视角,分别按 seriousCount / followCount > 0 过滤并降序。
+const seriousRows = computed(() =>
+	[...(props.items ?? [])]
+		.filter((x) => x && x.seriousCount > 0)
+		.sort((a, b) => b.seriousCount - a.seriousCount || b.total - a.total),
+);
+const followRows = computed(() =>
+	[...(props.items ?? [])]
+		.filter((x) => x && x.followCount > 0)
+		.sort((a, b) => b.followCount - a.followCount || b.total - a.total),
+);
 
 const minHeightStyle = computed<CSSProperties>(() => {
 	if (!props.minHeight || props.minHeight <= 0) return {};
 	return { minHeight: `${props.minHeight}px` };
 });
+
+// TASK-013-P2-AVG-CLOSE-FOLLOW-1:avgProcessHours 无样本(null/undefined/<=0)→ '--';
+// closeRate 缺字段 → '--',0 值显示 '0%'(数据真实闭环为 0,不当作空态)。
+function formatHoursOrDash(v: number | null | undefined): string {
+	if (v === null || v === undefined || !Number.isFinite(v) || v <= 0) return '--';
+	return v >= 24 ? `${Math.round(v)}h+` : `${v.toFixed(1)}h`;
+}
+function formatPercentOrDash(v: number | null | undefined): string {
+	if (v === null || v === undefined || !Number.isFinite(v)) return '--';
+	const clamped = Math.max(0, Math.min(100, v));
+	return `${clamped.toFixed(clamped % 1 === 0 ? 0 : 1)}%`;
+}
+
+const DeptTable = (rawProps: {
+	loading?: boolean;
+	rows: S8DeptBacklogItem[];
+	countLabel: string;
+	emptyText: string;
+	countKey: 'seriousCount' | 'followCount';
+}) => {
+	if (rawProps.loading) return h('div', { class: 's8-dept-severity__empty' }, '加载中…');
+	if (!rawProps.rows.length) return h('div', { class: 's8-dept-severity__empty' }, rawProps.emptyText);
+	return h('div', { class: 's8-dept-severity__table', role: 'table' }, [
+		h('div', { class: 's8-dept-severity__thead', role: 'row' }, [
+			h('span', { class: 's8-dept-severity__th s8-dept-severity__th--name', role: 'columnheader' }, '部门'),
+			h('span', { class: 's8-dept-severity__th s8-dept-severity__th--count', role: 'columnheader' }, rawProps.countLabel),
+			h('span', { class: 's8-dept-severity__th s8-dept-severity__th--metric', role: 'columnheader' }, '平均处理时间'),
+			h('span', { class: 's8-dept-severity__th s8-dept-severity__th--metric', role: 'columnheader' }, '关闭率'),
+		]),
+		h(
+			'div',
+			{ class: 's8-dept-severity__tbody' },
+			rawProps.rows.map((row) => {
+				const count = row[rawProps.countKey];
+				const hot = count > 0;
+				return h(
+					'div',
+					{
+						key: row.deptId,
+						class: 's8-dept-severity__row',
+						role: 'row',
+					},
+					[
+						h('span', { class: 's8-dept-severity__cell s8-dept-severity__cell--name', title: row.deptName, role: 'cell' }, row.deptName),
+						h(
+							'span',
+							{
+								class: [
+									's8-dept-severity__cell s8-dept-severity__cell--count',
+									hot ? 's8-dept-severity__cell--hot' : 's8-dept-severity__cell--zero',
+								],
+								role: 'cell',
+							},
+							String(count),
+						),
+						h('span', { class: 's8-dept-severity__cell s8-dept-severity__cell--metric', role: 'cell' }, formatHoursOrDash(row.avgProcessHours)),
+						h('span', { class: 's8-dept-severity__cell s8-dept-severity__cell--metric', role: 'cell' }, formatPercentOrDash(row.closeRate)),
+					],
+				);
+			}),
+		),
+	]);
+};
+DeptTable.props = {
+	loading: { type: Boolean as PropType<boolean>, default: false },
+	rows: { type: Array as PropType<S8DeptBacklogItem[]>, required: true },
+	countLabel: { type: String, required: true },
+	emptyText: { type: String, required: true },
+	countKey: { type: String as PropType<'seriousCount' | 'followCount'>, required: true },
+};
 </script>
 
 <style scoped>
@@ -237,10 +289,6 @@ const minHeightStyle = computed<CSSProperties>(() => {
 	color: #94a3b8;
 }
 
-.s8-dept-severity__cell--placeholder {
-	color: #475569;
-}
-
 .s8-dept-severity__empty {
 	color: #64748b;
 	font-size: 12px;

+ 3 - 0
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -58,6 +58,9 @@
     <None Update="UpdateScripts\1.0.110.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.111.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
   </ItemGroup>
 
   <ItemGroup>

+ 140 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.111.sql

@@ -0,0 +1,140 @@
+-- 1.0.111.sql
+-- S8-EXCEPTION-LIFECYCLE-DEMO-SEED-1
+-- 为 S8 overview 卡片驱动「闭环率 / 平均处理时间」演示能力,将 22 条 active 异常的一部分切到 CLOSED / IN_PROGRESS:
+--   * CLOSED 8 条:覆盖 S1/S2/S3/S4/S5/S6/S7 七个 stage,含 SERIOUS 与 FOLLOW 两档;处理时长 4/6/8/12/18/24/36/72h
+--   * IN_PROGRESS 5 条:覆盖 S1/S4/S5/S6 四个 stage;assigned_at = created_at + 1h
+--   * 其余 9 条保持 NEW
+-- 同时为受影响异常补写 timeline ASSIGN / CLOSE 动作行,CLOSE 仅写给 CLOSED 行。
+-- 执行入口:AutoVersionUpdate.UseAutoVersionUpdate(),csproj Version=1.0.111 主节点首次启动时触发。
+-- 幂等性:
+--   * UPDATE 通过 exception_code IN (...) + source_rule_code IN (...) + 目标状态 != 当前 status 防重;
+--     closed_at 用 created_at + INTERVAL N HOUR 固定生成,不用 NOW(),保证多次执行 closed_at 不漂移;
+--     重复执行后已是 CLOSED 的行不再被 SET(WHERE status <> 'CLOSED'),avg/closeRate 稳定。
+--   * timeline INSERT ... WHERE NOT EXISTS (action_code, action_remark='S8 lifecycle demo seed') 防重。
+-- 安全边界:
+--   * 不动 source_rule_code / exception_code / severity / stage_code / module_code / responsible_dept_id /
+--     is_deleted / timeout_flag / sla_deadline;
+--   * WHERE 严格限制 source_rule_code IN ('ORDER_FLOW_KPI_OVERTIME_DERIVED','S8_OVERVIEW_DEMO_COVERAGE_SEED');
+--   * 仅 13 条按 exception_code 显式列入,不会扩散到其它行;
+--   * 无 DELETE;无 ALTER;无全表 UPDATE;不动 ado_s8_order_flow_*;不动 watch_rule。
+-- Rollback: 如需回滚,可执行:
+--   UPDATE ado_s8_exception SET status='NEW', assigned_at=NULL, closed_at=NULL
+--    WHERE exception_code IN ('EX-DRV-SO-2026-009-OR','EX-DEMO-COV-S2-02','EX-DEMO-COV-S3-02',
+--                              'EX-DRV-SO-2026-001-MP','EX-DRV-SO-2026-002-MP','EX-DEMO-COV-S5-02',
+--                              'EX-DRV-SO-2026-003-BP','EX-DRV-SO-2026-011-FA',
+--                              'EX-DRV-SO-2026-015-OR','EX-DRV-SO-2026-004-PD','EX-DRV-SO-2026-006-MP',
+--                              'EX-DEMO-COV-S5-01','EX-DRV-SO-2026-007-BP')
+--      AND source_rule_code IN ('ORDER_FLOW_KPI_OVERTIME_DERIVED','S8_OVERVIEW_DEMO_COVERAGE_SEED');
+--   DELETE FROM ado_s8_exception_timeline WHERE action_remark='S8 lifecycle demo seed';
+-- 2026-05-16
+
+-- 1) CLOSED 8 条:assigned_at = created_at + 1h,closed_at = created_at + Nh
+--    幂等:WHERE status <> 'CLOSED' 避免重复执行覆盖 assigned_at(NULL→1h→1h,但若被人工修改过则保留)。
+UPDATE ado_s8_exception
+SET assigned_at = CASE
+        WHEN assigned_at IS NULL THEN DATE_ADD(created_at, INTERVAL 1 HOUR)
+        ELSE assigned_at
+    END,
+    closed_at = CASE exception_code
+        WHEN 'EX-DRV-SO-2026-009-OR' THEN DATE_ADD(created_at, INTERVAL 36 HOUR)
+        WHEN 'EX-DEMO-COV-S2-02'     THEN DATE_ADD(created_at, INTERVAL 24 HOUR)
+        WHEN 'EX-DEMO-COV-S3-02'     THEN DATE_ADD(created_at, INTERVAL  6 HOUR)
+        WHEN 'EX-DRV-SO-2026-001-MP' THEN DATE_ADD(created_at, INTERVAL 12 HOUR)
+        WHEN 'EX-DRV-SO-2026-002-MP' THEN DATE_ADD(created_at, INTERVAL 18 HOUR)
+        WHEN 'EX-DEMO-COV-S5-02'     THEN DATE_ADD(created_at, INTERVAL  8 HOUR)
+        WHEN 'EX-DRV-SO-2026-003-BP' THEN DATE_ADD(created_at, INTERVAL  4 HOUR)
+        WHEN 'EX-DRV-SO-2026-011-FA' THEN DATE_ADD(created_at, INTERVAL 72 HOUR)
+        ELSE closed_at
+    END,
+    status     = 'CLOSED',
+    updated_at = NOW(),
+    updated_by = 'system:s8_lifecycle_demo_seed'
+WHERE is_deleted = 0
+  AND status     <> 'CLOSED'
+  AND source_rule_code IN ('ORDER_FLOW_KPI_OVERTIME_DERIVED','S8_OVERVIEW_DEMO_COVERAGE_SEED')
+  AND exception_code IN (
+    'EX-DRV-SO-2026-009-OR',  -- S1 SERIOUS  36h
+    'EX-DEMO-COV-S2-02',      -- S2 SERIOUS  24h
+    'EX-DEMO-COV-S3-02',      -- S3 FOLLOW    6h
+    'EX-DRV-SO-2026-001-MP',  -- S4 FOLLOW   12h
+    'EX-DRV-SO-2026-002-MP',  -- S4 FOLLOW   18h
+    'EX-DEMO-COV-S5-02',      -- S5 FOLLOW    8h
+    'EX-DRV-SO-2026-003-BP',  -- S6 FOLLOW    4h
+    'EX-DRV-SO-2026-011-FA'   -- S7 FOLLOW   72h
+  );
+
+-- 2) IN_PROGRESS 5 条:assigned_at = created_at + 1h;closed_at 保持 NULL。
+--    幂等:WHERE status NOT IN ('IN_PROGRESS','CLOSED') 避免覆盖已闭环或多次刷新 assigned_at。
+UPDATE ado_s8_exception
+SET assigned_at = CASE
+        WHEN assigned_at IS NULL THEN DATE_ADD(created_at, INTERVAL 1 HOUR)
+        ELSE assigned_at
+    END,
+    closed_at  = NULL,
+    status     = 'IN_PROGRESS',
+    updated_at = NOW(),
+    updated_by = 'system:s8_lifecycle_demo_seed'
+WHERE is_deleted = 0
+  AND status NOT IN ('IN_PROGRESS','CLOSED')
+  AND source_rule_code IN ('ORDER_FLOW_KPI_OVERTIME_DERIVED','S8_OVERVIEW_DEMO_COVERAGE_SEED')
+  AND exception_code IN (
+    'EX-DRV-SO-2026-015-OR',  -- S1 SERIOUS
+    'EX-DRV-SO-2026-004-PD',  -- S1 SERIOUS
+    'EX-DRV-SO-2026-006-MP',  -- S4 FOLLOW
+    'EX-DEMO-COV-S5-01',      -- S5 FOLLOW
+    'EX-DRV-SO-2026-007-BP'   -- S6 FOLLOW
+  );
+
+-- 3) timeline ASSIGN:所有 IN_PROGRESS / CLOSED 共 13 条均写一行 ASSIGN,from_status='NEW' → to_status='IN_PROGRESS'。
+INSERT INTO ado_s8_exception_timeline
+  (exception_id, action_code, action_label, from_status, to_status,
+   operator_id, operator_name, action_remark, created_at)
+SELECT e.id, 'ASSIGN', '分派', 'NEW', 'IN_PROGRESS',
+       NULL, 'system', 'S8 lifecycle demo seed', e.assigned_at
+FROM ado_s8_exception e
+WHERE e.is_deleted = 0
+  AND e.source_rule_code IN ('ORDER_FLOW_KPI_OVERTIME_DERIVED','S8_OVERVIEW_DEMO_COVERAGE_SEED')
+  AND e.exception_code IN (
+    'EX-DRV-SO-2026-009-OR','EX-DEMO-COV-S2-02','EX-DEMO-COV-S3-02',
+    'EX-DRV-SO-2026-001-MP','EX-DRV-SO-2026-002-MP','EX-DEMO-COV-S5-02',
+    'EX-DRV-SO-2026-003-BP','EX-DRV-SO-2026-011-FA',
+    'EX-DRV-SO-2026-015-OR','EX-DRV-SO-2026-004-PD','EX-DRV-SO-2026-006-MP',
+    'EX-DEMO-COV-S5-01','EX-DRV-SO-2026-007-BP'
+  )
+  AND NOT EXISTS (
+    SELECT 1 FROM ado_s8_exception_timeline tl
+    WHERE tl.exception_id = e.id
+      AND tl.action_code  = 'ASSIGN'
+      AND tl.action_remark = 'S8 lifecycle demo seed'
+  );
+
+-- 4) timeline CLOSE:仅 8 条 CLOSED 写一行 CLOSE,from_status='IN_PROGRESS' → to_status='CLOSED'。
+INSERT INTO ado_s8_exception_timeline
+  (exception_id, action_code, action_label, from_status, to_status,
+   operator_id, operator_name, action_remark, created_at)
+SELECT e.id, 'CLOSE', '闭环', 'IN_PROGRESS', 'CLOSED',
+       NULL, 'system', 'S8 lifecycle demo seed', e.closed_at
+FROM ado_s8_exception e
+WHERE e.is_deleted = 0
+  AND e.status     = 'CLOSED'
+  AND e.source_rule_code IN ('ORDER_FLOW_KPI_OVERTIME_DERIVED','S8_OVERVIEW_DEMO_COVERAGE_SEED')
+  AND e.exception_code IN (
+    'EX-DRV-SO-2026-009-OR','EX-DEMO-COV-S2-02','EX-DEMO-COV-S3-02',
+    'EX-DRV-SO-2026-001-MP','EX-DRV-SO-2026-002-MP','EX-DEMO-COV-S5-02',
+    'EX-DRV-SO-2026-003-BP','EX-DRV-SO-2026-011-FA'
+  )
+  AND NOT EXISTS (
+    SELECT 1 FROM ado_s8_exception_timeline tl
+    WHERE tl.exception_id = e.id
+      AND tl.action_code  = 'CLOSE'
+      AND tl.action_remark = 'S8 lifecycle demo seed'
+  );
+
+-- 验证 SQL(注释,仅人工核对):
+-- SELECT status, COUNT(*) FROM ado_s8_exception WHERE is_deleted=0 GROUP BY status;
+-- SELECT stage_code, COUNT(*) total, SUM(status='CLOSED') closed_cnt,
+--        ROUND(SUM(status='CLOSED')*100.0/COUNT(*), 1) close_rate,
+--        ROUND(AVG(CASE WHEN closed_at IS NOT NULL THEN TIMESTAMPDIFF(MINUTE, created_at, closed_at) END)/60, 2) avg_hours
+--   FROM ado_s8_exception WHERE is_deleted=0 GROUP BY stage_code ORDER BY stage_code;
+-- SELECT action_code, to_status, COUNT(*) FROM ado_s8_exception_timeline
+--  WHERE action_remark='S8 lifecycle demo seed' GROUP BY action_code, to_status;

+ 28 - 12
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8DashboardService.cs

@@ -206,12 +206,13 @@ public class S8DashboardService : ITransient
         // S8-SLA-TIMEOUT-RUNTIME-1(P3):投影 SlaDeadline 替代 TimeoutFlag;timeout 由内存计算(与 GetSummaryAsync 一致 now)。
         // TASK-013-DEPT-SERIOUS-CARD-1:补投 Severity 用于 seriousCount/followCount 内存聚合(归一化走 S8SeverityCode.IsSerious/IsFollow,
         // 兼容 legacy HIGH/CRITICAL/MEDIUM/LOW 值)。
+        // TASK-013-P2-AVG-CLOSE-FOLLOW-1:补投 CreatedAt / ClosedAt 用于 closedCount / closeRate / avgProcessHours 聚合。
         var list = await _rep.AsQueryable()
             .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && !x.IsDeleted)
             .Where(x => S8ModuleCode.All.Contains(x.StageCode))
             .WhereIF(periodFrom.HasValue, x => x.CreatedAt >= periodFrom!.Value)
             .WhereIF(periodTo.HasValue,   x => x.CreatedAt < periodTo!.Value)
-            .Select(x => new { x.ResponsibleDeptId, x.Status, x.SlaDeadline, x.Severity })
+            .Select(x => new { x.ResponsibleDeptId, x.Status, x.SlaDeadline, x.Severity, x.CreatedAt, x.ClosedAt })
             .ToListAsync();
         var nowForTimeout = DateTime.Now;
 
@@ -225,18 +226,33 @@ public class S8DashboardService : ITransient
 
         return list
             .GroupBy(x => x.ResponsibleDeptId)
-            .Select(g => new
+            .Select(g =>
             {
-                deptId   = g.Key,
-                deptName = deptDict.GetValueOrDefault(g.Key, g.Key.ToString()),
-                pending    = g.Count(x => pendingStatuses.Contains(x.Status)),
-                inProgress = g.Count(x => x.Status == "IN_PROGRESS"),
-                // S8-SLA-TIMEOUT-RUNTIME-1:当前超时 = sla_deadline 在 now 之前 AND 未 CLOSED/RECOVERED。
-                timeout    = g.Count(x => x.SlaDeadline != null && x.SlaDeadline < nowForTimeout && x.Status != "CLOSED" && x.Status != "RECOVERED"),
-                total      = g.Count(),
-                // TASK-013-DEPT-SERIOUS-CARD-1:按 severity 归一化切两桶,与 ado_s8_exception 列 status 维度正交。
-                seriousCount = g.Count(x => S8SeverityCode.IsSerious(x.Severity)),
-                followCount  = g.Count(x => S8SeverityCode.IsFollow(x.Severity)),
+                var total = g.Count();
+                // TASK-013-P2-AVG-CLOSE-FOLLOW-1:closed 以 ClosedAt != null 为准(兼容 Status='CLOSED' 与未来 RECOVERED 闭环差异)。
+                var closedRows = g.Where(x => x.ClosedAt.HasValue).ToList();
+                var closedCount = closedRows.Count;
+                double? avgHours = closedCount == 0
+                    ? null
+                    : Math.Round(closedRows.Average(x => (x.ClosedAt!.Value - x.CreatedAt).TotalHours), 1);
+                double closeRate = total == 0 ? 0 : Math.Round(closedCount * 100.0 / total, 1);
+                return new
+                {
+                    deptId   = g.Key,
+                    deptName = deptDict.GetValueOrDefault(g.Key, g.Key.ToString()),
+                    pending    = g.Count(x => pendingStatuses.Contains(x.Status)),
+                    inProgress = g.Count(x => x.Status == "IN_PROGRESS"),
+                    // S8-SLA-TIMEOUT-RUNTIME-1:当前超时 = sla_deadline 在 now 之前 AND 未 CLOSED/RECOVERED。
+                    timeout    = g.Count(x => x.SlaDeadline != null && x.SlaDeadline < nowForTimeout && x.Status != "CLOSED" && x.Status != "RECOVERED"),
+                    total      = total,
+                    // TASK-013-DEPT-SERIOUS-CARD-1:按 severity 归一化切两桶,与 ado_s8_exception 列 status 维度正交。
+                    seriousCount = g.Count(x => S8SeverityCode.IsSerious(x.Severity)),
+                    followCount  = g.Count(x => S8SeverityCode.IsFollow(x.Severity)),
+                    // TASK-013-P2-AVG-CLOSE-FOLLOW-1:dept 级闭环指标,null avgHours 由前端显示 '--'。
+                    closedCount,
+                    closeRate,
+                    avgProcessHours = avgHours,
+                };
             })
             .OrderByDescending(x => x.seriousCount)
             .ThenByDescending(x => x.total)