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; 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( long tenantId, 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)) { // S8-TENANT-ONLY-BATCH6:补租户谓词。原实现是 ClearFilter() + TypeCode 等值 + ORDER BY FactoryId DESC, // **一个作用域谓词都没有** —— 全库挑 factory_id 最大的那行,A 租户的 module 可能由 B 租户的配置决定。 var typeRows = await _typeRep.AsQueryable().ClearFilter() .Where(t => t.TypeCode == exceptionTypeCode && t.Enabled && (t.TenantId == tenantId || t.TenantId == S8ConfigScope.GlobalTenantId)) .Select(t => new { t.TenantId, t.SceneCode }) .ToListAsync(); var typeScene = typeRows .OrderByDescending(t => t.TenantId != S8ConfigScope.GlobalTenantId) .Select(t => t.SceneCode) .FirstOrDefault(); 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; // S8-AUTO-WATCH-DEPT-RESOLVE-1(P2):自动建单部门 resolver 需读取 watch_rule.params_json 里的默认部门字段。 private readonly SqlSugarRepository _ruleRep; private readonly ISqlSugarClient _db; private readonly UserManager _userManager; private readonly FlowEngineService _flowEngine; // S8-RULE-READINESS-1:租户部门合法性的唯一实现,与启用前的就绪门禁同源。 private readonly IS8DepartmentScopeValidator _deptValidator; private readonly ILogger _logger; public S8ManualReportService( SqlSugarRepository rep, SqlSugarRepository timelineRep, SqlSugarRepository evidenceRep, SqlSugarRepository sceneRep, SqlSugarRepository deptRep, SqlSugarRepository lineRep, SqlSugarRepository typeRep, SqlSugarRepository ruleRep, ISqlSugarClient db, UserManager userManager, FlowEngineService flowEngine, IS8DepartmentScopeValidator deptValidator, ILogger logger) { _rep = rep; _timelineRep = timelineRep; _evidenceRep = evidenceRep; _sceneRep = sceneRep; _deptRep = deptRep; _lineRep = lineRep; _typeRep = typeRep; _ruleRep = ruleRep; _db = db; _userManager = userManager; _flowEngine = flowEngine; _deptValidator = deptValidator; _logger = logger; } /// /// S8-TENANT-ONLY-BATCH6:可信作用域收敛为租户。 /// /// 原实现还要 SysTenant.OrgId 解析一个 factoryId,并在其 <= 0 时**拒绝建单** /// ("当前租户尚未配置所属机构")。那道门禁保护不了任何东西 —— 工厂号不是隔离维度 —— /// 却让一个只是没配机构的租户完全用不了主动提报。随 factory 一起去掉。 /// private long ResolveTrustedTenantId() => AidopTenantScope.ResolveOrThrow(_userManager); // S8-SLA-TIMEOUT-RUNTIME-1(P3):按 exception_type.sla_minutes 计算 sla_deadline。 // typeCode 空 / type 缺失 / sla_minutes <= 0 → 返回 null(不阻断建单,仅 LogWarning)。 // 不写 timeout_flag;timeout_flag 已降级为 legacy 字段,当前超时由读端基于 sla_deadline + status 在线计算。 // S8-P0-1-SCHEDULER-TRUSTED-SCOPE-1:补可信作用域谓词。 // 原实现只有 ClearFilter() + TypeCode 等值 + ORDER BY FactoryId DESC,**没有任何租户/工厂谓词**, // 会在全库范围内挑「factory_id 最大」的那一行 —— 即 A 租户建单可能取到 B 租户的 sla_minutes。 // // S8-TENANT-ONLY-BATCH6:判据收敛为「本租户覆盖 OR 平台默认」,precedence 改为「租户行优先」。 // ORDER BY FactoryId DESC 必须一起改掉:本批之后租户覆盖行的 factory_id 恒为 0, // 继续按 factory 排序会让平台默认反超租户覆盖,SLA 静默用错口径。 private async Task ResolveSlaDeadlineAsync(long tenantId, string? exceptionTypeCode, DateTime createdAt) { if (string.IsNullOrWhiteSpace(exceptionTypeCode)) return null; var rows = await _typeRep.AsQueryable().ClearFilter() .Where(t => t.TypeCode == exceptionTypeCode && (t.TenantId == tenantId || t.TenantId == S8ConfigScope.GlobalTenantId)) .Select(t => new { t.TenantId, t.SlaMinutes }) .ToListAsync(); var slaMinutes = rows .OrderByDescending(t => t.TenantId != S8ConfigScope.GlobalTenantId) .Select(t => (int?)t.SlaMinutes) .FirstOrDefault(); if (slaMinutes == null) { _logger.LogWarning("s8_sla_type_not_found exceptionTypeCode={TypeCode}", exceptionTypeCode); return null; } if (slaMinutes.Value <= 0) return null; return createdAt.AddMinutes(slaMinutes.Value); } // S8-AUTO-WATCH-DEPT-RESOLVE-1(P2):自动建单部门解析顺序 hit → watch_rule.params_json default → 未归属。 // 单字段独立解析(occurrence / responsible 各自走优先级)。任一字段最终为 null → 调用方按"throw 让 scheduler 跳过"处理。 // 不允许写 0 作为最终值;不硬编码 1=质量部 / 2=生产部;不猜测业务对象部门派生(待后续增强)。 // 协议常量:未归属部门 codename = D-UNASSIGNED;按 factory_ref_id 唯一存在;本批不创建该基线,缺失时 resolver 返回 null。 private const string UnassignedDepartmentCode = "D-UNASSIGNED"; private sealed class AutoWatchDeptResolution { public long? OccurrenceDeptId { get; set; } public long? ResponsibleDeptId { get; set; } public string OccurrenceSource { get; set; } = "failed"; public string ResponsibleSource { get; set; } = "failed"; } private async Task ResolveAutoWatchDepartmentsAsync( string path, long tenantId, long? hitOccurrenceDeptId, long? hitResponsibleDeptId, long? ruleId, string? ruleCode, string? moduleCode, string? sceneCode, string? exceptionTypeCode, string? sourceObjectType, string? sourceObjectId, string? relatedObjectCode, string? dedupKey) { var result = new AutoWatchDeptResolution(); // S8-TENANT-ONLY-BATCH6:部门主数据的边界直接是**租户**,不再需要先解析「主数据在哪个 factory」。 // 原来的三级 ResolveDepartmentFactoryRefIdAsync(trusted factory → 全局配置 → 唯一 D-UNASSIGNED 反推) // 整块删除:它存在的唯一原因是 S8 运营 factory 与部门主数据 factory 可能不是同一个号, // 而 factory 一旦不再是作用域,这个问题本身就不存在了。连带把 S8MasterData.DepartmentFactoryRefId // 这个全局单值配置从建单路径上摘掉——它在多租户下从来就无法正确取值。 // 1) hit dept 优先(按租户校验) var occOk = await ValidateDeptInTenantAsync(hitOccurrenceDeptId, tenantId); var respOk = await ValidateDeptInTenantAsync(hitResponsibleDeptId, tenantId); if (occOk) { result.OccurrenceDeptId = hitOccurrenceDeptId; result.OccurrenceSource = "hit"; } if (respOk) { result.ResponsibleDeptId = hitResponsibleDeptId; result.ResponsibleSource = "hit"; } // 2) watch_rule.params_json 默认部门(按租户校验) if ((!occOk || !respOk) && ruleId.HasValue && ruleId.Value > 0) { var paramsJson = await _ruleRep.AsQueryable() .Where(x => x.Id == ruleId.Value) .Select(x => x.ParamsJson) .FirstAsync(); var (paramsOcc, paramsResp) = ParseParamsDefaultDepts(paramsJson); if (!occOk && paramsOcc.HasValue) { if (await ValidateDeptInTenantAsync(paramsOcc, tenantId)) { result.OccurrenceDeptId = paramsOcc; result.OccurrenceSource = "watch_rule_params"; } else { _logger.LogWarning( "s8_auto_watch_default_dept_invalid path={Path} ruleId={RuleId} ruleCode={RuleCode} field=defaultOccurrenceDeptId value={Value} tenantId={TenantId} reason=not_in_tenant_or_inactive", path, ruleId, ruleCode, paramsOcc, tenantId); } } if (!respOk && paramsResp.HasValue) { if (await ValidateDeptInTenantAsync(paramsResp, tenantId)) { result.ResponsibleDeptId = paramsResp; result.ResponsibleSource = "watch_rule_params"; } else { _logger.LogWarning( "s8_auto_watch_default_dept_invalid path={Path} ruleId={RuleId} ruleCode={RuleCode} field=defaultResponsibleDeptId value={Value} tenantId={TenantId} reason=not_in_tenant_or_inactive", path, ruleId, ruleCode, paramsResp, tenantId); } } } // 3) 未归属部门 fallback(按租户查 D-UNASSIGNED) long? unassignedId = null; if (result.OccurrenceDeptId == null || result.ResponsibleDeptId == null) { unassignedId = await _deptRep.AsQueryable().ClearFilter() .Where(x => x.Department == UnassignedDepartmentCode && x.TenantId == tenantId && x.IsActive) .Select(x => (long?)x.Id) .FirstAsync(); if (result.OccurrenceDeptId == null && unassignedId.HasValue) { result.OccurrenceDeptId = unassignedId; result.OccurrenceSource = "unassigned"; _logger.LogWarning( "s8_auto_watch_dept_unassigned_fallback path={Path} ruleId={RuleId} ruleCode={RuleCode} field=occurrence tenantId={TenantId} unassignedDeptId={UnassignedId} sourceObjectType={SourceObjectType} sourceObjectId={SourceObjectId} relatedObjectCode={RelatedObjectCode} dedupKey={DedupKey}", path, ruleId, ruleCode, tenantId, unassignedId, sourceObjectType, sourceObjectId, relatedObjectCode, dedupKey); } if (result.ResponsibleDeptId == null && unassignedId.HasValue) { result.ResponsibleDeptId = unassignedId; result.ResponsibleSource = "unassigned"; _logger.LogWarning( "s8_auto_watch_dept_unassigned_fallback path={Path} ruleId={RuleId} ruleCode={RuleCode} field=responsible tenantId={TenantId} unassignedDeptId={UnassignedId} sourceObjectType={SourceObjectType} sourceObjectId={SourceObjectId} relatedObjectCode={RelatedObjectCode} dedupKey={DedupKey}", path, ruleId, ruleCode, tenantId, unassignedId, sourceObjectType, sourceObjectId, relatedObjectCode, dedupKey); } } return result; } /// /// S8-TENANT-ONLY-BATCH6:部门必须属于当前租户且 active。 /// S8-RULE-READINESS-1:判据本体已抽到 ,本处只做委派。 /// /// 不要把判据搬回来。启用前的就绪门禁与这里的运行时建单必须用同一份实现: /// 两份"看起来一样"的判断迟早分叉,而分叉的表现是最难查的那种 —— /// 启用检查通过、建单却失败,页面上只剩「已启用 / 成功 / 0 条异常」。 /// private Task ValidateDeptInTenantAsync(long? deptId, long tenantId) => _deptValidator.ExistsInTenantAsync(deptId, tenantId); private static (long? Occurrence, long? Responsible) ParseParamsDefaultDepts(string? paramsJson) { if (string.IsNullOrWhiteSpace(paramsJson)) return (null, null); try { using var doc = System.Text.Json.JsonDocument.Parse(paramsJson); long? occ = ReadJsonLong(doc.RootElement, "defaultOccurrenceDeptId"); long? resp = ReadJsonLong(doc.RootElement, "defaultResponsibleDeptId"); return (occ, resp); } catch (System.Text.Json.JsonException) { return (null, null); } } private static long? ReadJsonLong(System.Text.Json.JsonElement root, string property) { if (root.ValueKind != System.Text.Json.JsonValueKind.Object) return null; if (!root.TryGetProperty(property, out var el)) return null; if (el.ValueKind == System.Text.Json.JsonValueKind.Number && el.TryGetInt64(out var v)) return v; if (el.ValueKind == System.Text.Json.JsonValueKind.String && long.TryParse(el.GetString(), out var sv)) return sv; return null; } /// /// 默认发生部门 = 该账号在本租户最近一次人工提报所选的发生部门(且该部门当前仍有效)。 /// 只作前端表单初值;无历史 / 部门已停用 / 跨租户一律返回 (null, null),不报错、不阻断页面。 /// private async Task<(long? DeptId, string? DeptName)> ResolveLastReportedOccurrenceDeptAsync(long tenantId, long sysUserId) { if (sysUserId <= 0 || tenantId <= 0) return (null, null); var lastDeptId = await _rep.AsQueryable() .Where(x => x.TenantId == tenantId && x.ReporterUserId == sysUserId && x.SourceType == "MANUAL" && x.OccurrenceDeptId > 0 && !x.IsDeleted) .OrderByDescending(x => x.Id) .Select(x => (long?)x.OccurrenceDeptId) .FirstAsync(); if (lastDeptId is not > 0) return (null, null); // 部门可能事后被停用 / 归属改变;回填前按当前有效性复核一次。 // ClearFilter:判据由本查询显式声明(tenant_id == 可信租户),不依赖全局 multi-tenant AOP。 var dept = await _deptRep.AsQueryable().ClearFilter() .Where(x => x.Id == lastDeptId.Value && x.TenantId == tenantId && x.IsActive) .Select(x => new { x.Id, x.Department, x.Descr }) .FirstAsync(); if (dept == null) { _logger.LogInformation( "s8_manual_report_default_dept_stale sysUserId={SysUserId} tenantId={TenantId} deptId={DeptId}", sysUserId, tenantId, lastDeptId.Value); return (null, null); } return (dept.Id, string.IsNullOrWhiteSpace(dept.Descr) ? dept.Department : dept.Descr); } // TASK-002-RESET-DIMENSION-MODEL-DEV-2B:维度字段辅助。 private static string? NormalizeOrNull(string? value) { if (string.IsNullOrWhiteSpace(value)) return null; var t = value.Trim(); return t.Length == 0 ? null : t; } private static string? NormalizeStage(string? value) { var t = NormalizeOrNull(value); if (t == null) return null; return S8ModuleCode.All.Contains(t) ? t : null; } /// /// TASK-002-RESET-DIMENSION-MODEL-DEV-2B:自动建单时从规则复制维度三件套;规则缺失则按 rule_type 推断 rule_mechanism。 /// private async Task<(string? StageCode, string? OrderFlowCode, string? RuleMechanism)> ResolveRuleDimensionsAsync( long ruleId, string? fallbackModuleCode, string? ruleType) { string? stageCode = null; string? orderFlowCode = null; string? ruleMechanism = null; if (ruleId > 0) { var row = await _ruleRep.AsQueryable() .Where(x => x.Id == ruleId) .Select(x => new { x.StageCode, x.OrderFlowCode, x.RuleMechanism, x.RuleType }) .FirstAsync(); if (row != null) { stageCode = NormalizeStage(row.StageCode); orderFlowCode = NormalizeOrNull(row.OrderFlowCode); ruleMechanism = NormalizeOrNull(row.RuleMechanism); ruleType ??= row.RuleType; } } stageCode ??= NormalizeStage(fallbackModuleCode); ruleMechanism ??= ruleType switch { "TIMEOUT" => "DATE", "OUT_OF_RANGE" => "VALUE_RANGE", _ => null, }; return (stageCode, orderFlowCode, ruleMechanism); } /// /// 主动提报推断 ExceptionTypeCode:场景下取启用且 SortNo 最小的一条。 /// baseline 异常类型当前 tenant_id=0/factory_id=0(全局基线),所以匹配条件为 /// 本租户覆盖 OR 平台默认 (tenant_id = 0)。ClearFilter 兜底全局多租户过滤器。 /// 找不到返回 null(保持兼容)。 /// private async Task InferExceptionTypeCodeAsync(long tenantId, string sceneCode) { if (string.IsNullOrWhiteSpace(sceneCode)) return null; return await _typeRep.AsQueryable().ClearFilter() .Where(x => (x.TenantId == tenantId || x.TenantId == 0) && x.SceneCode == sceneCode && x.Enabled) .OrderBy(x => x.SortNo) .Select(x => x.TypeCode) .FirstAsync(); } /// /// TB001 异常提报审批流:自动监控 + 主动提报后软触发,失败仅 warn 日志,不阻断建单。 /// S8-EXCEPTION-FLOW-TENANT-CONTEXT-1:统一走 FlowEngineService 的受信任租户重载, /// 传入 entity.TenantId(该异常自身已确认的归属租户,三个调用方——自动建单的后台 Job 路径 /// 与手工提报的 HTTP 路径——均已在建单前完成租户解析,此处直接复用,不再依赖 /// _userManager.TenantId 隐式取值;后台 Job 场景下 HttpContext 为 null 会导致其恒为 0。 /// 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 ?? "" } }, entity.TenantId); } catch (Exception ex) { // S8-S1-EXCEPTION-FLOW-SYNC-FIX-1:建单起流失败保持 best-effort(不阻断建单),但日志补齐可观测字段。 _logger.LogWarning(ex, "TB001 异常提报审批流触发失败 ExceptionId={Id} ExceptionCode={Code} BizType=EXCEPTION_REPORT InitiatorId={Initiator} source={Source} scene={Scene} err={Err}", entity.Id, entity.ExceptionCode, _userManager.UserId, entity.SourceType, entity.SceneCode, ex.Message); } } public async Task GetFormOptionsAsync() { var tenantId = ResolveTrustedTenantId(); var scenes = await _sceneRep.AsQueryable() .Where(x => x.TenantId == tenantId && x.Enabled) .OrderBy(x => x.SortNo) .Select(x => new { value = x.SceneCode, label = x.SceneName }) .ToListAsync(); // ClearFilter:判据由本方法显式声明(tenant_id == 可信租户),不依赖全局 multi-tenant AOP 的当前行为。 // S8-TENANT-ONLY-BATCH6:硬边界由 factory_ref_id 改为 tenant_id —— factory_ref_id=1000 横跨两个租户, // 原判据会把另一租户的部门 / 产线列进本租户的提报表单。 var departments = await _deptRep.AsQueryable().ClearFilter() .Where(x => x.TenantId == tenantId) .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.TenantId == tenantId) .OrderBy(x => x.Line) .Take(500) .Select(x => new { value = x.Id, label = x.Describe ?? x.Line }) .ToListAsync(); // S8-SYSUSER-ONLY-1:默认发生部门改为「本人上一次提报选的部门」。 // // 原实现是 SysUser → EmployeeMaster.sys_user_id → Employee.Department(字符串) → DepartmentMaster, // 整条链的第一跳就依赖员工绑定 —— 真库 1105 名员工只有 8 个绑了账号, // 也就是说这个"默认值"对 99.3% 的人从来没生效过。SysUser 侧不存在指向 DepartmentMaster 的关系 // (SysUser.OrgId 指的是 SysOrg 公司/工厂树,与 DepartmentMaster 是两棵不相干的树), // 因此不用 SysOrg 硬凑一个近似值。 // // 新口径只依赖 S8 自己的数据,且必须**同租户 + 部门当前仍有效**才回填; // 解析不到就返回 null,前端继续要求手动选择(与原行为一致,CreateAsync 的 dept>0 guard 不变)。 var sysUserId = _userManager.UserId; var (defaultOccDeptId, defaultOccDeptName) = sysUserId > 0 ? await ResolveLastReportedOccurrenceDeptAsync(tenantId, sysUserId) : (null, null); return new { scenes, // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:业务枚举 FOLLOW/SERIOUS 两档。 severities = S8SeverityCode.Options(), departments, lines, materials = Array.Empty(), defaultOccurrenceDeptId = defaultOccDeptId, defaultOccurrenceDeptName = defaultOccDeptName, }; } public async Task CreateAsync(AdoS8ManualReportCreateDto dto) { var tenantId = ResolveTrustedTenantId(); dto.TenantId = tenantId; // S8-TENANT-ONLY-BATCH6:factory_id 降级为兼容列,新建异常一律盖章 0(与规则供给、 // 调度器自动建单同口径)。既有行不批量重写。 dto.FactoryId = S8ConfigScope.GlobalFactoryId; 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。 // S8-SYSUSER-ONLY-1:reporter 与 timeline operator 现在是同一个值、同一个 ID 空间, // 不再经 EmployeeMaster 换算(换不到就写 null 的那条静默失败路径随之消失)。 var currentUserId = _userManager.UserId > 0 ? _userManager.UserId : (long?)null; // 主动提报无前端 type 字段,按场景兜底推断;保证不进"未分类"桶。 var inferredType = await InferExceptionTypeCodeAsync(tenantId, 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( tenantId: tenantId, 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); } // S8-SLA-TIMEOUT-RUNTIME-1(P3):CreatedAt 与 SlaDeadline 共用同一 now,避免漂移。 var now = DateTime.Now; var slaDeadline = await ResolveSlaDeadlineAsync(tenantId, inferredType, now); var code = $"EX-{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-SYSUSER-ONLY-1:reporter_user_id idspace = SysUser.Id(与 assignee/verifier/timeline 同)。 ReporterUserId = currentUserId, ExceptionTypeCode = inferredType, ModuleCode = resolvedModule, // S8-PROCESS-NODE-MODULE-CODE-ALIGNMENT-EXEC-1:当前阶段 process_node_code 留空, // module_code 承担 S1-S7 主流程归属;process_node_code 留给未来更细流程节点。 ProcessNodeCode = null, // S8-MANUAL-RELATED-OBJECT-FILL-1:手工提报支持自由文本关联对象编码(订单项/类订单项), // 空白归 null;不写 source_object_type / source_object_id / dedup_key(仍由自动监控链路独占)。 RelatedObjectCode = string.IsNullOrWhiteSpace(dto.RelatedObjectCode) ? null : dto.RelatedObjectCode.Trim(), CreatedAt = now, // S8-SLA-TIMEOUT-RUNTIME-1:sla_deadline 由 exception_type.sla_minutes 决定;缺配置 → null。 SlaDeadline = slaDeadline, IsDeleted = false, // TASK-002-RESET-DIMENSION-MODEL-DEV-2B:维度归属优先 DTO,缺省回退 module_code;rule_mechanism 固定 MANUAL_REPORT。 StageCode = NormalizeStage(dto.StageCode) ?? resolvedModule, OrderFlowCode = NormalizeOrNull(dto.OrderFlowCode), RuleMechanism = "MANUAL_REPORT", }; 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", OperatorUserId = 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( long tenantId, S8WatchHitResult hit) { // S8-TENANT-ONLY-BATCH6:工厂上下文不再是自动建单的前置条件。 if (tenantId <= 0) throw new S8BizException("自动建单缺失有效租户上下文"); if (hit.SourceRuleId <= 0 || string.IsNullOrWhiteSpace(hit.RelatedObjectCode)) throw new S8BizException("自动建单缺失追溯键"); // S8-SLA-TIMEOUT-RUNTIME-1(P3):CreatedAt 与 SlaDeadline 共用同一 now。 var now = DateTime.Now; var code = $"EX-{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( tenantId: tenantId, 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); } // S8-AUTO-WATCH-DEPT-RESOLVE-1(P2):部门解析顺序 hit → params → unassigned;resolver 失败 → throw。 var deptResolution = await ResolveAutoWatchDepartmentsAsync( path: nameof(CreateFromWatchAsync), tenantId: tenantId, hitOccurrenceDeptId: hit.OccurrenceDeptId, hitResponsibleDeptId: hit.ResponsibleDeptId, ruleId: hit.SourceRuleId, ruleCode: hit.SourceRuleCode, moduleCode: resolvedModule, sceneCode: S8SceneCode.S2, exceptionTypeCode: "EQUIP_FAULT", sourceObjectType: null, sourceObjectId: null, relatedObjectCode: hit.RelatedObjectCode, dedupKey: null); if (!deptResolution.OccurrenceDeptId.HasValue || !deptResolution.ResponsibleDeptId.HasValue) { _logger.LogWarning( "s8_auto_watch_dept_resolve_failed path={Path} ruleId={RuleId} ruleCode={RuleCode} tenantId={TenantId} occSource={OccSource} respSource={RespSource} relatedObjectCode={RelatedObjectCode}", nameof(CreateFromWatchAsync), hit.SourceRuleId, hit.SourceRuleCode, tenantId, deptResolution.OccurrenceSource, deptResolution.ResponsibleSource, hit.RelatedObjectCode); throw new S8BizException($"自动建单部门解析失败:租户 {tenantId} 下 {UnassignedDepartmentCode} 未命中或 inactive"); } var entity = new AdoS8Exception { TenantId = tenantId, // S8-TENANT-ONLY-BATCH6:兼容列,恒 0。 FactoryId = S8ConfigScope.GlobalFactoryId, ExceptionCode = code, Title = title, Description = null, SceneCode = S8SceneCode.S2, // 首版自动监控建单来源标识(字符串值,先不抽常量类)。 SourceType = "AUTO_WATCH", Status = "NEW", Severity = S8SeverityCode.Normalize(hit.Severity), PriorityScore = 0, PriorityLevel = "P3", // S8-AUTO-WATCH-DEPT-RESOLVE-1:经 resolver 严格校验后写入;不再使用 ?? 0 兜底。 OccurrenceDeptId = deptResolution.OccurrenceDeptId.Value, ResponsibleDeptId = deptResolution.ResponsibleDeptId.Value, ReporterUserId = null, CreatedAt = now, // S8-SLA-TIMEOUT-RUNTIME-1:sla_deadline 由 exception_type.sla_minutes 决定(EQUIP_FAULT)。 SlaDeadline = await ResolveSlaDeadlineAsync(tenantId, "EQUIP_FAULT", 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, // S8-STANDARD-DATASET-HARD-CUTOVER-1:物理数据源概念已不存在,此列不再写入。 // 列本身保留:历史异常单里存着真实的旧数据源 Id,删列会让那段血缘无法解释。 SourcePayload = hit.SourcePayload, RelatedObjectCode = hit.RelatedObjectCode, }; // TASK-002-RESET-DIMENSION-MODEL-DEV-2B:从规则复制维度三件套。 var (gWatchStage, gWatchFlow, gWatchMech) = await ResolveRuleDimensionsAsync(hit.SourceRuleId, resolvedModule, ruleType: null); entity.StageCode = gWatchStage; entity.OrderFlowCode = gWatchFlow; entity.RuleMechanism = gWatchMech; _logger.LogInformation( "s8_auto_watch_dept_resolved path={Path} ruleId={RuleId} ruleCode={RuleCode} tenantId={TenantId} occDeptId={OccDeptId} occSource={OccSource} respDeptId={RespDeptId} respSource={RespSource}", nameof(CreateFromWatchAsync), hit.SourceRuleId, hit.SourceRuleCode, tenantId, entity.OccurrenceDeptId, deptResolution.OccurrenceSource, entity.ResponsibleDeptId, deptResolution.ResponsibleSource); 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", OperatorUserId = 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( long tenantId, S8RuleHit hit) { // S8-TENANT-ONLY-BATCH6:工厂上下文不再是自动建单的前置条件。 if (tenantId <= 0) throw new S8BizException("自动建单缺失有效租户上下文"); 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( tenantId: tenantId, 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); } // S8-AUTO-WATCH-DEPT-RESOLVE-1(P2):部门解析顺序 hit → params → unassigned;resolver 失败 → throw。 var deptResolution = await ResolveAutoWatchDepartmentsAsync( path: nameof(CreateFromHitAsync), tenantId: tenantId, hitOccurrenceDeptId: hit.OccurrenceDeptId, hitResponsibleDeptId: hit.ResponsibleDeptId, ruleId: hit.SourceRuleId, ruleCode: hit.SourceRuleCode, moduleCode: resolvedModule, sceneCode: effectiveScene, exceptionTypeCode: hit.ExceptionTypeCode, sourceObjectType: hit.SourceObjectType, sourceObjectId: hit.SourceObjectId, relatedObjectCode: hit.RelatedObjectCode, dedupKey: hit.DedupKey); if (!deptResolution.OccurrenceDeptId.HasValue || !deptResolution.ResponsibleDeptId.HasValue) { _logger.LogWarning( "s8_auto_watch_dept_resolve_failed path={Path} ruleId={RuleId} ruleCode={RuleCode} tenantId={TenantId} occSource={OccSource} respSource={RespSource} sourceObjectType={SourceObjectType} sourceObjectId={SourceObjectId} dedupKey={DedupKey}", nameof(CreateFromHitAsync), hit.SourceRuleId, hit.SourceRuleCode, tenantId, deptResolution.OccurrenceSource, deptResolution.ResponsibleSource, hit.SourceObjectType, hit.SourceObjectId, hit.DedupKey); throw new S8BizException($"自动建单部门解析失败:租户 {tenantId} 下 {UnassignedDepartmentCode} 未命中或 inactive"); } // S8-SLA-TIMEOUT-RUNTIME-1(P3):CreatedAt 与 SlaDeadline 共用同一 now。 var now = DateTime.Now; var code = $"EX-{now:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}"; var slaDeadline = await ResolveSlaDeadlineAsync(tenantId, hit.ExceptionTypeCode, now); var entity = new AdoS8Exception { TenantId = tenantId, // S8-TENANT-ONLY-BATCH6:兼容列,恒 0。 FactoryId = S8ConfigScope.GlobalFactoryId, 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", // S8-AUTO-WATCH-DEPT-RESOLVE-1:经 resolver 严格校验后写入;不再使用 ?? 0 兜底。 OccurrenceDeptId = deptResolution.OccurrenceDeptId.Value, ResponsibleDeptId = deptResolution.ResponsibleDeptId.Value, ReporterUserId = null, CreatedAt = now, // S8-SLA-TIMEOUT-RUNTIME-1:sla_deadline 来自 hit.ExceptionTypeCode 对应 sla_minutes。 SlaDeadline = slaDeadline, 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, // 同上:不再写 source_data_source_id,历史行的值原样保留。 SourcePayload = hit.SourcePayload, RelatedObjectCode = hit.RelatedObjectCode, // R2 新列回填 DedupKey = hit.DedupKey, LastDetectedAt = hit.DetectedAt, RecoveredAt = null, SourceRuleCode = hit.SourceRuleCode, SourceObjectType = hit.SourceObjectType, SourceObjectId = hit.SourceObjectId, }; // TASK-002-RESET-DIMENSION-MODEL-DEV-2B:R2 自动建单同样从规则复制维度三件套。 var (gHitStage, gHitFlow, gHitMech) = await ResolveRuleDimensionsAsync(hit.SourceRuleId, resolvedModule, ruleType: null); entity.StageCode = gHitStage; entity.OrderFlowCode = gHitFlow; entity.RuleMechanism = gHitMech; _logger.LogInformation( "s8_auto_watch_dept_resolved path={Path} ruleId={RuleId} ruleCode={RuleCode} tenantId={TenantId} occDeptId={OccDeptId} occSource={OccSource} respDeptId={RespDeptId} respSource={RespSource} sourceObjectType={SourceObjectType} sourceObjectId={SourceObjectId} dedupKey={DedupKey}", nameof(CreateFromHitAsync), hit.SourceRuleId, hit.SourceRuleCode, tenantId, entity.OccurrenceDeptId, deptResolution.OccurrenceSource, entity.ResponsibleDeptId, deptResolution.ResponsibleSource, hit.SourceObjectType, hit.SourceObjectId, hit.DedupKey); 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", OperatorUserId = null, ActionRemark = "自动建单(R2)", CreatedAt = DateTime.Now }); }, ex => throw ex); await TryStartIntakeFlowAsync(entity); return entity; } // S8-TENANT-FACTORY-P0-CLOSURE-1:按 Id + 服务端可信作用域绑行,禁止裸 Id 读他租户异常。 public async Task GetAsync(long id) { var tenantId = ResolveTrustedTenantId(); return await _rep.GetFirstAsync( x => x.Id == id && x.TenantId == tenantId && !x.IsDeleted); } // S8-TENANT-FACTORY-P0-CLOSURE-1:附件写入同样按可信作用域绑行,禁止裸 Id 往他租户异常挂附件。 public async Task AddAttachmentAsync(long id, AdoS8AttachmentCreateDto dto) { var tenantId = ResolveTrustedTenantId(); var entity = await _rep.GetFirstAsync( x => x.Id == id && x.TenantId == tenantId && !x.IsDeleted) ?? throw new S8NotFoundException("异常不存在"); 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; } }