S8ManualReportService.cs 47 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904
  1. using Admin.NET.Plugin.AiDOP.Dto.S8;
  2. using Admin.NET.Plugin.AiDOP.Entity.S0.Manufacturing;
  3. using Admin.NET.Plugin.AiDOP.Entity.S0.Warehouse;
  4. using Admin.NET.Plugin.AiDOP.Entity.S8;
  5. using Admin.NET.Plugin.AiDOP.Infrastructure;
  6. using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  7. using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
  8. using Admin.NET.Plugin.ApprovalFlow.Service;
  9. using Microsoft.Extensions.Logging;
  10. namespace Admin.NET.Plugin.AiDOP.Service.S8;
  11. public class S8ManualReportService : ITransient
  12. {
  13. // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:业务枚举只保留 FOLLOW/SERIOUS。
  14. // 旧值 LOW/MEDIUM/HIGH/CRITICAL 仍可作为兼容输入(接收后由 S8SeverityCode.Normalize 归一)。
  15. private static readonly HashSet<string> AllowedSeverities = new(StringComparer.OrdinalIgnoreCase)
  16. {
  17. "FOLLOW", "SERIOUS",
  18. "LOW", "MEDIUM", "HIGH", "CRITICAL", // legacy compat
  19. };
  20. // S8-PROCESS-NODE-S1S7-ALIGN-1:process_node_code 当前阶段对齐 S1-S7 订单主流程。
  21. // 优先 module_code(已是 S1-S7),其次按 scene_code 反推;都无法识别则 null。
  22. // S8-PROCESS-NODE-MODULE-CODE-ALIGNMENT-EXEC-1:保留函数挂起待用——本阶段所有建单点不再调用,
  23. // 由 module_code 承担 S1-S7 主流程归属;未来引入更细流程节点(如 S2.PLAN / S6.WO_RELEASE)时恢复使用。
  24. private static string? ResolveProcessNodeCode(string? sceneCode, string? moduleCode)
  25. {
  26. if (!string.IsNullOrWhiteSpace(moduleCode) && S8ModuleCode.All.Contains(moduleCode))
  27. return moduleCode;
  28. var fromScene = S8ModuleCode.FromScene(sceneCode);
  29. if (!string.IsNullOrWhiteSpace(fromScene) && S8ModuleCode.All.Contains(fromScene))
  30. return fromScene;
  31. return null;
  32. }
  33. /// <summary>
  34. /// S8-EXCEPTION-CREATION-MODULE-CODE-FIX-1:建单 module_code 派生统一入口。
  35. /// 优先级:显式 module → hit module → 单模块 sceneCode(S1-S7)→ exception_type.scene_code。
  36. /// 严格 S1-S7 校验,不接受 legacy 复合 scene;最终无法确定时返回 null(caller 决定记日志或拒绝)。
  37. /// </summary>
  38. private async Task<string?> ResolveModuleCodeAsync(
  39. long tenantId,
  40. string? explicitModuleCode,
  41. string? hitModuleCode,
  42. string? sceneCode,
  43. string? exceptionTypeCode)
  44. {
  45. var byExplicit = S8ModuleCode.Normalize(explicitModuleCode);
  46. if (byExplicit != null) return byExplicit;
  47. var byHit = S8ModuleCode.Normalize(hitModuleCode);
  48. if (byHit != null) return byHit;
  49. var byScene = S8ModuleCode.FromCanonicalScene(sceneCode);
  50. if (byScene != null) return byScene;
  51. if (!string.IsNullOrWhiteSpace(exceptionTypeCode))
  52. {
  53. // S8-TENANT-ONLY-BATCH6:补租户谓词。原实现是 ClearFilter() + TypeCode 等值 + ORDER BY FactoryId DESC,
  54. // **一个作用域谓词都没有** —— 全库挑 factory_id 最大的那行,A 租户的 module 可能由 B 租户的配置决定。
  55. var typeRows = await _typeRep.AsQueryable().ClearFilter()
  56. .Where(t => t.TypeCode == exceptionTypeCode && t.Enabled
  57. && (t.TenantId == tenantId || t.TenantId == S8ConfigScope.GlobalTenantId))
  58. .Select(t => new { t.TenantId, t.SceneCode })
  59. .ToListAsync();
  60. var typeScene = typeRows
  61. .OrderByDescending(t => t.TenantId != S8ConfigScope.GlobalTenantId)
  62. .Select(t => t.SceneCode)
  63. .FirstOrDefault();
  64. var byType = S8ModuleCode.FromCanonicalScene(typeScene);
  65. if (byType != null) return byType;
  66. }
  67. return null;
  68. }
  69. private readonly SqlSugarRepository<AdoS8Exception> _rep;
  70. private readonly SqlSugarRepository<AdoS8ExceptionTimeline> _timelineRep;
  71. private readonly SqlSugarRepository<AdoS8Evidence> _evidenceRep;
  72. private readonly SqlSugarRepository<AdoS8SceneConfig> _sceneRep;
  73. private readonly SqlSugarRepository<AdoS0DepartmentMaster> _deptRep;
  74. private readonly SqlSugarRepository<AdoS0LineMaster> _lineRep;
  75. private readonly SqlSugarRepository<AdoS8ExceptionType> _typeRep;
  76. // S8-AUTO-WATCH-DEPT-RESOLVE-1(P2):自动建单部门 resolver 需读取 watch_rule.params_json 里的默认部门字段。
  77. private readonly SqlSugarRepository<AdoS8WatchRule> _ruleRep;
  78. private readonly ISqlSugarClient _db;
  79. private readonly UserManager _userManager;
  80. private readonly FlowEngineService _flowEngine;
  81. // S8-RULE-READINESS-1:租户部门合法性的唯一实现,与启用前的就绪门禁同源。
  82. private readonly IS8DepartmentScopeValidator _deptValidator;
  83. private readonly ILogger<S8ManualReportService> _logger;
  84. public S8ManualReportService(
  85. SqlSugarRepository<AdoS8Exception> rep,
  86. SqlSugarRepository<AdoS8ExceptionTimeline> timelineRep,
  87. SqlSugarRepository<AdoS8Evidence> evidenceRep,
  88. SqlSugarRepository<AdoS8SceneConfig> sceneRep,
  89. SqlSugarRepository<AdoS0DepartmentMaster> deptRep,
  90. SqlSugarRepository<AdoS0LineMaster> lineRep,
  91. SqlSugarRepository<AdoS8ExceptionType> typeRep,
  92. SqlSugarRepository<AdoS8WatchRule> ruleRep,
  93. ISqlSugarClient db,
  94. UserManager userManager,
  95. FlowEngineService flowEngine,
  96. IS8DepartmentScopeValidator deptValidator,
  97. ILogger<S8ManualReportService> logger)
  98. {
  99. _rep = rep;
  100. _timelineRep = timelineRep;
  101. _evidenceRep = evidenceRep;
  102. _sceneRep = sceneRep;
  103. _deptRep = deptRep;
  104. _lineRep = lineRep;
  105. _typeRep = typeRep;
  106. _ruleRep = ruleRep;
  107. _db = db;
  108. _userManager = userManager;
  109. _flowEngine = flowEngine;
  110. _deptValidator = deptValidator;
  111. _logger = logger;
  112. }
  113. /// <summary>
  114. /// S8-TENANT-ONLY-BATCH6:可信作用域收敛为租户。
  115. ///
  116. /// <para>原实现还要 <c>SysTenant.OrgId</c> 解析一个 factoryId,并在其 &lt;= 0 时**拒绝建单**
  117. /// ("当前租户尚未配置所属机构")。那道门禁保护不了任何东西 —— 工厂号不是隔离维度 ——
  118. /// 却让一个只是没配机构的租户完全用不了主动提报。随 factory 一起去掉。</para>
  119. /// </summary>
  120. private long ResolveTrustedTenantId() => AidopTenantScope.ResolveOrThrow(_userManager);
  121. // S8-SLA-TIMEOUT-RUNTIME-1(P3):按 exception_type.sla_minutes 计算 sla_deadline。
  122. // typeCode 空 / type 缺失 / sla_minutes <= 0 → 返回 null(不阻断建单,仅 LogWarning)。
  123. // 不写 timeout_flag;timeout_flag 已降级为 legacy 字段,当前超时由读端基于 sla_deadline + status 在线计算。
  124. // S8-P0-1-SCHEDULER-TRUSTED-SCOPE-1:补可信作用域谓词。
  125. // 原实现只有 ClearFilter() + TypeCode 等值 + ORDER BY FactoryId DESC,**没有任何租户/工厂谓词**,
  126. // 会在全库范围内挑「factory_id 最大」的那一行 —— 即 A 租户建单可能取到 B 租户的 sla_minutes。
  127. //
  128. // S8-TENANT-ONLY-BATCH6:判据收敛为「本租户覆盖 OR 平台默认」,precedence 改为「租户行优先」。
  129. // ORDER BY FactoryId DESC 必须一起改掉:本批之后租户覆盖行的 factory_id 恒为 0,
  130. // 继续按 factory 排序会让平台默认反超租户覆盖,SLA 静默用错口径。
  131. private async Task<DateTime?> ResolveSlaDeadlineAsync(long tenantId, string? exceptionTypeCode, DateTime createdAt)
  132. {
  133. if (string.IsNullOrWhiteSpace(exceptionTypeCode)) return null;
  134. var rows = await _typeRep.AsQueryable().ClearFilter()
  135. .Where(t => t.TypeCode == exceptionTypeCode
  136. && (t.TenantId == tenantId || t.TenantId == S8ConfigScope.GlobalTenantId))
  137. .Select(t => new { t.TenantId, t.SlaMinutes })
  138. .ToListAsync();
  139. var slaMinutes = rows
  140. .OrderByDescending(t => t.TenantId != S8ConfigScope.GlobalTenantId)
  141. .Select(t => (int?)t.SlaMinutes)
  142. .FirstOrDefault();
  143. if (slaMinutes == null)
  144. {
  145. _logger.LogWarning("s8_sla_type_not_found exceptionTypeCode={TypeCode}", exceptionTypeCode);
  146. return null;
  147. }
  148. if (slaMinutes.Value <= 0) return null;
  149. return createdAt.AddMinutes(slaMinutes.Value);
  150. }
  151. // S8-AUTO-WATCH-DEPT-RESOLVE-1(P2):自动建单部门解析顺序 hit → watch_rule.params_json default → 未归属。
  152. // 单字段独立解析(occurrence / responsible 各自走优先级)。任一字段最终为 null → 调用方按"throw 让 scheduler 跳过"处理。
  153. // 不允许写 0 作为最终值;不硬编码 1=质量部 / 2=生产部;不猜测业务对象部门派生(待后续增强)。
  154. // 协议常量:未归属部门 codename = D-UNASSIGNED;按 factory_ref_id 唯一存在;本批不创建该基线,缺失时 resolver 返回 null。
  155. private const string UnassignedDepartmentCode = "D-UNASSIGNED";
  156. private sealed class AutoWatchDeptResolution
  157. {
  158. public long? OccurrenceDeptId { get; set; }
  159. public long? ResponsibleDeptId { get; set; }
  160. public string OccurrenceSource { get; set; } = "failed";
  161. public string ResponsibleSource { get; set; } = "failed";
  162. }
  163. private async Task<AutoWatchDeptResolution> ResolveAutoWatchDepartmentsAsync(
  164. string path,
  165. long tenantId,
  166. long? hitOccurrenceDeptId,
  167. long? hitResponsibleDeptId,
  168. long? ruleId,
  169. string? ruleCode,
  170. string? moduleCode,
  171. string? sceneCode,
  172. string? exceptionTypeCode,
  173. string? sourceObjectType,
  174. string? sourceObjectId,
  175. string? relatedObjectCode,
  176. string? dedupKey)
  177. {
  178. var result = new AutoWatchDeptResolution();
  179. // S8-TENANT-ONLY-BATCH6:部门主数据的边界直接是**租户**,不再需要先解析「主数据在哪个 factory」。
  180. // 原来的三级 ResolveDepartmentFactoryRefIdAsync(trusted factory → 全局配置 → 唯一 D-UNASSIGNED 反推)
  181. // 整块删除:它存在的唯一原因是 S8 运营 factory 与部门主数据 factory 可能不是同一个号,
  182. // 而 factory 一旦不再是作用域,这个问题本身就不存在了。连带把 S8MasterData.DepartmentFactoryRefId
  183. // 这个全局单值配置从建单路径上摘掉——它在多租户下从来就无法正确取值。
  184. // 1) hit dept 优先(按租户校验)
  185. var occOk = await ValidateDeptInTenantAsync(hitOccurrenceDeptId, tenantId);
  186. var respOk = await ValidateDeptInTenantAsync(hitResponsibleDeptId, tenantId);
  187. if (occOk) { result.OccurrenceDeptId = hitOccurrenceDeptId; result.OccurrenceSource = "hit"; }
  188. if (respOk) { result.ResponsibleDeptId = hitResponsibleDeptId; result.ResponsibleSource = "hit"; }
  189. // 2) watch_rule.params_json 默认部门(按租户校验)
  190. if ((!occOk || !respOk) && ruleId.HasValue && ruleId.Value > 0)
  191. {
  192. var paramsJson = await _ruleRep.AsQueryable()
  193. .Where(x => x.Id == ruleId.Value)
  194. .Select(x => x.ParamsJson)
  195. .FirstAsync();
  196. var (paramsOcc, paramsResp) = ParseParamsDefaultDepts(paramsJson);
  197. if (!occOk && paramsOcc.HasValue)
  198. {
  199. if (await ValidateDeptInTenantAsync(paramsOcc, tenantId))
  200. {
  201. result.OccurrenceDeptId = paramsOcc;
  202. result.OccurrenceSource = "watch_rule_params";
  203. }
  204. else
  205. {
  206. _logger.LogWarning(
  207. "s8_auto_watch_default_dept_invalid path={Path} ruleId={RuleId} ruleCode={RuleCode} field=defaultOccurrenceDeptId value={Value} tenantId={TenantId} reason=not_in_tenant_or_inactive",
  208. path, ruleId, ruleCode, paramsOcc, tenantId);
  209. }
  210. }
  211. if (!respOk && paramsResp.HasValue)
  212. {
  213. if (await ValidateDeptInTenantAsync(paramsResp, tenantId))
  214. {
  215. result.ResponsibleDeptId = paramsResp;
  216. result.ResponsibleSource = "watch_rule_params";
  217. }
  218. else
  219. {
  220. _logger.LogWarning(
  221. "s8_auto_watch_default_dept_invalid path={Path} ruleId={RuleId} ruleCode={RuleCode} field=defaultResponsibleDeptId value={Value} tenantId={TenantId} reason=not_in_tenant_or_inactive",
  222. path, ruleId, ruleCode, paramsResp, tenantId);
  223. }
  224. }
  225. }
  226. // 3) 未归属部门 fallback(按租户查 D-UNASSIGNED)
  227. long? unassignedId = null;
  228. if (result.OccurrenceDeptId == null || result.ResponsibleDeptId == null)
  229. {
  230. unassignedId = await _deptRep.AsQueryable().ClearFilter()
  231. .Where(x => x.Department == UnassignedDepartmentCode && x.TenantId == tenantId && x.IsActive)
  232. .Select(x => (long?)x.Id)
  233. .FirstAsync();
  234. if (result.OccurrenceDeptId == null && unassignedId.HasValue)
  235. {
  236. result.OccurrenceDeptId = unassignedId;
  237. result.OccurrenceSource = "unassigned";
  238. _logger.LogWarning(
  239. "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}",
  240. path, ruleId, ruleCode, tenantId, unassignedId, sourceObjectType, sourceObjectId, relatedObjectCode, dedupKey);
  241. }
  242. if (result.ResponsibleDeptId == null && unassignedId.HasValue)
  243. {
  244. result.ResponsibleDeptId = unassignedId;
  245. result.ResponsibleSource = "unassigned";
  246. _logger.LogWarning(
  247. "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}",
  248. path, ruleId, ruleCode, tenantId, unassignedId, sourceObjectType, sourceObjectId, relatedObjectCode, dedupKey);
  249. }
  250. }
  251. return result;
  252. }
  253. /// <summary>
  254. /// S8-TENANT-ONLY-BATCH6:部门必须属于当前租户且 active。
  255. /// S8-RULE-READINESS-1:判据本体已抽到 <see cref="S8DepartmentScopeValidator"/>,本处只做委派。
  256. ///
  257. /// <para><b>不要把判据搬回来。</b>启用前的就绪门禁与这里的运行时建单必须用同一份实现:
  258. /// 两份"看起来一样"的判断迟早分叉,而分叉的表现是最难查的那种 ——
  259. /// 启用检查通过、建单却失败,页面上只剩「已启用 / 成功 / 0 条异常」。</para>
  260. /// </summary>
  261. private Task<bool> ValidateDeptInTenantAsync(long? deptId, long tenantId) =>
  262. _deptValidator.ExistsInTenantAsync(deptId, tenantId);
  263. private static (long? Occurrence, long? Responsible) ParseParamsDefaultDepts(string? paramsJson)
  264. {
  265. if (string.IsNullOrWhiteSpace(paramsJson)) return (null, null);
  266. try
  267. {
  268. using var doc = System.Text.Json.JsonDocument.Parse(paramsJson);
  269. long? occ = ReadJsonLong(doc.RootElement, "defaultOccurrenceDeptId");
  270. long? resp = ReadJsonLong(doc.RootElement, "defaultResponsibleDeptId");
  271. return (occ, resp);
  272. }
  273. catch (System.Text.Json.JsonException) { return (null, null); }
  274. }
  275. private static long? ReadJsonLong(System.Text.Json.JsonElement root, string property)
  276. {
  277. if (root.ValueKind != System.Text.Json.JsonValueKind.Object) return null;
  278. if (!root.TryGetProperty(property, out var el)) return null;
  279. if (el.ValueKind == System.Text.Json.JsonValueKind.Number && el.TryGetInt64(out var v)) return v;
  280. if (el.ValueKind == System.Text.Json.JsonValueKind.String && long.TryParse(el.GetString(), out var sv)) return sv;
  281. return null;
  282. }
  283. /// <summary>
  284. /// 默认发生部门 = 该账号在本租户最近一次人工提报所选的发生部门(且该部门当前仍有效)。
  285. /// 只作前端表单初值;无历史 / 部门已停用 / 跨租户一律返回 (null, null),不报错、不阻断页面。
  286. /// </summary>
  287. private async Task<(long? DeptId, string? DeptName)> ResolveLastReportedOccurrenceDeptAsync(long tenantId, long sysUserId)
  288. {
  289. if (sysUserId <= 0 || tenantId <= 0) return (null, null);
  290. var lastDeptId = await _rep.AsQueryable()
  291. .Where(x => x.TenantId == tenantId
  292. && x.ReporterUserId == sysUserId
  293. && x.SourceType == "MANUAL"
  294. && x.OccurrenceDeptId > 0
  295. && !x.IsDeleted)
  296. .OrderByDescending(x => x.Id)
  297. .Select(x => (long?)x.OccurrenceDeptId)
  298. .FirstAsync();
  299. if (lastDeptId is not > 0) return (null, null);
  300. // 部门可能事后被停用 / 归属改变;回填前按当前有效性复核一次。
  301. // ClearFilter:判据由本查询显式声明(tenant_id == 可信租户),不依赖全局 multi-tenant AOP。
  302. var dept = await _deptRep.AsQueryable().ClearFilter()
  303. .Where(x => x.Id == lastDeptId.Value && x.TenantId == tenantId && x.IsActive)
  304. .Select(x => new { x.Id, x.Department, x.Descr })
  305. .FirstAsync();
  306. if (dept == null)
  307. {
  308. _logger.LogInformation(
  309. "s8_manual_report_default_dept_stale sysUserId={SysUserId} tenantId={TenantId} deptId={DeptId}",
  310. sysUserId, tenantId, lastDeptId.Value);
  311. return (null, null);
  312. }
  313. return (dept.Id, string.IsNullOrWhiteSpace(dept.Descr) ? dept.Department : dept.Descr);
  314. }
  315. // TASK-002-RESET-DIMENSION-MODEL-DEV-2B:维度字段辅助。
  316. private static string? NormalizeOrNull(string? value)
  317. {
  318. if (string.IsNullOrWhiteSpace(value)) return null;
  319. var t = value.Trim();
  320. return t.Length == 0 ? null : t;
  321. }
  322. private static string? NormalizeStage(string? value)
  323. {
  324. var t = NormalizeOrNull(value);
  325. if (t == null) return null;
  326. return S8ModuleCode.All.Contains(t) ? t : null;
  327. }
  328. /// <summary>
  329. /// TASK-002-RESET-DIMENSION-MODEL-DEV-2B:自动建单时从规则复制维度三件套;规则缺失则按 rule_type 推断 rule_mechanism。
  330. /// </summary>
  331. private async Task<(string? StageCode, string? OrderFlowCode, string? RuleMechanism)> ResolveRuleDimensionsAsync(
  332. long ruleId, string? fallbackModuleCode, string? ruleType)
  333. {
  334. string? stageCode = null;
  335. string? orderFlowCode = null;
  336. string? ruleMechanism = null;
  337. if (ruleId > 0)
  338. {
  339. var row = await _ruleRep.AsQueryable()
  340. .Where(x => x.Id == ruleId)
  341. .Select(x => new { x.StageCode, x.OrderFlowCode, x.RuleMechanism, x.RuleType })
  342. .FirstAsync();
  343. if (row != null)
  344. {
  345. stageCode = NormalizeStage(row.StageCode);
  346. orderFlowCode = NormalizeOrNull(row.OrderFlowCode);
  347. ruleMechanism = NormalizeOrNull(row.RuleMechanism);
  348. ruleType ??= row.RuleType;
  349. }
  350. }
  351. stageCode ??= NormalizeStage(fallbackModuleCode);
  352. ruleMechanism ??= ruleType switch
  353. {
  354. "TIMEOUT" => "DATE",
  355. "OUT_OF_RANGE" => "VALUE_RANGE",
  356. _ => null,
  357. };
  358. return (stageCode, orderFlowCode, ruleMechanism);
  359. }
  360. /// <summary>
  361. /// 主动提报推断 ExceptionTypeCode:场景下取启用且 SortNo 最小的一条。
  362. /// baseline 异常类型当前 tenant_id=0/factory_id=0(全局基线),所以匹配条件为
  363. /// 本租户覆盖 OR 平台默认 (tenant_id = 0)。ClearFilter 兜底全局多租户过滤器。
  364. /// 找不到返回 null(保持兼容)。
  365. /// </summary>
  366. private async Task<string?> InferExceptionTypeCodeAsync(long tenantId, string sceneCode)
  367. {
  368. if (string.IsNullOrWhiteSpace(sceneCode)) return null;
  369. return await _typeRep.AsQueryable().ClearFilter()
  370. .Where(x => (x.TenantId == tenantId || x.TenantId == 0)
  371. && x.SceneCode == sceneCode && x.Enabled)
  372. .OrderBy(x => x.SortNo)
  373. .Select(x => x.TypeCode)
  374. .FirstAsync();
  375. }
  376. /// <summary>
  377. /// TB001 异常提报审批流:自动监控 + 主动提报后软触发,失败仅 warn 日志,不阻断建单。
  378. /// S8-EXCEPTION-FLOW-TENANT-CONTEXT-1:统一走 FlowEngineService 的受信任租户重载,
  379. /// 传入 entity.TenantId(该异常自身已确认的归属租户,三个调用方——自动建单的后台 Job 路径
  380. /// 与手工提报的 HTTP 路径——均已在建单前完成租户解析,此处直接复用,不再依赖
  381. /// _userManager.TenantId 隐式取值;后台 Job 场景下 HttpContext 为 null 会导致其恒为 0。
  382. /// </summary>
  383. private async Task TryStartIntakeFlowAsync(AdoS8Exception entity)
  384. {
  385. try
  386. {
  387. await _flowEngine.StartFlow(new StartFlowInput
  388. {
  389. BizType = "EXCEPTION_REPORT",
  390. BizId = entity.Id,
  391. BizNo = entity.ExceptionCode,
  392. Title = $"异常提报 - {entity.ExceptionCode}",
  393. Comment = entity.SourceType == "AUTO_WATCH" ? "自动监控触发" : "主动提报触发",
  394. BizData = new Dictionary<string, object>
  395. {
  396. ["sceneCode"] = entity.SceneCode ?? "",
  397. ["exceptionTypeCode"] = entity.ExceptionTypeCode ?? "",
  398. ["sourceType"] = entity.SourceType ?? ""
  399. }
  400. }, entity.TenantId);
  401. }
  402. catch (Exception ex)
  403. {
  404. // S8-S1-EXCEPTION-FLOW-SYNC-FIX-1:建单起流失败保持 best-effort(不阻断建单),但日志补齐可观测字段。
  405. _logger.LogWarning(ex,
  406. "TB001 异常提报审批流触发失败 ExceptionId={Id} ExceptionCode={Code} BizType=EXCEPTION_REPORT InitiatorId={Initiator} source={Source} scene={Scene} err={Err}",
  407. entity.Id, entity.ExceptionCode, _userManager.UserId, entity.SourceType, entity.SceneCode, ex.Message);
  408. }
  409. }
  410. public async Task<object> GetFormOptionsAsync()
  411. {
  412. var tenantId = ResolveTrustedTenantId();
  413. var scenes = await _sceneRep.AsQueryable()
  414. .Where(x => x.TenantId == tenantId && x.Enabled)
  415. .OrderBy(x => x.SortNo)
  416. .Select(x => new { value = x.SceneCode, label = x.SceneName })
  417. .ToListAsync();
  418. // ClearFilter:判据由本方法显式声明(tenant_id == 可信租户),不依赖全局 multi-tenant AOP 的当前行为。
  419. // S8-TENANT-ONLY-BATCH6:硬边界由 factory_ref_id 改为 tenant_id —— factory_ref_id=1000 横跨两个租户,
  420. // 原判据会把另一租户的部门 / 产线列进本租户的提报表单。
  421. var departments = await _deptRep.AsQueryable().ClearFilter()
  422. .Where(x => x.TenantId == tenantId)
  423. .OrderBy(x => x.Department)
  424. .Take(500)
  425. .Select(x => new { value = x.Id, label = x.Descr ?? x.Department })
  426. .ToListAsync();
  427. var lines = await _lineRep.AsQueryable().ClearFilter()
  428. .Where(x => x.TenantId == tenantId)
  429. .OrderBy(x => x.Line)
  430. .Take(500)
  431. .Select(x => new { value = x.Id, label = x.Describe ?? x.Line })
  432. .ToListAsync();
  433. // S8-SYSUSER-ONLY-1:默认发生部门改为「本人上一次提报选的部门」。
  434. //
  435. // 原实现是 SysUser → EmployeeMaster.sys_user_id → Employee.Department(字符串) → DepartmentMaster,
  436. // 整条链的第一跳就依赖员工绑定 —— 真库 1105 名员工只有 8 个绑了账号,
  437. // 也就是说这个"默认值"对 99.3% 的人从来没生效过。SysUser 侧不存在指向 DepartmentMaster 的关系
  438. // (SysUser.OrgId 指的是 SysOrg 公司/工厂树,与 DepartmentMaster 是两棵不相干的树),
  439. // 因此不用 SysOrg 硬凑一个近似值。
  440. //
  441. // 新口径只依赖 S8 自己的数据,且必须**同租户 + 部门当前仍有效**才回填;
  442. // 解析不到就返回 null,前端继续要求手动选择(与原行为一致,CreateAsync 的 dept>0 guard 不变)。
  443. var sysUserId = _userManager.UserId;
  444. var (defaultOccDeptId, defaultOccDeptName) = sysUserId > 0
  445. ? await ResolveLastReportedOccurrenceDeptAsync(tenantId, sysUserId)
  446. : (null, null);
  447. return new
  448. {
  449. scenes,
  450. // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:业务枚举 FOLLOW/SERIOUS 两档。
  451. severities = S8SeverityCode.Options(),
  452. departments,
  453. lines,
  454. materials = Array.Empty<object>(),
  455. defaultOccurrenceDeptId = defaultOccDeptId,
  456. defaultOccurrenceDeptName = defaultOccDeptName,
  457. };
  458. }
  459. public async Task<AdoS8ManualReportResultDto> CreateAsync(AdoS8ManualReportCreateDto dto)
  460. {
  461. var tenantId = ResolveTrustedTenantId();
  462. dto.TenantId = tenantId;
  463. // S8-TENANT-ONLY-BATCH6:factory_id 降级为兼容列,新建异常一律盖章 0(与规则供给、
  464. // 调度器自动建单同口径)。既有行不批量重写。
  465. dto.FactoryId = S8ConfigScope.GlobalFactoryId;
  466. if (string.IsNullOrWhiteSpace(dto.Title)) throw new S8BizException("标题必填");
  467. if (string.IsNullOrWhiteSpace(dto.SceneCode)) throw new S8BizException("场景必填");
  468. // S8-MANUAL-REPORT-DEPT-ZERO-GUARD-1(P0-B-3):人工提报禁止 dept=0 入库。
  469. // 字段类型 long(非空),未填默认 0;guard 命中 <=0 直接拒绝,绝不转 null/默认部门/未归属。
  470. // 范围仅限本入口;自动建单(CreateFromWatchAsync/CreateFromHitAsync)的 ?? 0 风险归 P0-B-4。
  471. if (dto.OccurrenceDeptId <= 0) throw new S8BizException("发生部门不能为空,请选择有效发生部门");
  472. if (dto.ResponsibleDeptId <= 0) throw new S8BizException("处理部门不能为空,请选择有效处理部门");
  473. // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:白名单接受新值(FOLLOW/SERIOUS)+ 旧值兼容;
  474. // 通过 Normalize 写入 DB 一律为 FOLLOW/SERIOUS。
  475. var rawSeverity = string.IsNullOrWhiteSpace(dto.Severity) ? S8SeverityCode.Follow : dto.Severity.Trim();
  476. if (!AllowedSeverities.Contains(rawSeverity))
  477. throw new S8BizException($"严重度 {rawSeverity} 非法,仅允许 FOLLOW/SERIOUS");
  478. var severity = S8SeverityCode.Normalize(rawSeverity);
  479. // 提报人以服务端登录上下文为准,忽略前端传入;未登录上下文落 null。
  480. // S8-SYSUSER-ONLY-1:reporter 与 timeline operator 现在是同一个值、同一个 ID 空间,
  481. // 不再经 EmployeeMaster 换算(换不到就写 null 的那条静默失败路径随之消失)。
  482. var currentUserId = _userManager.UserId > 0 ? _userManager.UserId : (long?)null;
  483. // 主动提报无前端 type 字段,按场景兜底推断;保证不进"未分类"桶。
  484. var inferredType = await InferExceptionTypeCodeAsync(tenantId, dto.SceneCode.Trim());
  485. // S8-EXCEPTION-CREATION-MODULE-CODE-FIX-1:手工提报 module_code 严格按 S1-S7 派生;
  486. // dto 暂不携带显式 module_code,按 scene → exception_type.scene 链路降级。
  487. var resolvedModule = await ResolveModuleCodeAsync(
  488. tenantId: tenantId,
  489. explicitModuleCode: null,
  490. hitModuleCode: null,
  491. sceneCode: dto.SceneCode.Trim(),
  492. exceptionTypeCode: inferredType);
  493. if (resolvedModule == null)
  494. {
  495. _logger.LogWarning(
  496. "manual_report_module_code_unresolved sceneCode={SceneCode} exceptionTypeCode={TypeCode} title={Title}",
  497. dto.SceneCode, inferredType, dto.Title);
  498. }
  499. // S8-SLA-TIMEOUT-RUNTIME-1(P3):CreatedAt 与 SlaDeadline 共用同一 now,避免漂移。
  500. var now = DateTime.Now;
  501. var slaDeadline = await ResolveSlaDeadlineAsync(tenantId, inferredType, now);
  502. var code = $"EX-{now:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
  503. var entity = new AdoS8Exception
  504. {
  505. TenantId = dto.TenantId,
  506. FactoryId = dto.FactoryId,
  507. ExceptionCode = code,
  508. Title = dto.Title.Trim(),
  509. Description = dto.Description,
  510. SceneCode = dto.SceneCode.Trim(),
  511. SourceType = "MANUAL",
  512. Status = "NEW",
  513. Severity = severity,
  514. PriorityScore = 0,
  515. PriorityLevel = "P3",
  516. OccurrenceDeptId = dto.OccurrenceDeptId,
  517. ResponsibleDeptId = dto.ResponsibleDeptId,
  518. // S8-SYSUSER-ONLY-1:reporter_user_id idspace = SysUser.Id(与 assignee/verifier/timeline 同)。
  519. ReporterUserId = currentUserId,
  520. ExceptionTypeCode = inferredType,
  521. ModuleCode = resolvedModule,
  522. // S8-PROCESS-NODE-MODULE-CODE-ALIGNMENT-EXEC-1:当前阶段 process_node_code 留空,
  523. // module_code 承担 S1-S7 主流程归属;process_node_code 留给未来更细流程节点。
  524. ProcessNodeCode = null,
  525. // S8-MANUAL-RELATED-OBJECT-FILL-1:手工提报支持自由文本关联对象编码(订单项/类订单项),
  526. // 空白归 null;不写 source_object_type / source_object_id / dedup_key(仍由自动监控链路独占)。
  527. RelatedObjectCode = string.IsNullOrWhiteSpace(dto.RelatedObjectCode) ? null : dto.RelatedObjectCode.Trim(),
  528. CreatedAt = now,
  529. // S8-SLA-TIMEOUT-RUNTIME-1:sla_deadline 由 exception_type.sla_minutes 决定;缺配置 → null。
  530. SlaDeadline = slaDeadline,
  531. IsDeleted = false,
  532. // TASK-002-RESET-DIMENSION-MODEL-DEV-2B:维度归属优先 DTO,缺省回退 module_code;rule_mechanism 固定 MANUAL_REPORT。
  533. StageCode = NormalizeStage(dto.StageCode) ?? resolvedModule,
  534. OrderFlowCode = NormalizeOrNull(dto.OrderFlowCode),
  535. RuleMechanism = "MANUAL_REPORT",
  536. };
  537. await _rep.AsTenant().UseTranAsync(async () =>
  538. {
  539. entity = await _rep.AsInsertable(entity).ExecuteReturnEntityAsync();
  540. await _timelineRep.InsertAsync(new AdoS8ExceptionTimeline
  541. {
  542. ExceptionId = entity.Id,
  543. ActionCode = "CREATE",
  544. ActionLabel = "创建",
  545. FromStatus = null,
  546. ToStatus = "NEW",
  547. OperatorUserId = currentUserId,
  548. ActionRemark = "主动提报",
  549. CreatedAt = DateTime.Now
  550. });
  551. }, ex => throw ex);
  552. await TryStartIntakeFlowAsync(entity);
  553. return new AdoS8ManualReportResultDto
  554. {
  555. ExceptionId = entity.Id,
  556. ExceptionCode = entity.ExceptionCode,
  557. TaskId = entity.Id
  558. };
  559. }
  560. /// <summary>
  561. /// G01-06:自动建单分支(非第二套创建主链)。
  562. /// 这是本服务内的自动监控建单路径,与 <see cref="CreateAsync"/> 并列,
  563. /// 复用同一仓储(_rep / _timelineRep)、同一事务边界、同一 ExceptionCode 生成规则、
  564. /// 同一时间线主链(ActionCode="CREATE"、ToStatus="NEW");仅差异点:
  565. /// - SourceType 标识为自动监控来源
  566. /// - 填入 SourceRuleId / SourceDataSourceId / SourcePayload / RelatedObjectCode 追溯
  567. /// - ExceptionTypeCode 固定 EQUIP_FAULT(G-01 首版唯一映射)
  568. /// - SceneCode 固定 S2(G-01 首版唯一场景,迁移后从 S2S6_PRODUCTION 切到单模块 S2)
  569. /// 不做补偿、重试、对账;失败由调用方接住。
  570. /// </summary>
  571. public async Task<AdoS8Exception> CreateFromWatchAsync(
  572. long tenantId,
  573. S8WatchHitResult hit)
  574. {
  575. // S8-TENANT-ONLY-BATCH6:工厂上下文不再是自动建单的前置条件。
  576. if (tenantId <= 0)
  577. throw new S8BizException("自动建单缺失有效租户上下文");
  578. if (hit.SourceRuleId <= 0 || string.IsNullOrWhiteSpace(hit.RelatedObjectCode))
  579. throw new S8BizException("自动建单缺失追溯键");
  580. // S8-SLA-TIMEOUT-RUNTIME-1(P3):CreatedAt 与 SlaDeadline 共用同一 now。
  581. var now = DateTime.Now;
  582. var code = $"EX-{now:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
  583. var title = $"[自动] 设备 {hit.RelatedObjectCode} {hit.TriggerCondition} {hit.ThresholdValue}(当前 {hit.CurrentValue})";
  584. // S8-EXCEPTION-CREATION-MODULE-CODE-FIX-1:固定 SceneCode=S2 + ExceptionTypeCode=EQUIP_FAULT,
  585. // 派生链路通过 ResolveModuleCodeAsync 严格按 S1-S7 走(结果稳定为 S2,但消除 FromScene 的 legacy 兼容路径)。
  586. var resolvedModule = await ResolveModuleCodeAsync(
  587. tenantId: tenantId,
  588. explicitModuleCode: null,
  589. hitModuleCode: null,
  590. sceneCode: S8SceneCode.S2,
  591. exceptionTypeCode: "EQUIP_FAULT");
  592. if (resolvedModule == null)
  593. {
  594. _logger.LogWarning(
  595. "auto_watch_module_code_unresolved sceneCode={SceneCode} exceptionTypeCode={TypeCode} ruleId={RuleId}",
  596. S8SceneCode.S2, "EQUIP_FAULT", hit.SourceRuleId);
  597. }
  598. // S8-AUTO-WATCH-DEPT-RESOLVE-1(P2):部门解析顺序 hit → params → unassigned;resolver 失败 → throw。
  599. var deptResolution = await ResolveAutoWatchDepartmentsAsync(
  600. path: nameof(CreateFromWatchAsync),
  601. tenantId: tenantId,
  602. hitOccurrenceDeptId: hit.OccurrenceDeptId,
  603. hitResponsibleDeptId: hit.ResponsibleDeptId,
  604. ruleId: hit.SourceRuleId,
  605. ruleCode: hit.SourceRuleCode,
  606. moduleCode: resolvedModule,
  607. sceneCode: S8SceneCode.S2,
  608. exceptionTypeCode: "EQUIP_FAULT",
  609. sourceObjectType: null,
  610. sourceObjectId: null,
  611. relatedObjectCode: hit.RelatedObjectCode,
  612. dedupKey: null);
  613. if (!deptResolution.OccurrenceDeptId.HasValue || !deptResolution.ResponsibleDeptId.HasValue)
  614. {
  615. _logger.LogWarning(
  616. "s8_auto_watch_dept_resolve_failed path={Path} ruleId={RuleId} ruleCode={RuleCode} tenantId={TenantId} occSource={OccSource} respSource={RespSource} relatedObjectCode={RelatedObjectCode}",
  617. nameof(CreateFromWatchAsync), hit.SourceRuleId, hit.SourceRuleCode, tenantId,
  618. deptResolution.OccurrenceSource, deptResolution.ResponsibleSource, hit.RelatedObjectCode);
  619. throw new S8BizException($"自动建单部门解析失败:租户 {tenantId} 下 {UnassignedDepartmentCode} 未命中或 inactive");
  620. }
  621. var entity = new AdoS8Exception
  622. {
  623. TenantId = tenantId,
  624. // S8-TENANT-ONLY-BATCH6:兼容列,恒 0。
  625. FactoryId = S8ConfigScope.GlobalFactoryId,
  626. ExceptionCode = code,
  627. Title = title,
  628. Description = null,
  629. SceneCode = S8SceneCode.S2,
  630. // 首版自动监控建单来源标识(字符串值,先不抽常量类)。
  631. SourceType = "AUTO_WATCH",
  632. Status = "NEW",
  633. Severity = S8SeverityCode.Normalize(hit.Severity),
  634. PriorityScore = 0,
  635. PriorityLevel = "P3",
  636. // S8-AUTO-WATCH-DEPT-RESOLVE-1:经 resolver 严格校验后写入;不再使用 ?? 0 兜底。
  637. OccurrenceDeptId = deptResolution.OccurrenceDeptId.Value,
  638. ResponsibleDeptId = deptResolution.ResponsibleDeptId.Value,
  639. ReporterUserId = null,
  640. CreatedAt = now,
  641. // S8-SLA-TIMEOUT-RUNTIME-1:sla_deadline 由 exception_type.sla_minutes 决定(EQUIP_FAULT)。
  642. SlaDeadline = await ResolveSlaDeadlineAsync(tenantId, "EQUIP_FAULT", now),
  643. IsDeleted = false,
  644. // G-01 首版唯一异常类型映射(baseline 已迁后 EQUIP_FAULT 属 S2 制造协同场景)。
  645. ExceptionTypeCode = "EQUIP_FAULT",
  646. ModuleCode = resolvedModule,
  647. // S8-PROCESS-NODE-MODULE-CODE-ALIGNMENT-EXEC-1:当前阶段 process_node_code 留空,
  648. // module_code 承担 S1-S7 主流程归属;process_node_code 留给未来更细流程节点。
  649. ProcessNodeCode = null,
  650. // 追溯三件套(自动建单必填口径)。
  651. SourceRuleId = hit.SourceRuleId,
  652. // S8-STANDARD-DATASET-HARD-CUTOVER-1:物理数据源概念已不存在,此列不再写入。
  653. // 列本身保留:历史异常单里存着真实的旧数据源 Id,删列会让那段血缘无法解释。
  654. SourcePayload = hit.SourcePayload,
  655. RelatedObjectCode = hit.RelatedObjectCode,
  656. };
  657. // TASK-002-RESET-DIMENSION-MODEL-DEV-2B:从规则复制维度三件套。
  658. var (gWatchStage, gWatchFlow, gWatchMech) = await ResolveRuleDimensionsAsync(hit.SourceRuleId, resolvedModule, ruleType: null);
  659. entity.StageCode = gWatchStage;
  660. entity.OrderFlowCode = gWatchFlow;
  661. entity.RuleMechanism = gWatchMech;
  662. _logger.LogInformation(
  663. "s8_auto_watch_dept_resolved path={Path} ruleId={RuleId} ruleCode={RuleCode} tenantId={TenantId} occDeptId={OccDeptId} occSource={OccSource} respDeptId={RespDeptId} respSource={RespSource}",
  664. nameof(CreateFromWatchAsync), hit.SourceRuleId, hit.SourceRuleCode, tenantId,
  665. entity.OccurrenceDeptId, deptResolution.OccurrenceSource, entity.ResponsibleDeptId, deptResolution.ResponsibleSource);
  666. await _rep.AsTenant().UseTranAsync(async () =>
  667. {
  668. entity = await _rep.AsInsertable(entity).ExecuteReturnEntityAsync();
  669. await _timelineRep.InsertAsync(new AdoS8ExceptionTimeline
  670. {
  671. ExceptionId = entity.Id,
  672. ActionCode = "CREATE",
  673. ActionLabel = "创建",
  674. FromStatus = null,
  675. ToStatus = "NEW",
  676. OperatorUserId = null,
  677. ActionRemark = "自动建单",
  678. CreatedAt = DateTime.Now
  679. });
  680. }, ex => throw ex);
  681. await TryStartIntakeFlowAsync(entity);
  682. return entity;
  683. }
  684. /// <summary>
  685. /// R2 自动建单分支(TIMEOUT 等新 evaluator 走此路径)。
  686. /// 与 <see cref="CreateFromWatchAsync"/> 并列:复用同一仓储 / 事务 / 时间线 ActionCode;
  687. /// 差异点:消费 <see cref="S8RuleHit"/> 一份命中模型,把 R2 新列(DedupKey / LastDetectedAt /
  688. /// SourceRuleCode / SourceObjectType / SourceObjectId)落齐;ExceptionTypeCode 由 hit 自带,
  689. /// 不再硬编码 EQUIP_FAULT。RecoveredAt 本轮不写。
  690. /// 调用方负责前置检查 ExceptionTypeCode 是否在 baseline;本方法不再二次校验。
  691. /// </summary>
  692. public async Task<AdoS8Exception> CreateFromHitAsync(
  693. long tenantId,
  694. S8RuleHit hit)
  695. {
  696. // S8-TENANT-ONLY-BATCH6:工厂上下文不再是自动建单的前置条件。
  697. if (tenantId <= 0)
  698. throw new S8BizException("自动建单缺失有效租户上下文");
  699. if (hit.SourceRuleId <= 0 || string.IsNullOrWhiteSpace(hit.RelatedObjectCode))
  700. throw new S8BizException("自动建单缺失追溯键");
  701. var effectiveScene = string.IsNullOrWhiteSpace(hit.SceneCode) ? S8SceneCode.S2 : hit.SceneCode;
  702. // S8-EXCEPTION-CREATION-MODULE-CODE-FIX-1:R2 自动建单 module_code 派生统一走严格 S1-S7 链路;
  703. // hit.ModuleCode(evaluator 显式)→ rule/hit.SceneCode(当前 DB 100% S1-S7)→ exception_type.scene_code。
  704. var resolvedModule = await ResolveModuleCodeAsync(
  705. tenantId: tenantId,
  706. explicitModuleCode: null,
  707. hitModuleCode: hit.ModuleCode,
  708. sceneCode: effectiveScene,
  709. exceptionTypeCode: hit.ExceptionTypeCode);
  710. if (resolvedModule == null)
  711. {
  712. _logger.LogWarning(
  713. "auto_watch_hit_module_code_unresolved sceneCode={SceneCode} hitModule={HitModule} typeCode={TypeCode} ruleCode={RuleCode}",
  714. hit.SceneCode, hit.ModuleCode, hit.ExceptionTypeCode, hit.SourceRuleCode);
  715. }
  716. // S8-AUTO-WATCH-DEPT-RESOLVE-1(P2):部门解析顺序 hit → params → unassigned;resolver 失败 → throw。
  717. var deptResolution = await ResolveAutoWatchDepartmentsAsync(
  718. path: nameof(CreateFromHitAsync),
  719. tenantId: tenantId,
  720. hitOccurrenceDeptId: hit.OccurrenceDeptId,
  721. hitResponsibleDeptId: hit.ResponsibleDeptId,
  722. ruleId: hit.SourceRuleId,
  723. ruleCode: hit.SourceRuleCode,
  724. moduleCode: resolvedModule,
  725. sceneCode: effectiveScene,
  726. exceptionTypeCode: hit.ExceptionTypeCode,
  727. sourceObjectType: hit.SourceObjectType,
  728. sourceObjectId: hit.SourceObjectId,
  729. relatedObjectCode: hit.RelatedObjectCode,
  730. dedupKey: hit.DedupKey);
  731. if (!deptResolution.OccurrenceDeptId.HasValue || !deptResolution.ResponsibleDeptId.HasValue)
  732. {
  733. _logger.LogWarning(
  734. "s8_auto_watch_dept_resolve_failed path={Path} ruleId={RuleId} ruleCode={RuleCode} tenantId={TenantId} occSource={OccSource} respSource={RespSource} sourceObjectType={SourceObjectType} sourceObjectId={SourceObjectId} dedupKey={DedupKey}",
  735. nameof(CreateFromHitAsync), hit.SourceRuleId, hit.SourceRuleCode, tenantId,
  736. deptResolution.OccurrenceSource, deptResolution.ResponsibleSource, hit.SourceObjectType, hit.SourceObjectId, hit.DedupKey);
  737. throw new S8BizException($"自动建单部门解析失败:租户 {tenantId} 下 {UnassignedDepartmentCode} 未命中或 inactive");
  738. }
  739. // S8-SLA-TIMEOUT-RUNTIME-1(P3):CreatedAt 与 SlaDeadline 共用同一 now。
  740. var now = DateTime.Now;
  741. var code = $"EX-{now:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
  742. var slaDeadline = await ResolveSlaDeadlineAsync(tenantId, hit.ExceptionTypeCode, now);
  743. var entity = new AdoS8Exception
  744. {
  745. TenantId = tenantId,
  746. // S8-TENANT-ONLY-BATCH6:兼容列,恒 0。
  747. FactoryId = S8ConfigScope.GlobalFactoryId,
  748. ExceptionCode = code,
  749. Title = string.IsNullOrWhiteSpace(hit.Title)
  750. ? $"[自动] {hit.SourceObjectType} {hit.SourceObjectId}"
  751. : hit.Title,
  752. Description = null,
  753. SceneCode = effectiveScene,
  754. SourceType = "AUTO_WATCH",
  755. Status = "NEW",
  756. Severity = S8SeverityCode.Normalize(hit.Severity),
  757. PriorityScore = 0,
  758. PriorityLevel = "P3",
  759. // S8-AUTO-WATCH-DEPT-RESOLVE-1:经 resolver 严格校验后写入;不再使用 ?? 0 兜底。
  760. OccurrenceDeptId = deptResolution.OccurrenceDeptId.Value,
  761. ResponsibleDeptId = deptResolution.ResponsibleDeptId.Value,
  762. ReporterUserId = null,
  763. CreatedAt = now,
  764. // S8-SLA-TIMEOUT-RUNTIME-1:sla_deadline 来自 hit.ExceptionTypeCode 对应 sla_minutes。
  765. SlaDeadline = slaDeadline,
  766. IsDeleted = false,
  767. ExceptionTypeCode = hit.ExceptionTypeCode,
  768. ModuleCode = resolvedModule,
  769. // S8-PROCESS-NODE-MODULE-CODE-ALIGNMENT-EXEC-1:当前阶段 process_node_code 留空,
  770. // module_code 承担 S1-S7 主流程归属;process_node_code 留给未来更细流程节点。
  771. ProcessNodeCode = null,
  772. SourceRuleId = hit.SourceRuleId,
  773. // 同上:不再写 source_data_source_id,历史行的值原样保留。
  774. SourcePayload = hit.SourcePayload,
  775. RelatedObjectCode = hit.RelatedObjectCode,
  776. // R2 新列回填
  777. DedupKey = hit.DedupKey,
  778. LastDetectedAt = hit.DetectedAt,
  779. RecoveredAt = null,
  780. SourceRuleCode = hit.SourceRuleCode,
  781. SourceObjectType = hit.SourceObjectType,
  782. SourceObjectId = hit.SourceObjectId,
  783. };
  784. // TASK-002-RESET-DIMENSION-MODEL-DEV-2B:R2 自动建单同样从规则复制维度三件套。
  785. var (gHitStage, gHitFlow, gHitMech) = await ResolveRuleDimensionsAsync(hit.SourceRuleId, resolvedModule, ruleType: null);
  786. entity.StageCode = gHitStage;
  787. entity.OrderFlowCode = gHitFlow;
  788. entity.RuleMechanism = gHitMech;
  789. _logger.LogInformation(
  790. "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}",
  791. nameof(CreateFromHitAsync), hit.SourceRuleId, hit.SourceRuleCode, tenantId,
  792. entity.OccurrenceDeptId, deptResolution.OccurrenceSource, entity.ResponsibleDeptId, deptResolution.ResponsibleSource, hit.SourceObjectType, hit.SourceObjectId, hit.DedupKey);
  793. await _rep.AsTenant().UseTranAsync(async () =>
  794. {
  795. entity = await _rep.AsInsertable(entity).ExecuteReturnEntityAsync();
  796. await _timelineRep.InsertAsync(new AdoS8ExceptionTimeline
  797. {
  798. ExceptionId = entity.Id,
  799. ActionCode = "CREATE",
  800. ActionLabel = "创建",
  801. FromStatus = null,
  802. ToStatus = "NEW",
  803. OperatorUserId = null,
  804. ActionRemark = "自动建单(R2)",
  805. CreatedAt = DateTime.Now
  806. });
  807. }, ex => throw ex);
  808. await TryStartIntakeFlowAsync(entity);
  809. return entity;
  810. }
  811. // S8-TENANT-FACTORY-P0-CLOSURE-1:按 Id + 服务端可信作用域绑行,禁止裸 Id 读他租户异常。
  812. public async Task<AdoS8Exception?> GetAsync(long id)
  813. {
  814. var tenantId = ResolveTrustedTenantId();
  815. return await _rep.GetFirstAsync(
  816. x => x.Id == id && x.TenantId == tenantId && !x.IsDeleted);
  817. }
  818. // S8-TENANT-FACTORY-P0-CLOSURE-1:附件写入同样按可信作用域绑行,禁止裸 Id 往他租户异常挂附件。
  819. public async Task<AdoS8Evidence> AddAttachmentAsync(long id, AdoS8AttachmentCreateDto dto)
  820. {
  821. var tenantId = ResolveTrustedTenantId();
  822. var entity = await _rep.GetFirstAsync(
  823. x => x.Id == id && x.TenantId == tenantId && !x.IsDeleted)
  824. ?? throw new S8NotFoundException("异常不存在");
  825. if (string.IsNullOrWhiteSpace(dto.FileName) || string.IsNullOrWhiteSpace(dto.FileUrl))
  826. throw new S8BizException("附件名称和地址必填");
  827. var evidence = new AdoS8Evidence
  828. {
  829. ExceptionId = id,
  830. EvidenceType = string.IsNullOrWhiteSpace(dto.EvidenceType) ? "file" : dto.EvidenceType,
  831. FileName = dto.FileName.Trim(),
  832. FileUrl = dto.FileUrl.Trim(),
  833. SourceSystem = dto.SourceSystem,
  834. UploadedBy = dto.UploadedBy,
  835. UploadedAt = DateTime.Now,
  836. IsDeleted = false
  837. };
  838. await _evidenceRep.InsertAsync(evidence);
  839. return evidence;
  840. }
  841. }