using Admin.NET.Plugin.AiDOP.Dto.S8; using Admin.NET.Plugin.AiDOP.Entity.S0.Manufacturing; using Admin.NET.Plugin.AiDOP.Entity.S0.Warehouse; using Admin.NET.Plugin.AiDOP.Entity.S8; using Admin.NET.Plugin.AiDOP.Infrastructure; using Admin.NET.Plugin.AiDOP.Infrastructure.S8; namespace Admin.NET.Plugin.AiDOP.Service.S8; public class S8ExceptionService : ITransient { private readonly SqlSugarRepository _rep; private readonly SqlSugarRepository _deptRep; private readonly IS8UserScopeValidator _userScope; private readonly S8ImpactMetricsService _impactMetricsService; // S8-DEMO-IMPACT-SORT-NOTICE-1:影响排序的候选集合上限。 // 超过此阈值时降级为 CreatedAt DESC 走 DB 分页,避免全量取 → 内存排序在生产规模下退化。 private const int ImpactSortCandidateCap = 2000; // S8-EXCEPTION-DEPT-NAME-HYDRATION-MASTERDATA-FACTORY-FIX-1:未归属部门 codename(与 S8ManualReportService 同协议常量值)。 private const string UnassignedDepartmentCode = "D-UNASSIGNED"; public S8ExceptionService( SqlSugarRepository rep, SqlSugarRepository deptRep, IS8UserScopeValidator userScope, S8ImpactMetricsService impactMetricsService) { _rep = rep; _deptRep = deptRep; _userScope = userScope; _impactMetricsService = impactMetricsService; } public async Task<(int total, List list)> GetPagedAsync(AdoS8ExceptionQueryDto q) { (q.Page, q.PageSize) = PagingGuard.Normalize(q.Page, q.PageSize); var pendingStatuses = new[] { "NEW", "ASSIGNED", "IN_PROGRESS", "PENDING_VERIFICATION" }; var includeUnclassified = q.IncludeUnclassified == true; // S8-SLA-TIMEOUT-RUNTIME-1(P3):方法入口取一次 now,供 q.TimeoutFlag 筛选 + 投影写 TimeoutFlag 共用,避免分页前后不一致。 var timeoutNow = DateTime.Now; var query = _rep.Context.Queryable() // S8-TENANT-ONLY-BATCH6:join 键去掉 factory_id。 // 不去掉是**必然断链**:本批之后异常 factory_id 恒为 0,而库里的 scene_config // 全部是 factory_id ≠ 0 的租户行(实测 29 行,无一为 0), // 带 factory 的 join 一条也匹配不上 —— 场景名与规则名会整列变空,且不报错。 .LeftJoin((e, sc) => e.TenantId == sc.TenantId && e.SceneCode == sc.SceneCode) .LeftJoin((e, sc, wr) => e.TenantId == wr.TenantId && e.SourceRuleCode == wr.RuleCode) .Where((e, sc, wr) => e.TenantId == q.TenantId && !e.IsDeleted) // S8-EXCEPTION-FIRST-VIEW-CLOSURE-1(CC-1):默认列表的「已分类」口径同时要求 stage_code 非空。 // stage_code 是 Monitoring 聚合键(GetSummaryAsync 按 S8ModuleCode.All 过滤),为空即「未归入任何业务域」, // 与 exception_type_code 为空同属未分类。所有建单入口(S8ManualReportService 的手工/watch/hit 三分支) // 都会写 stage_code,故本条只影响历史遗留行;下钻仍可用 includeUnclassified=true 查看全量。 .WhereIF(!includeUnclassified, (e, sc, wr) => !SqlFunc.IsNullOrEmpty(e.ExceptionTypeCode) && !SqlFunc.IsNullOrEmpty(e.StageCode)) .WhereIF(!string.IsNullOrWhiteSpace(q.Status), (e, sc, wr) => e.Status == q.Status) .WhereIF(string.IsNullOrWhiteSpace(q.Status) && q.StatusBucket == "pending", (e, sc, wr) => pendingStatuses.Contains(e.Status)) // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:legacy 兼容 — q.Severity 传 LOW/MEDIUM/HIGH/CRITICAL 时 // 通过 Normalize 映射为 FOLLOW/SERIOUS 再过滤。 .WhereIF(!string.IsNullOrWhiteSpace(q.Severity), (e, sc, wr) => e.Severity == S8SeverityCode.Normalize(q.Severity)) .WhereIF(!string.IsNullOrWhiteSpace(q.SceneCode), (e, sc, wr) => e.SceneCode == q.SceneCode) .WhereIF(!string.IsNullOrWhiteSpace(q.ModuleCode), (e, sc, wr) => e.ModuleCode == q.ModuleCode) // S8-DASHBOARD-DATA-ALIGN-S1S7-1:看板明细表传 OnlyS1S7Modules=true 时与 KPI 口径对齐; // 异常列表页不传该参数,行为保持不变(仍可见 NULL module 的 legacy 行)。 .WhereIF(q.OnlyS1S7Modules == true, (e, sc, wr) => S8ModuleCode.All.Contains(e.ModuleCode)) .WhereIF(q.DeptId.HasValue, (e, sc, wr) => e.ResponsibleDeptId == q.DeptId!.Value || e.OccurrenceDeptId == q.DeptId!.Value) // S8-SLA-TIMEOUT-RUNTIME-1(P3):q.TimeoutFlag 由 timeout_flag 字段筛选切到 sla_deadline + status 在线计算。 // q.TimeoutFlag=true → 当前超时;q.TimeoutFlag=false → 未当前超时(含未配 SLA / 已关闭 / 已恢复)。 .WhereIF(q.TimeoutFlag == true, (e, sc, wr) => e.SlaDeadline != null && e.SlaDeadline < timeoutNow && e.Status != "CLOSED" && e.Status != "RECOVERED") .WhereIF(q.TimeoutFlag == false, (e, sc, wr) => !(e.SlaDeadline != null && e.SlaDeadline < timeoutNow && e.Status != "CLOSED" && e.Status != "RECOVERED")) .WhereIF(q.BeginTime.HasValue, (e, sc, wr) => e.CreatedAt >= q.BeginTime!.Value) .WhereIF(q.EndTime.HasValue, (e, sc, wr) => e.CreatedAt <= q.EndTime!.Value) .WhereIF(!string.IsNullOrWhiteSpace(q.ProcessNodeCode), (e, sc, wr) => e.ProcessNodeCode == q.ProcessNodeCode) .WhereIF(!string.IsNullOrWhiteSpace(q.RelatedObjectCode), (e, sc, wr) => e.RelatedObjectCode == q.RelatedObjectCode) .WhereIF(!string.IsNullOrWhiteSpace(q.OrderFlowCode), (e, sc, wr) => e.OrderFlowCode == q.OrderFlowCode) .WhereIF(!string.IsNullOrWhiteSpace(q.StageCode), (e, sc, wr) => e.StageCode == q.StageCode) .WhereIF(!string.IsNullOrWhiteSpace(q.RuleMechanism), (e, sc, wr) => e.RuleMechanism == q.RuleMechanism) .WhereIF(q.RecoveredStatus == "RECOVERED", (e, sc, wr) => e.RecoveredAt != null) .WhereIF(q.RecoveredStatus == "ACTIVE", (e, sc, wr) => e.RecoveredAt == null) .WhereIF(!string.IsNullOrWhiteSpace(q.RuleType), (e, sc, wr) => wr.RuleType == q.RuleType) .WhereIF(!string.IsNullOrWhiteSpace(q.Keyword), (e, sc, wr) => e.Title.Contains(q.Keyword!) || e.ExceptionCode.Contains(q.Keyword!)); var total = await query.CountAsync(); // S8-DEMO-IMPACT-SORT-NOTICE-1:候选集合 <= ImpactSortCandidateCap 时走"全量取 → 内存填影响 → 排序 → 分页", // 支持 impactScore / repeatCount30d / cumulativeLossHours30d 等运行期字段排序;超过则降级 DB 分页 createdAt DESC。 var useImpactSort = total > 0 && total <= ImpactSortCandidateCap; List list; if (useImpactSort) { // 先按 query 命中集合一次性取候选,order 仅 createdAt DESC 用于稳定性,后续内存覆写排序。 var candidates = await query .OrderBy((e, sc, wr) => e.CreatedAt, OrderByType.Desc) .Take(ImpactSortCandidateCap) .Select((e, sc, wr) => new AdoS8ExceptionListItemDto { Id = e.Id, FactoryId = e.FactoryId, ExceptionCode = e.ExceptionCode, Title = e.Title, Status = e.Status, Severity = e.Severity, PriorityScore = e.PriorityScore, PriorityLevel = e.PriorityLevel, SceneCode = e.SceneCode, SceneName = sc.SceneName, ModuleCode = e.ModuleCode, ResponsibleDeptId = e.ResponsibleDeptId, OccurrenceDeptId = e.OccurrenceDeptId, AssigneeUserId = e.AssigneeUserId, SlaDeadline = e.SlaDeadline, TimeoutFlag = e.SlaDeadline != null && e.SlaDeadline < timeoutNow && e.Status != "CLOSED" && e.Status != "RECOVERED", CreatedAt = e.CreatedAt, ClosedAt = e.ClosedAt, ExceptionTypeCode = e.ExceptionTypeCode, RecoveredAt = e.RecoveredAt, SourceRuleCode = e.SourceRuleCode, SourceObjectType = e.SourceObjectType, SourceObjectId = e.SourceObjectId, DedupKey = e.DedupKey, LastDetectedAt = e.LastDetectedAt, RuleType = wr.RuleType, RelatedObjectCode = e.RelatedObjectCode, OrderFlowCode = e.OrderFlowCode, StageCode = e.StageCode, RuleMechanism = e.RuleMechanism, }) .ToListAsync(); // DTO 后处理:损失时间 / 是否超时关闭 / 标签。 foreach (var r in candidates) { r.StatusLabel = S8Labels.StatusLabel(r.Status); r.SeverityLabel = S8Labels.SeverityLabel(r.Severity); r.ModuleName = string.IsNullOrWhiteSpace(r.ModuleCode) ? null : S8ModuleCode.Label(r.ModuleCode!); var (lossHours, isOverdueClosed) = ComputeClosureMetrics(r.CreatedAt, r.ClosedAt, r.SlaDeadline); r.LossHours = lossHours; r.IsOverdueClosed = isOverdueClosed; } // 影响统计批量填充(30 天窗口聚合一次性 GROUP BY,O(n) 回填,无 N+1)。 var impactRows = candidates .Select(r => new ExceptionImpactRow { Id = r.Id, ExceptionTypeCode = r.ExceptionTypeCode, Severity = r.Severity, CreatedAt = r.CreatedAt, ClosedAt = r.ClosedAt, TimeoutFlag = r.TimeoutFlag, }) .ToList(); await _impactMetricsService.FillBatchAsync(q.TenantId, impactRows); var impactById = impactRows.ToDictionary(x => x.Id); foreach (var r in candidates) { if (!impactById.TryGetValue(r.Id, out var snap)) continue; r.RepeatCount30d = snap.RepeatCount30d; r.CumulativeLossHours30d = snap.CumulativeLossHours30d; r.ImpactScore = snap.ImpactScore; r.SuggestedAttentionLevel = snap.SuggestedAttentionLevel; r.SuggestedAttentionLabel = snap.SuggestedAttentionLabel; r.ImpactReason = snap.ImpactReason; } // 内存排序(白名单,非白名单回退 createdAt DESC)。 var sorted = ApplyImpactSort(candidates, q.SortField, q.SortOrder); // 内存分页。 list = sorted .Skip((q.Page - 1) * q.PageSize) .Take(q.PageSize) .ToList(); } else { // 降级路径:候选 > ImpactSortCandidateCap,DB 分页 createdAt DESC,不填充影响字段(前端展示 0/null)。 list = await query .OrderBy((e, sc, wr) => e.CreatedAt, OrderByType.Desc) .Select((e, sc, wr) => new AdoS8ExceptionListItemDto { Id = e.Id, FactoryId = e.FactoryId, ExceptionCode = e.ExceptionCode, Title = e.Title, Status = e.Status, Severity = e.Severity, PriorityScore = e.PriorityScore, PriorityLevel = e.PriorityLevel, SceneCode = e.SceneCode, SceneName = sc.SceneName, ModuleCode = e.ModuleCode, ResponsibleDeptId = e.ResponsibleDeptId, OccurrenceDeptId = e.OccurrenceDeptId, AssigneeUserId = e.AssigneeUserId, SlaDeadline = e.SlaDeadline, TimeoutFlag = e.SlaDeadline != null && e.SlaDeadline < timeoutNow && e.Status != "CLOSED" && e.Status != "RECOVERED", CreatedAt = e.CreatedAt, ClosedAt = e.ClosedAt, ExceptionTypeCode = e.ExceptionTypeCode, RecoveredAt = e.RecoveredAt, SourceRuleCode = e.SourceRuleCode, SourceObjectType = e.SourceObjectType, SourceObjectId = e.SourceObjectId, DedupKey = e.DedupKey, LastDetectedAt = e.LastDetectedAt, RuleType = wr.RuleType, RelatedObjectCode = e.RelatedObjectCode, OrderFlowCode = e.OrderFlowCode, StageCode = e.StageCode, RuleMechanism = e.RuleMechanism, }) .ToPageListAsync(q.Page, q.PageSize); foreach (var r in list) { r.StatusLabel = S8Labels.StatusLabel(r.Status); r.SeverityLabel = S8Labels.SeverityLabel(r.Severity); r.ModuleName = string.IsNullOrWhiteSpace(r.ModuleCode) ? null : S8ModuleCode.Label(r.ModuleCode!); var (lossHours, isOverdueClosed) = ComputeClosureMetrics(r.CreatedAt, r.ClosedAt, r.SlaDeadline); r.LossHours = lossHours; r.IsOverdueClosed = isOverdueClosed; } } await FillDisplayNamesAsync(list, q.TenantId); return (total, list); } // S8-DEMO-IMPACT-SORT-NOTICE-1:白名单排序;severity 自定义键(SERIOUS=2 / FOLLOW=1 / 其他=0)。 // sortField 空或非白名单 → createdAt DESC;sortOrder 非 asc/desc → desc。 private static List ApplyImpactSort( List rows, string? sortField, string? sortOrder) { var asc = string.Equals(sortOrder, "asc", StringComparison.OrdinalIgnoreCase); var key = (sortField ?? string.Empty).Trim(); return key switch { "impactScore" => asc ? rows.OrderBy(r => r.ImpactScore).ThenByDescending(r => r.CreatedAt).ToList() : rows.OrderByDescending(r => r.ImpactScore).ThenByDescending(r => r.CreatedAt).ToList(), "repeatCount30d" => asc ? rows.OrderBy(r => r.RepeatCount30d).ThenByDescending(r => r.CreatedAt).ToList() : rows.OrderByDescending(r => r.RepeatCount30d).ThenByDescending(r => r.CreatedAt).ToList(), "cumulativeLossHours30d" => asc ? rows.OrderBy(r => r.CumulativeLossHours30d).ThenByDescending(r => r.CreatedAt).ToList() : rows.OrderByDescending(r => r.CumulativeLossHours30d).ThenByDescending(r => r.CreatedAt).ToList(), "severity" => asc ? rows.OrderBy(r => SeveritySortKey(r.Severity)).ThenByDescending(r => r.CreatedAt).ToList() : rows.OrderByDescending(r => SeveritySortKey(r.Severity)).ThenByDescending(r => r.CreatedAt).ToList(), "priorityScore" => asc ? rows.OrderBy(r => r.PriorityScore).ThenByDescending(r => r.CreatedAt).ToList() : rows.OrderByDescending(r => r.PriorityScore).ThenByDescending(r => r.CreatedAt).ToList(), "createdAt" => asc ? rows.OrderBy(r => r.CreatedAt).ToList() : rows.OrderByDescending(r => r.CreatedAt).ToList(), _ => rows.OrderByDescending(r => r.CreatedAt).ToList(), }; } private static int SeveritySortKey(string severity) => severity switch { "SERIOUS" => 2, "FOLLOW" => 1, _ => 0, }; public async Task GetFilterOptionsAsync(long tenantId) { var scenes = await _rep.Context.Queryable() .Where(x => x.TenantId == tenantId && x.Enabled) .OrderBy(x => x.SortNo) .Select(x => new { value = x.SceneCode, label = x.SceneName }) .ToListAsync(); var departments = await _deptRep.AsQueryable() .Where(x => x.TenantId == tenantId) .OrderBy(x => x.Department) .Take(500) .Select(x => new { value = x.Id, label = x.Descr ?? x.Department }) .ToListAsync(); // S8-EXCEPTION-MODULE-DISPLAY-1:业务展示主口径切到 module_code(S1-S7)。 // scenes 保留兼容期,前端筛选已切到 modules。 var modules = S8ModuleCode.All .Select(code => new { value = code, label = S8ModuleCode.Label(code) }) .ToList(); return new { statuses = S8Labels.StatusOptions(), severities = S8Labels.SeverityOptions(), scenes, modules, departments }; } public async Task GetDetailAsync(long id, long tenantId) { // S8-SLA-TIMEOUT-RUNTIME-1(P3):详情 TimeoutFlag 与 list 同口径,运行时计算。 var timeoutNow = DateTime.Now; var rows = await _rep.Context.Queryable() .LeftJoin((e, sc) => e.TenantId == sc.TenantId && e.SceneCode == sc.SceneCode) .LeftJoin((e, sc, wr) => e.TenantId == wr.TenantId && e.SourceRuleCode == wr.RuleCode) .Where((e, sc, wr) => e.Id == id && e.TenantId == tenantId && !e.IsDeleted) .Select((e, sc, wr) => new AdoS8ExceptionDetailDto { Id = e.Id, FactoryId = e.FactoryId, ExceptionCode = e.ExceptionCode, Title = e.Title, Description = e.Description, Status = e.Status, Severity = e.Severity, PriorityScore = e.PriorityScore, PriorityLevel = e.PriorityLevel, SceneCode = e.SceneCode, SceneName = sc.SceneName, ModuleCode = e.ModuleCode, SourceType = e.SourceType, OccurrenceDeptId = e.OccurrenceDeptId, ResponsibleDeptId = e.ResponsibleDeptId, ResponsibleGroupId = e.ResponsibleGroupId, AssigneeUserId = e.AssigneeUserId, ReporterUserId = e.ReporterUserId, SlaDeadline = e.SlaDeadline, // S8-SLA-TIMEOUT-RUNTIME-1(P3):详情展示 TimeoutFlag = 在线计算。 TimeoutFlag = e.SlaDeadline != null && e.SlaDeadline < timeoutNow && e.Status != "CLOSED" && e.Status != "RECOVERED", CreatedAt = e.CreatedAt, ClosedAt = e.ClosedAt, AssignedAt = e.AssignedAt, UpdatedAt = e.UpdatedAt, ActiveFlowInstanceId = e.ActiveFlowInstanceId, ActiveFlowBizType = e.ActiveFlowBizType, VerifierUserId = e.VerifierUserId, VerificationAssignedAt = e.VerificationAssignedAt, VerifiedAt = e.VerifiedAt, VerificationResult = e.VerificationResult, VerificationRemark = e.VerificationRemark, SourceRuleId = e.SourceRuleId, RelatedObjectCode = e.RelatedObjectCode, DedupKey = e.DedupKey, LastDetectedAt = e.LastDetectedAt, RecoveredAt = e.RecoveredAt, SourceRuleCode = e.SourceRuleCode, SourceObjectType = e.SourceObjectType, SourceObjectId = e.SourceObjectId, ExceptionTypeCode = e.ExceptionTypeCode, RuleType = wr.RuleType, OrderFlowCode = e.OrderFlowCode, StageCode = e.StageCode, RuleMechanism = e.RuleMechanism, }) .Take(1) .ToListAsync(); if (rows.Count == 0) return null; var d = rows[0]; d.StatusLabel = S8Labels.StatusLabel(d.Status); d.SeverityLabel = S8Labels.SeverityLabel(d.Severity); d.ModuleName = string.IsNullOrWhiteSpace(d.ModuleCode) ? null : S8ModuleCode.Label(d.ModuleCode!); // S8-DEMO-CORE-FIELD-COMPLETE-1:详情与列表同口径,DTO 后处理计算损失时间 / 是否超时关闭。 var (lossHours, isOverdueClosed) = ComputeClosureMetrics(d.CreatedAt, d.ClosedAt, d.SlaDeadline); d.LossHours = lossHours; d.IsOverdueClosed = isOverdueClosed; await FillDisplayNamesAsync(new[] { d }, tenantId); return d; } // S8-DEMO-CORE-FIELD-COMPLETE-1:损失时间(小时,1 位小数)+ 是否超时关闭(冻结判定)。 // 未关闭 → LossHours=null;未关闭或无 SLA → IsOverdueClosed=null。与运行时 TimeoutFlag 不复用。 private static (decimal? lossHours, bool? isOverdueClosed) ComputeClosureMetrics( DateTime createdAt, DateTime? closedAt, DateTime? slaDeadline) { decimal? lossHours = closedAt == null ? (decimal?)null : Math.Round((decimal)(closedAt.Value - createdAt).TotalHours, 1); bool? isOverdueClosed = (closedAt == null || slaDeadline == null) ? (bool?)null : closedAt.Value > slaDeadline.Value; return (lossHours, isOverdueClosed); } // S8-DEPT-DISPLAY-CONSISTENCY-1(P0-A-2/A-3):list 行也水合 OccurrenceDeptName; // 部门查询加租户二次约束,避免跨租户同名 / 同 RecID 错位。 private async Task FillDisplayNamesAsync(IEnumerable rows, long tenantId) { var list = rows.ToList(); if (list.Count == 0) return; var deptIds = list .Select(x => x.ResponsibleDeptId) .Concat(list.Select(x => x.OccurrenceDeptId)) .Where(x => x > 0) .Distinct() .ToList(); // S8-DEPARTMENT-LOOKUP-CLOSURE-1(B-1):部门水合曾用**全局单值配置** S8MasterData.DepartmentFactoryRefId // 解析主数据 factory,在多租户下必然只能服务一个租户;已废除,改用当前请求的 trusted 边界。 // // S8-TENANT-ONLY-BATCH6:该 trusted 边界由 factoryId 收敛为 **tenantId**。 // B-1 当时的理由是「各 S8 运营租户的 DepartmentMaster.factory_ref_id 均等于其运营 factoryId // (每租户 factory 唯一)」—— 真库复查证明这个前提今天已不成立: // factory_ref_id = 1000 同时属于 824585161322565 与 797403760988229 两个租户, // 按 factory 水合会把另一租户的 210 个部门当作可用名称来源。tenant_id 才是唯一正确的边界。 var deptMap = deptIds.Count == 0 ? new Dictionary() : (await _deptRep.AsQueryable().ClearFilter() .Where(x => deptIds.Contains(x.Id) && x.TenantId == tenantId) .Select(x => new { x.Id, Name = x.Descr ?? x.Department }) .ToListAsync()) .ToDictionary(x => x.Id, x => x.Name); // S8-SYSUSER-ONLY-1:处理人 / 检验人 / 提报人现在同为 SysUser.Id,一次水合三个字段。 // // 原实现要查两张表:EmployeeMaster(assignee/verifier,且 ClearFilter 无租户谓词) // 与 SysUser(reporter 的兼容兜底),并按"empMap 优先、userMap 兜底"合并 —— // 那正是同一列曾经装过两套 ID 的证据。现在只有一个 ID 空间、一次查询、一个租户边界。 var personIds = list .SelectMany(x => new[] { x.AssigneeUserId ?? 0L, (x as AdoS8ExceptionDetailDto)?.VerifierUserId ?? 0L, (x as AdoS8ExceptionDetailDto)?.ReporterUserId ?? 0L }) .Where(x => x > 0) .Distinct() .ToList(); // 判据与认领 / 转派 / 复检完全同源(见 S8UserScopeValidator):租户内 + 启用。 // 停用账号解析不出名字 → 回落 UnresolvedLookupLabel,与部门的处理一致。 var personMap = await _userScope.ResolveDisplayAsync(tenantId, personIds); foreach (var row in list) { row.ResponsibleDeptName = ResolveDeptName(row.ResponsibleDeptId, deptMap); row.OccurrenceDeptName = ResolveDeptName(row.OccurrenceDeptId, deptMap); row.AssigneeName = ResolvePersonName(row.AssigneeUserId, personMap); if (row is AdoS8ExceptionDetailDto detail) { detail.ReporterName = ResolvePersonName(detail.ReporterUserId, personMap); detail.VerifierName = ResolvePersonName(detail.VerifierUserId, personMap); } } } /// /// 人员名水合。空 = 未指派(返回 null,前端按"未指派"展示); /// 有值但解析不到 = 账号已停用或已被移出本租户 → 与部门同口径回落业务可读文案, /// 绝不回落成裸数字 ID(那对业务用户没有任何意义)。 /// private static string? ResolvePersonName(long? userId, IReadOnlyDictionary map) { if (userId is not > 0) return null; return map.TryGetValue(userId.Value, out var u) ? u.DisplayName : UnresolvedLookupLabel; } // S8-DEPT-DISPLAY-CONSISTENCY-1(P0-A-3):dept fallback —— 0/缺失=未归属。 // S8-EXCEPTION-FIRST-VIEW-CLOSURE-1(CC-2):解析不到主数据时不再回落「部门ID:{id}」—— // 裸内部 ID 对业务用户无意义,且只可能出现在 FK 悬空的历史行上,统一显示为业务可读文案。 internal const string UnresolvedLookupLabel = "历史数据 / 未配置"; private static string? ResolveDeptName(long deptId, Dictionary deptMap) { if (deptId <= 0) return "未归属"; return deptMap.TryGetValue(deptId, out var name) && !string.IsNullOrWhiteSpace(name) ? name : UnresolvedLookupLabel; } // S8-DEPARTMENT-LOOKUP-CLOSURE-1(B-1):原 ResolveMasterDataDepartmentFactoryRefIdAsync 已移除。 // 它把全局单值配置 S8MasterData.DepartmentFactoryRefId 作为部门主数据 factory,在多租户下不安全 // (实测导致跨租户读取 + 其余租户全部解析失败)。 // S8-TENANT-ONLY-BATCH6:显示路径的边界进一步收敛为 trusted tenantId(factory 横跨租户,不可作边界)。 /// /// S2-EW:debug-only 软删 RelatedObjectCode 以 "TEST_G09_" 开头的异常。 /// 调用方仅限 ,由其 _debugEndpointEnabled 守门。 /// 实现纪律: /// - 仅 SET IsDeleted=true,不动业务字段(status/closed_at/source_rule_id 等)。 /// - 前缀字面量硬编码,不接受外部 prefix;防止前缀注入。 /// - 不联动 timeline / decision / evidence;业务读路径以父 IsDeleted 屏蔽。 /// public async Task SoftDeleteTestPrefixAsync(long tenantId) { const string prefix = "TEST_G09_"; var affected = await _rep.AsUpdateable() .SetColumns(e => new AdoS8Exception { IsDeleted = true }) .Where(e => e.TenantId == tenantId && !e.IsDeleted && e.RelatedObjectCode != null && e.RelatedObjectCode.StartsWith(prefix)) .ExecuteCommandAsync(); System.Diagnostics.Trace.TraceWarning( $"[S2-EW] soft-deleted {affected} TEST_G09_ exceptions, tenant={tenantId}"); return affected; } }