Просмотр исходного кода

fix(s8): align dashboard KPI counts with exception list drill-down

A. KPI drill-down preserves filters end-to-end:
   - Dashboard onKpiClick now adds includeUnclassified=true so the list
     shows the same total the dashboard counts (NULL exception_type_code
     rows are not silently filtered out).
   - todayNew KPI: list now parses todayNew=true → applies a today
     00:00–23:59 window. Use a local-time string instead of toISOString()
     because MySQL DATETIME has no timezone and the UTC ISO would shift
     the window by 8h on +08 servers.
   - List page reads route.query.includeUnclassified, holds beginTime /
     endTime in query state, and resets them on 重置.

B. Auto-infer ExceptionTypeCode on manual report:
   - InferExceptionTypeCodeAsync looks up the lowest-SortNo enabled type
     for the scene. Manual reports no longer land in the "未分类" bucket
     and disappear from the default list view.
   - ado_s8_exception_type baseline rows have tenant_id=0/factory_id=0
     (global), so the lookup matches "tenant 命中 OR 0" with .ClearFilter()
     to bypass the multi-tenant global filter — same boundary protocol as
     BUG-S8-EMPLOYEES-TENANT-FILTER.
YY968XX 3 месяцев назад
Родитель
Сommit
af750968a8

+ 6 - 1
Web/src/views/aidop/s8/dashboard/S8DashboardPage.vue

@@ -463,7 +463,12 @@ function onKpiClick(key: string) {
 	if (key === 'timeout')    q.timeoutFlag = 'true';
 	if (key === 'closed')     q.status = 'CLOSED';
 	if (key === 'todayNew')   q.todayNew = 'true';
-	if (Object.keys(q).length) router.push({ path: '/aidop/s8/exceptions', query: q });
+	// 看板按 is_deleted=0 全口径计数;列表默认隐藏 NULL type,下钻时强制带上 includeUnclassified
+	// 保证看板数字 == 列表条数。
+	if (Object.keys(q).length) {
+		q.includeUnclassified = 'true';
+		router.push({ path: '/aidop/s8/exceptions', query: q });
+	}
 }
 
 function onDetailPageChange(page: number) {

+ 17 - 0
Web/src/views/aidop/s8/exceptions/S8ExceptionListPage.vue

@@ -122,6 +122,8 @@ const query = reactive({
 	ruleType: '' as string,
 	recoveredStatus: '' as string,
 	includeUnclassified: false,
+	beginTime: '' as string,
+	endTime: '' as string,
 	page: 1,
 	pageSize: 20,
 	tenantId: 1,
@@ -169,6 +171,8 @@ async function loadList() {
 			ruleType: query.ruleType || undefined,
 			recoveredStatus: query.recoveredStatus || undefined,
 			includeUnclassified: query.includeUnclassified || undefined,
+			beginTime: query.beginTime || undefined,
+			endTime: query.endTime || undefined,
 		});
 		rows.value = res.list;
 		total.value = res.total;
@@ -188,6 +192,8 @@ function resetQuery() {
 	query.ruleType = '';
 	query.recoveredStatus = '';
 	query.includeUnclassified = false;
+	query.beginTime = '';
+	query.endTime = '';
 	query.page = 1;
 	void loadList();
 }
@@ -204,6 +210,17 @@ onMounted(async () => {
 	if (route.query.status) query.status = String(route.query.status);
 	if (route.query.bucket) query.statusBucket = String(route.query.bucket);
 	if (route.query.timeout === '1' || route.query.timeoutFlag === 'true') query.timeoutFlag = true;
+	if (route.query.includeUnclassified === 'true') query.includeUnclassified = true;
+	// 今日新增 KPI 下钻:把列表过滤限制在今天 00:00 ~ 23:59。
+	// 不能用 toISOString — 它输出 UTC,本地 +08 会把"今日 00:00"转成"昨日 16:00 UTC",
+	// 后端拿到后查询区间相对 MySQL 本地 DATETIME 偏移 8 小时,命中错误的"昨晚~今下午"。
+	if (route.query.todayNew === 'true') {
+		const now = new Date();
+		const pad = (n: number) => String(n).padStart(2, '0');
+		const ymd = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
+		query.beginTime = `${ymd} 00:00:00`;
+		query.endTime = `${ymd} 23:59:59`;
+	}
 	await loadFilters();
 	await loadList();
 });

+ 25 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ManualReportService.cs

@@ -23,6 +23,7 @@ public class S8ManualReportService : ITransient
     private readonly SqlSugarRepository<AdoS8SceneConfig> _sceneRep;
     private readonly SqlSugarRepository<AdoS0DepartmentMaster> _deptRep;
     private readonly SqlSugarRepository<AdoS0LineMaster> _lineRep;
+    private readonly SqlSugarRepository<AdoS8ExceptionType> _typeRep;
     private readonly UserManager _userManager;
     private readonly FlowEngineService _flowEngine;
     private readonly ILogger<S8ManualReportService> _logger;
@@ -34,6 +35,7 @@ public class S8ManualReportService : ITransient
         SqlSugarRepository<AdoS8SceneConfig> sceneRep,
         SqlSugarRepository<AdoS0DepartmentMaster> deptRep,
         SqlSugarRepository<AdoS0LineMaster> lineRep,
+        SqlSugarRepository<AdoS8ExceptionType> typeRep,
         UserManager userManager,
         FlowEngineService flowEngine,
         ILogger<S8ManualReportService> logger)
@@ -44,11 +46,30 @@ public class S8ManualReportService : ITransient
         _sceneRep = sceneRep;
         _deptRep = deptRep;
         _lineRep = lineRep;
+        _typeRep = typeRep;
         _userManager = userManager;
         _flowEngine = flowEngine;
         _logger = logger;
     }
 
