| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567 |
- 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.Const.S8;
- using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
- namespace Admin.NET.Plugin.AiDOP.Service.S8;
- public class S8ExceptionService : ITransient
- {
- private readonly SqlSugarRepository<AdoS8Exception> _rep;
- private readonly SqlSugarRepository<AdoS0DepartmentMaster> _deptRep;
- private readonly IS8UserScopeValidator _userScope;
- private readonly IS8ExceptionActionAuthorizer _actionAuthorizer;
- private readonly IS8RuleHandlerPoolReader _handlerPool;
- private readonly UserManager _userManager;
- 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<AdoS8Exception> rep,
- SqlSugarRepository<AdoS0DepartmentMaster> deptRep,
- IS8UserScopeValidator userScope,
- IS8ExceptionActionAuthorizer actionAuthorizer,
- IS8RuleHandlerPoolReader handlerPool,
- UserManager userManager,
- S8ImpactMetricsService impactMetricsService)
- {
- _rep = rep;
- _deptRep = deptRep;
- _userScope = userScope;
- _actionAuthorizer = actionAuthorizer;
- _handlerPool = handlerPool;
- _userManager = userManager;
- _impactMetricsService = impactMetricsService;
- }
- /// <summary>
- /// 解析当前账号的异常可见范围。
- ///
- /// <para>返回 null = <b>不加限制</b>(持有 <c>EXCEPTION_VIEW_ALL</c>,或没有登录上下文
- /// 的内部调用如看板聚合 / 后台作业)。返回值 = (我负责的规则编码集合, 我的账号 Id)。</para>
- ///
- /// <para><b>超管不受限</b>:与 <c>S8ExceptionActionAttribute</c> 同口径,
- /// 避免出现"平台放行、S8 拦截"的不一致。</para>
- /// </summary>
- private async Task<(List<string> RuleCodes, long UserId)?> ResolveVisibilityScopeAsync(long tenantId)
- {
- if (_userManager.SuperAdmin) return null;
- var userId = _userManager.UserId;
- // 无登录上下文(看板聚合 / 后台作业)不做人员可见性收敛 ——
- // 那些调用方本来就不是"某个人在看列表",加上限制只会让统计数字凭空少掉。
- if (userId <= 0 || tenantId <= 0) return null;
- var allowed = await _actionAuthorizer.GetAllowedActionsAsync(tenantId, userId);
- if (allowed.Contains(S8ExceptionActionCode.ViewAll)) return null;
- var ruleCodes = (await _handlerPool.GetRuleCodesForUserAsync(tenantId, userId)).ToList();
- return (ruleCodes, userId);
- }
- public async Task<(int total, List<AdoS8ExceptionListItemDto> 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<AdoS8Exception>()
- // S8-TENANT-ONLY-BATCH6:join 键去掉 factory_id。
- // 不去掉是**必然断链**:本批之后异常 factory_id 恒为 0,而库里的 scene_config
- // 全部是 factory_id ≠ 0 的租户行(实测 29 行,无一为 0),
- // 带 factory 的 join 一条也匹配不上 —— 场景名与规则名会整列变空,且不报错。
- .LeftJoin<AdoS8SceneConfig>((e, sc) =>
- e.TenantId == sc.TenantId && e.SceneCode == sc.SceneCode)
- .LeftJoin<AdoS8WatchRule>((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!));
- // ── S8-HANDLER-POOL-1:可见性收敛 ──
- //
- // View 与 View All 是两件事:前者是"能进这个页面",后者是"能看别人负责的单子"。
- // 没有 EXCEPTION_VIEW_ALL 的账号只看得到:① 自己负责的规则产出的异常;
- // ② 人工提报(无来源规则,不归属任何责任池);③ 与自己直接相关的单子
- // (处理人 / 检验人 / 提报人是本人)——最后一条是必要的:单据可能在他认领之后
- // 被移出责任池,若不保留这一支,他会看不见自己手上正在处理的单子。
- //
- // 这是 EXCEPTION_VIEW_ALL 的**唯一真实消费方**。P1-B 建立该配置位时它还没有消费方,
- // 在此闭环。
- var visibilityScope = await ResolveVisibilityScopeAsync(q.TenantId);
- if (visibilityScope != null)
- {
- var (myRuleCodes, myUserId) = visibilityScope.Value;
- query = query.Where((e, sc, wr) =>
- SqlFunc.IsNullOrEmpty(e.SourceRuleCode)
- || myRuleCodes.Contains(e.SourceRuleCode)
- || e.AssigneeUserId == myUserId
- || e.VerifierUserId == myUserId
- || e.ReporterUserId == myUserId);
- }
- 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<AdoS8ExceptionListItemDto> 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<AdoS8ExceptionListItemDto> ApplyImpactSort(
- List<AdoS8ExceptionListItemDto> 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<object> GetFilterOptionsAsync(long tenantId)
- {
- var scenes = await _rep.Context.Queryable<AdoS8SceneConfig>()
- .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<AdoS8ExceptionDetailDto?> GetDetailAsync(long id, long tenantId)
- {
- // S8-SLA-TIMEOUT-RUNTIME-1(P3):详情 TimeoutFlag 与 list 同口径,运行时计算。
- var timeoutNow = DateTime.Now;
- var rows = await _rep.Context.Queryable<AdoS8Exception>()
- .LeftJoin<AdoS8SceneConfig>((e, sc) =>
- e.TenantId == sc.TenantId && e.SceneCode == sc.SceneCode)
- .LeftJoin<AdoS8WatchRule>((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<AdoS8ExceptionListItemDto> 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<long, string>()
- : (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);
- }
- }
- }
- /// <summary>
- /// 人员名水合。空 = 未指派(返回 null,前端按"未指派"展示);
- /// 有值但解析不到 = 账号已停用或已被移出本租户 → 与部门同口径回落业务可读文案,
- /// <b>绝不回落成裸数字 ID</b>(那对业务用户没有任何意义)。
- /// </summary>
- private static string? ResolvePersonName(long? userId, IReadOnlyDictionary<long, S8OperatorUser> 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<long, string> 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 横跨租户,不可作边界)。
- /// <summary>
- /// S2-EW:debug-only 软删 RelatedObjectCode 以 "TEST_G09_" 开头的异常。
- /// 调用方仅限 <see cref="Controllers.S8.AdoS8WatchDebugController"/>,由其 _debugEndpointEnabled 守门。
- /// 实现纪律:
- /// - 仅 SET IsDeleted=true,不动业务字段(status/closed_at/source_rule_id 等)。
- /// - 前缀字面量硬编码,不接受外部 prefix;防止前缀注入。
- /// - 不联动 timeline / decision / evidence;业务读路径以父 IsDeleted 屏蔽。
- /// </summary>
- public async Task<int> 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;
- }
- }
|