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.S8; using Admin.NET.Plugin.AiDOP.Service.S8.Rules; using Admin.NET.Plugin.ApprovalFlow.Service; using Microsoft.Extensions.Logging; namespace Admin.NET.Plugin.AiDOP.Service.S8; public class S8ManualReportService : ITransient { // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:业务枚举只保留 FOLLOW/SERIOUS。 // 旧值 LOW/MEDIUM/HIGH/CRITICAL 仍可作为兼容输入(接收后由 S8SeverityCode.Normalize 归一)。 private static readonly HashSet AllowedSeverities = new(StringComparer.OrdinalIgnoreCase) { "FOLLOW", "SERIOUS", "LOW", "MEDIUM", "HIGH", "CRITICAL", // legacy compat }; // S8-PROCESS-NODE-S1S7-ALIGN-1:process_node_code 当前阶段对齐 S1-S7 订单主流程。 // 优先 module_code(已是 S1-S7),其次按 scene_code 反推;都无法识别则 null。 // S8-PROCESS-NODE-MODULE-CODE-ALIGNMENT-EXEC-1:保留函数挂起待用——本阶段所有建单点不再调用, // 由 module_code 承担 S1-S7 主流程归属;未来引入更细流程节点(如 S2.PLAN / S6.WO_RELEASE)时恢复使用。 private static string? ResolveProcessNodeCode(string? sceneCode, string? moduleCode) { if (!string.IsNullOrWhiteSpace(moduleCode) && S8ModuleCode.All.Contains(moduleCode)) return moduleCode; var fromScene = S8ModuleCode.FromScene(sceneCode); if (!string.IsNullOrWhiteSpace(fromScene) && S8ModuleCode.All.Contains(fromScene)) return fromScene; return null; } /// /// S8-EXCEPTION-CREATION-MODULE-CODE-FIX-1:建单 module_code 派生统一入口。 /// 优先级:显式 module → hit module → 单模块 sceneCode(S1-S7)→ exception_type.scene_code。 /// 严格 S1-S7 校验,不接受 legacy 复合 scene;最终无法确定时返回 null(caller 决定记日志或拒绝)。 /// private async Task ResolveModuleCodeAsync( string? explicitModuleCode, string? hitModuleCode, string? sceneCode, string? exceptionTypeCode) { var byExplicit = S8ModuleCode.Normalize(explicitModuleCode); if (byExplicit != null) return byExplicit; var byHit = S8ModuleCode.Normalize(hitModuleCode); if (byHit != null) return byHit; var byScene = S8ModuleCode.FromCanonicalScene(sceneCode); if (byScene != null) return byScene; if (!string.IsNullOrWhiteSpace(exceptionTypeCode)) { var typeScene = await _typeRep.AsQueryable().ClearFilter() .Where(t => t.TypeCode == exceptionTypeCode && t.Enabled) .OrderByDescending(t => t.FactoryId) .Select(t => t.SceneCode) .FirstAsync(); var byType = S8ModuleCode.FromCanonicalScene(typeScene); if (byType != null) return byType; } return null; } private readonly SqlSugarRepository _rep; private readonly SqlSugarRepository _timelineRep; private readonly SqlSugarRepository _evidenceRep; private readonly SqlSugarRepository _sceneRep; private readonly SqlSugarRepository _deptRep; private readonly SqlSugarRepository _lineRep; private readonly SqlSugarRepository _typeRep; private readonly SqlSugarRepository _empRep; private readonly UserManager _userManager; private readonly FlowEngineService _flowEngine; private readonly ILogger _logger; public S8ManualReportService( SqlSugarRepository rep, SqlSugarRepository timelineRep, SqlSugarRepository evidenceRep, SqlSugarRepository sceneRep, SqlSugarRepository deptRep, SqlSugarRepository lineRep, SqlSugarRepository typeRep, SqlSugarRepository empRep, UserManager userManager, FlowEngineService flowEngine, ILogger logger) { _rep = rep; _timelineRep = timelineRep; _evidenceRep = evidenceRep; _sceneRep = sceneRep; _deptRep = deptRep; _lineRep = lineRep; _typeRep = typeRep; _empRep = empRep; _userManager = userManager; _flowEngine = flowEngine; _logger = logger; } // S8-REPORTER-IDSPACE-FIX-1(P0-B-1):把当前登录 SysUser.Id 反查 EmployeeMaster.RecID。 // 用于 reporter_id idspace 与 assignee_id / verifier_id 对齐。 // 协议:sys_user_id + factory_ref_id 双键命中;EmployeeMaster.tenant_id 与 SysUser.TenantId 历史错位 → // ClearFilter,由 factoryRefId 做硬边界(与 S8MasterDataAdapter / S8TaskFlowService 同口径)。 // 未绑定时返回 null,调用方负责诊断日志。 private async Task ResolveCurrentEmployeeIdAsync(long factoryId, long sysUserId) { if (sysUserId <= 0 || factoryId <= 0) return null; var emp = await _empRep.AsQueryable().ClearFilter() .Where(x => x.SysUserId == sysUserId && x.FactoryRefId == factoryId && x.IsActive) .Select(x => new { x.Id }) .FirstAsync(); return emp?.Id; } /// /// 主动提报推断 ExceptionTypeCode:场景下取启用且 SortNo 最小的一条。 /// baseline 异常类型当前 tenant_id=0/factory_id=0(全局基线),所以匹配条件为 /// (tenantId 命中 OR 0) AND (factoryId 命中 OR 0)。ClearFilter 兜底全局多租户过滤器。 /// 找不到返回 null(保持兼容)。 /// private async Task 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(); } /// /// TB001 异常提报审批流:自动监控 + 主动提报后软触发,失败仅 warn 日志,不阻断建单。 /// private async Task TryStartIntakeFlowAsync(AdoS8Exception entity) { try { await _flowEngine.StartFlow(new StartFlowInput { BizType = "EXCEPTION_REPORT", BizId = entity.Id, BizNo = entity.ExceptionCode, Title = $"异常提报 - {entity.ExceptionCode}", Comment = entity.SourceType == "AUTO_WATCH" ? "自动监控触发" : "主动提报触发", BizData = new Dictionary { ["sceneCode"] = entity.SceneCode ?? "", ["exceptionTypeCode"] = entity.ExceptionTypeCode ?? "", ["sourceType"] = entity.SourceType ?? "" } }); } catch (Exception ex) { _logger.LogWarning(ex, "TB001 异常提报审批流触发失败 ExceptionId={Id} ExceptionCode={Code}", entity.Id, entity.ExceptionCode); } } public async Task GetFormOptionsAsync(long tenantId, long factoryId) { var scenes = await _sceneRep.AsQueryable() .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.Enabled) .OrderBy(x => x.SortNo) .Select(x => new { value = x.SceneCode, label = x.SceneName }) .ToListAsync(); // ClearFilter:DepartmentMaster.tenant_id 属 S0 域租户,不与登录 token TenantId 一致; // 用 factory_ref_id 做硬边界,安全等价。同 BUG-S8-EMPLOYEES-TENANT-FILTER 协议。 var departments = await _deptRep.AsQueryable().ClearFilter() .Where(x => x.FactoryRefId == factoryId) .OrderBy(x => x.Department) .Take(500) .Select(x => new { value = x.Id, label = x.Descr ?? x.Department }) .ToListAsync(); var lines = await _lineRep.AsQueryable().ClearFilter() .Where(x => x.FactoryRefId == factoryId) .OrderBy(x => x.Line) .Take(500) .Select(x => new { value = x.Id, label = x.Describe ?? x.Line }) .ToListAsync(); return new { scenes, // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:业务枚举 FOLLOW/SERIOUS 两档。 severities = S8SeverityCode.Options(), departments, lines, materials = Array.Empty() }; } public async Task CreateAsync(AdoS8ManualReportCreateDto dto) { if (string.IsNullOrWhiteSpace(dto.Title)) throw new S8BizException("标题必填"); if (string.IsNullOrWhiteSpace(dto.SceneCode)) throw new S8BizException("场景必填"); // S8-MANUAL-REPORT-DEPT-ZERO-GUARD-1(P0-B-3):人工提报禁止 dept=0 入库。 // 字段类型 long(非空),未填默认 0;guard 命中 <=0 直接拒绝,绝不转 null/默认部门/未归属。 // 范围仅限本入口;自动建单(CreateFromWatchAsync/CreateFromHitAsync)的 ?? 0 风险归 P0-B-4。 if (dto.OccurrenceDeptId <= 0) throw new S8BizException("发生部门不能为空,请选择有效发生部门"); if (dto.ResponsibleDeptId <= 0) throw new S8BizException("处理部门不能为空,请选择有效处理部门"); // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:白名单接受新值(FOLLOW/SERIOUS)+ 旧值兼容; // 通过 Normalize 写入 DB 一律为 FOLLOW/SERIOUS。 var rawSeverity = string.IsNullOrWhiteSpace(dto.Severity) ? S8SeverityCode.Follow : dto.Severity.Trim(); if (!AllowedSeverities.Contains(rawSeverity)) throw new S8BizException($"严重度 {rawSeverity} 非法,仅允许 FOLLOW/SERIOUS"); var severity = S8SeverityCode.Normalize(rawSeverity); // 提报人以服务端登录上下文为准,忽略前端传入;未登录上下文落 null。 // currentUserId 仍是 SysUser.Id,仅用作 timeline.OperatorId(与 S8TaskFlowService 同口径); // S8-REPORTER-IDSPACE-FIX-1:ReporterId 写入改用 EmployeeMaster.RecID(与 assignee/verifier 同 idspace)。 var currentUserId = _userManager.UserId > 0 ? _userManager.UserId : (long?)null; long? currentEmployeeId = null; if (currentUserId.HasValue) { currentEmployeeId = await ResolveCurrentEmployeeIdAsync(dto.FactoryId, currentUserId.Value); if (currentEmployeeId == null) { _logger.LogWarning( "manual_report_reporter_unbound sysUserId={SysUserId} factoryId={FactoryId} title={Title}", currentUserId.Value, dto.FactoryId, dto.Title); } } // 主动提报无前端 type 字段,按场景兜底推断;保证不进"未分类"桶。 var inferredType = await InferExceptionTypeCodeAsync(dto.TenantId, dto.FactoryId, dto.SceneCode.Trim()); // S8-EXCEPTION-CREATION-MODULE-CODE-FIX-1:手工提报 module_code 严格按 S1-S7 派生; // dto 暂不携带显式 module_code,按 scene → exception_type.scene 链路降级。 var resolvedModule = await ResolveModuleCodeAsync( explicitModuleCode: null, hitModuleCode: null, sceneCode: dto.SceneCode.Trim(), exceptionTypeCode: inferredType); if (resolvedModule == null) { _logger.LogWarning( "manual_report_module_code_unresolved sceneCode={SceneCode} exceptionTypeCode={TypeCode} title={Title}", dto.SceneCode, inferredType, dto.Title); } var code = $"EX-{DateTime.Now:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}"; var entity = new AdoS8Exception { TenantId = dto.TenantId, FactoryId = dto.FactoryId, ExceptionCode = code, Title = dto.Title.Trim(), Description = dto.Description, SceneCode = dto.SceneCode.Trim(), SourceType = "MANUAL", Status = "NEW", Severity = severity, PriorityScore = 0, PriorityLevel = "P3", OccurrenceDeptId = dto.OccurrenceDeptId, ResponsibleDeptId = dto.ResponsibleDeptId, // S8-REPORTER-IDSPACE-FIX-1:reporter_id idspace = EmployeeMaster.RecID(与 assignee/verifier 同)。 ReporterId = currentEmployeeId, ExceptionTypeCode = inferredType, ModuleCode = resolvedModule, // S8-PROCESS-NODE-MODULE-CODE-ALIGNMENT-EXEC-1:当前阶段 process_node_code 留空, // module_code 承担 S1-S7 主流程归属;process_node_code 留给未来更细流程节点。 ProcessNodeCode = null, CreatedAt = DateTime.Now, IsDeleted = false }; await _rep.AsTenant().UseTranAsync(async () => { entity = await _rep.AsInsertable(entity).ExecuteReturnEntityAsync(); await _timelineRep.InsertAsync(new AdoS8ExceptionTimeline { ExceptionId = entity.Id, ActionCode = "CREATE", ActionLabel = "创建", FromStatus = null, ToStatus = "NEW", OperatorId = currentUserId, ActionRemark = "主动提报", CreatedAt = DateTime.Now }); }, ex => throw ex); await TryStartIntakeFlowAsync(entity); return new AdoS8ManualReportResultDto { ExceptionId = entity.Id, ExceptionCode = entity.ExceptionCode, TaskId = entity.Id }; } /// /// G01-06:自动建单分支(非第二套创建主链)。 /// 这是本服务内的自动监控建单路径,与 并列, /// 复用同一仓储(_rep / _timelineRep)、同一事务边界、同一 ExceptionCode 生成规则、 /// 同一时间线主链(ActionCode="CREATE"、ToStatus="NEW");仅差异点: /// - SourceType 标识为自动监控来源 /// - 填入 SourceRuleId / SourceDataSourceId / SourcePayload / RelatedObjectCode 追溯 /// - ExceptionTypeCode 固定 EQUIP_FAULT(G-01 首版唯一映射) /// - SceneCode 固定 S2(G-01 首版唯一场景,迁移后从 S2S6_PRODUCTION 切到单模块 S2) /// 不做补偿、重试、对账;失败由调用方接住。 /// public async Task CreateFromWatchAsync(S8WatchHitResult hit) { if (hit.SourceRuleId <= 0 || string.IsNullOrWhiteSpace(hit.RelatedObjectCode)) throw new S8BizException("自动建单缺失追溯键"); var code = $"EX-{DateTime.Now:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}"; var title = $"[自动] 设备 {hit.RelatedObjectCode} {hit.TriggerCondition} {hit.ThresholdValue}(当前 {hit.CurrentValue})"; // S8-EXCEPTION-CREATION-MODULE-CODE-FIX-1:固定 SceneCode=S2 + ExceptionTypeCode=EQUIP_FAULT, // 派生链路通过 ResolveModuleCodeAsync 严格按 S1-S7 走(结果稳定为 S2,但消除 FromScene 的 legacy 兼容路径)。 var resolvedModule = await ResolveModuleCodeAsync( explicitModuleCode: null, hitModuleCode: null, sceneCode: S8SceneCode.S2, exceptionTypeCode: "EQUIP_FAULT"); if (resolvedModule == null) { _logger.LogWarning( "auto_watch_module_code_unresolved sceneCode={SceneCode} exceptionTypeCode={TypeCode} ruleId={RuleId}", S8SceneCode.S2, "EQUIP_FAULT", hit.SourceRuleId); } var entity = new AdoS8Exception { // 租户/工厂:与 S8WatchSchedulerService.RunOnceAsync 当前固定上下文一致。 TenantId = 1, FactoryId = 1, ExceptionCode = code, Title = title, Description = null, SceneCode = S8SceneCode.S2, // 首版自动监控建单来源标识(字符串值,先不抽常量类)。 SourceType = "AUTO_WATCH", Status = "NEW", Severity = S8SeverityCode.Normalize(hit.Severity), PriorityScore = 0, PriorityLevel = "P3", // 首版兜底口径:Hit 未提供部门时置 0 仅为保证“能建成标准异常单并进入主链”, // 不是最终业务部门语义;后续需由上游查询结果提供,或在专项任务中补口径。 OccurrenceDeptId = hit.OccurrenceDeptId ?? 0, ResponsibleDeptId = hit.ResponsibleDeptId ?? 0, ReporterId = null, CreatedAt = DateTime.Now, IsDeleted = false, // G-01 首版唯一异常类型映射(baseline 已迁后 EQUIP_FAULT 属 S2 制造协同场景)。 ExceptionTypeCode = "EQUIP_FAULT", ModuleCode = resolvedModule, // S8-PROCESS-NODE-MODULE-CODE-ALIGNMENT-EXEC-1:当前阶段 process_node_code 留空, // module_code 承担 S1-S7 主流程归属;process_node_code 留给未来更细流程节点。 ProcessNodeCode = null, // 追溯三件套(自动建单必填口径)。 SourceRuleId = hit.SourceRuleId, SourceDataSourceId = hit.DataSourceId, SourcePayload = hit.SourcePayload, RelatedObjectCode = hit.RelatedObjectCode }; await _rep.AsTenant().UseTranAsync(async () => { entity = await _rep.AsInsertable(entity).ExecuteReturnEntityAsync(); await _timelineRep.InsertAsync(new AdoS8ExceptionTimeline { ExceptionId = entity.Id, ActionCode = "CREATE", ActionLabel = "创建", FromStatus = null, ToStatus = "NEW", OperatorId = null, ActionRemark = "自动建单", CreatedAt = DateTime.Now }); }, ex => throw ex); await TryStartIntakeFlowAsync(entity); return entity; } /// /// R2 自动建单分支(TIMEOUT 等新 evaluator 走此路径)。 /// 与 并列:复用同一仓储 / 事务 / 时间线 ActionCode; /// 差异点:消费 一份命中模型,把 R2 新列(DedupKey / LastDetectedAt / /// SourceRuleCode / SourceObjectType / SourceObjectId)落齐;ExceptionTypeCode 由 hit 自带, /// 不再硬编码 EQUIP_FAULT。RecoveredAt 本轮不写。 /// 调用方负责前置检查 ExceptionTypeCode 是否在 baseline;本方法不再二次校验。 /// public async Task CreateFromHitAsync(S8RuleHit hit) { if (hit.SourceRuleId <= 0 || string.IsNullOrWhiteSpace(hit.RelatedObjectCode)) throw new S8BizException("自动建单缺失追溯键"); var effectiveScene = string.IsNullOrWhiteSpace(hit.SceneCode) ? S8SceneCode.S2 : hit.SceneCode; // S8-EXCEPTION-CREATION-MODULE-CODE-FIX-1:R2 自动建单 module_code 派生统一走严格 S1-S7 链路; // hit.ModuleCode(evaluator 显式)→ rule/hit.SceneCode(当前 DB 100% S1-S7)→ exception_type.scene_code。 var resolvedModule = await ResolveModuleCodeAsync( explicitModuleCode: null, hitModuleCode: hit.ModuleCode, sceneCode: effectiveScene, exceptionTypeCode: hit.ExceptionTypeCode); if (resolvedModule == null) { _logger.LogWarning( "auto_watch_hit_module_code_unresolved sceneCode={SceneCode} hitModule={HitModule} typeCode={TypeCode} ruleCode={RuleCode}", hit.SceneCode, hit.ModuleCode, hit.ExceptionTypeCode, hit.SourceRuleCode); } var code = $"EX-{DateTime.Now:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}"; var entity = new AdoS8Exception { // 与 CreateFromWatchAsync 当前固定上下文一致;R2 不变更租户/工厂上下文语义。 TenantId = 1, FactoryId = 1, ExceptionCode = code, Title = string.IsNullOrWhiteSpace(hit.Title) ? $"[自动] {hit.SourceObjectType} {hit.SourceObjectId}" : hit.Title, Description = null, SceneCode = effectiveScene, SourceType = "AUTO_WATCH", Status = "NEW", Severity = S8SeverityCode.Normalize(hit.Severity), PriorityScore = 0, PriorityLevel = "P3", OccurrenceDeptId = hit.OccurrenceDeptId ?? 0, ResponsibleDeptId = hit.ResponsibleDeptId ?? 0, ReporterId = null, CreatedAt = DateTime.Now, IsDeleted = false, ExceptionTypeCode = hit.ExceptionTypeCode, ModuleCode = resolvedModule, // S8-PROCESS-NODE-MODULE-CODE-ALIGNMENT-EXEC-1:当前阶段 process_node_code 留空, // module_code 承担 S1-S7 主流程归属;process_node_code 留给未来更细流程节点。 ProcessNodeCode = null, SourceRuleId = hit.SourceRuleId, SourceDataSourceId = hit.DataSourceId == 0 ? null : hit.DataSourceId, SourcePayload = hit.SourcePayload, RelatedObjectCode = hit.RelatedObjectCode, // R2 新列回填 DedupKey = hit.DedupKey, LastDetectedAt = hit.DetectedAt, RecoveredAt = null, SourceRuleCode = hit.SourceRuleCode, SourceObjectType = hit.SourceObjectType, SourceObjectId = hit.SourceObjectId }; await _rep.AsTenant().UseTranAsync(async () => { entity = await _rep.AsInsertable(entity).ExecuteReturnEntityAsync(); await _timelineRep.InsertAsync(new AdoS8ExceptionTimeline { ExceptionId = entity.Id, ActionCode = "CREATE", ActionLabel = "创建", FromStatus = null, ToStatus = "NEW", OperatorId = null, ActionRemark = "自动建单(R2)", CreatedAt = DateTime.Now }); }, ex => throw ex); await TryStartIntakeFlowAsync(entity); return entity; } public async Task GetAsync(long id) => await _rep.GetByIdAsync(id); public async Task AddAttachmentAsync(long id, AdoS8AttachmentCreateDto dto) { var entity = await _rep.GetFirstAsync(x => x.Id == id && !x.IsDeleted) ?? throw new S8BizException("异常不存在"); if (string.IsNullOrWhiteSpace(dto.FileName) || string.IsNullOrWhiteSpace(dto.FileUrl)) throw new S8BizException("附件名称和地址必填"); var evidence = new AdoS8Evidence { ExceptionId = id, EvidenceType = string.IsNullOrWhiteSpace(dto.EvidenceType) ? "file" : dto.EvidenceType, FileName = dto.FileName.Trim(), FileUrl = dto.FileUrl.Trim(), SourceSystem = dto.SourceSystem, UploadedBy = dto.UploadedBy, UploadedAt = DateTime.Now, IsDeleted = false }; await _evidenceRep.InsertAsync(evidence); return evidence; } }