+    /// <summary>
+    /// 主动提报推断 ExceptionTypeCode:场景下取启用且 SortNo 最小的一条。
+    /// baseline 异常类型当前 tenant_id=0/factory_id=0(全局基线),所以匹配条件为
+    /// (tenantId 命中 OR 0) AND (factoryId 命中 OR 0)。ClearFilter 兜底全局多租户过滤器。
+    /// 找不到返回 null(保持兼容)。
+    /// </summary>
+    private async Task<string?> InferExceptionTypeCodeAsync(long tenantId, long factoryId, string sceneCode)
+    {
+        if (string.IsNullOrWhiteSpace(sceneCode)) return null;
+        return await _typeRep.AsQueryable().ClearFilter()
+            .Where(x => (x.TenantId == tenantId || x.TenantId == 0)
+                     && (x.FactoryId == factoryId || x.FactoryId == 0)
+                     && x.SceneCode == sceneCode && x.Enabled)
+            .OrderBy(x => x.SortNo)
+            .Select(x => x.TypeCode)
+            .FirstAsync();
+    }
+
     /// <summary>
     /// TB001 异常提报审批流:自动监控 + 主动提报后软触发,失败仅 warn 日志,不阻断建单。
     /// </summary>
@@ -127,6 +148,9 @@ public class S8ManualReportService : ITransient
         // 提报人以服务端登录上下文为准,忽略前端传入;未登录上下文落 null。
         var currentUserId = _userManager.UserId > 0 ? _userManager.UserId : (long?)null;
 
+        // 主动提报无前端 type 字段,按场景兜底推断;保证不进"未分类"桶。
+        var inferredType = await InferExceptionTypeCodeAsync(dto.TenantId, dto.FactoryId, dto.SceneCode.Trim());
+
         var code = $"EX-{DateTime.Now:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
         var entity = new AdoS8Exception
         {
@@ -144,6 +168,7 @@ public class S8ManualReportService : ITransient
             OccurrenceDeptId = dto.OccurrenceDeptId,
             ResponsibleDeptId = dto.ResponsibleDeptId,
             ReporterId = currentUserId,
+            ExceptionTypeCode = inferredType,
             CreatedAt = DateTime.Now,
             IsDeleted = false
         };