S8ManualReportService.cs 55 KB

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