| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020 |
- using Admin.NET.Plugin.AiDOP.Const.S8;
- 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<string> 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;
- }
- /// <summary>
- /// 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 决定记日志或拒绝)。
- /// </summary>
- private async Task<string?> 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<AdoS8Exception> _rep;
- private readonly SqlSugarRepository<AdoS8ExceptionTimeline> _timelineRep;
- private readonly SqlSugarRepository<AdoS8Evidence> _evidenceRep;
- private readonly SqlSugarRepository<AdoS8SceneConfig> _sceneRep;
- private readonly SqlSugarRepository<AdoS0DepartmentMaster> _deptRep;
- private readonly SqlSugarRepository<AdoS0LineMaster> _lineRep;
- private readonly SqlSugarRepository<AdoS8ExceptionType> _typeRep;
- // S8-AUTO-WATCH-DEPT-RESOLVE-1(P2):自动建单部门 resolver 需读取 watch_rule.params_json 里的默认部门字段。
- private readonly SqlSugarRepository<AdoS8WatchRule> _ruleRep;
- private readonly ISqlSugarClient _db;
- private readonly UserManager _userManager;
- private readonly FlowEngineService _flowEngine;
- // S8-RULE-READINESS-1:租户部门合法性的唯一实现,与启用前的就绪门禁同源。
- private readonly IS8DepartmentScopeValidator _deptValidator;
- // S8-SYSUSER-ONLY-1:「这个账号在本租户能不能作为 S8 的人」只有这一份判据。
- // 主动提报选处理人/复检人必须复用它,而不是在本文件里另写一套 SysUser 查询 ——
- // 认领 / 转派 / 提交复检已经踩过一次「几处写成一样」最终分叉的坑。
- private readonly IS8UserScopeValidator _userScope;
- /// <summary>
- /// S8-MANUAL-DIRECT-ASSIGN-1:建单后通知处理人。
- /// 只用它的<b>显式收件人</b>入口 —— 主动提报没有来源规则,配置路径按
- /// (租户, rule_code, 事件) 取收件人,对 rule_code 为空的单据永远查不到行,
- /// 会在 <c>Source == "NONE"</c> 处直接返回。收件人在这里本就是业务动作的一部分
- /// (提报人当场指定的),不该再去问配置。
- /// </summary>
- private readonly S8NotificationLayerResolver _notificationLayerResolver;
- private readonly ILogger<S8ManualReportService> _logger;
- public S8ManualReportService(
- SqlSugarRepository<AdoS8Exception> rep,
- SqlSugarRepository<AdoS8ExceptionTimeline> timelineRep,
- SqlSugarRepository<AdoS8Evidence> evidenceRep,
- SqlSugarRepository<AdoS8SceneConfig> sceneRep,
- SqlSugarRepository<AdoS0DepartmentMaster> deptRep,
- SqlSugarRepository<AdoS0LineMaster> lineRep,
- SqlSugarRepository<AdoS8ExceptionType> typeRep,
- SqlSugarRepository<AdoS8WatchRule> ruleRep,
- ISqlSugarClient db,
- UserManager userManager,
- FlowEngineService flowEngine,
- IS8DepartmentScopeValidator deptValidator,
- IS8UserScopeValidator userScope,
- S8NotificationLayerResolver notificationLayerResolver,
- ILogger<S8ManualReportService> 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;
- _userScope = userScope;
- _notificationLayerResolver = notificationLayerResolver;
- _logger = logger;
- }
- /// <summary>
- /// S8-TENANT-ONLY-BATCH6:可信作用域收敛为租户。
- ///
- /// <para>原实现还要 <c>SysTenant.OrgId</c> 解析一个 factoryId,并在其 <= 0 时**拒绝建单**
- /// ("当前租户尚未配置所属机构")。那道门禁保护不了任何东西 —— 工厂号不是隔离维度 ——
- /// 却让一个只是没配机构的租户完全用不了主动提报。随 factory 一起去掉。</para>
- /// </summary>
- 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<DateTime?> 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<AutoWatchDeptResolution> 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;
- }
- /// <summary>
- /// S8-TENANT-ONLY-BATCH6:部门必须属于当前租户且 active。
- /// S8-RULE-READINESS-1:判据本体已抽到 <see cref="S8DepartmentScopeValidator"/>,本处只做委派。
- ///
- /// <para><b>不要把判据搬回来。</b>启用前的就绪门禁与这里的运行时建单必须用同一份实现:
- /// 两份"看起来一样"的判断迟早分叉,而分叉的表现是最难查的那种 ——
- /// 启用检查通过、建单却失败,页面上只剩「已启用 / 成功 / 0 条异常」。</para>
- /// </summary>
- private Task<bool> 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;
- }
- /// <summary>
- /// 默认发生部门 = 该账号在本租户最近一次人工提报所选的发生部门(且该部门当前仍有效)。
- /// 只作前端表单初值;无历史 / 部门已停用 / 跨租户一律返回 (null, null),不报错、不阻断页面。
- /// </summary>
- 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;
- }
- /// <summary>
- /// TASK-002-RESET-DIMENSION-MODEL-DEV-2B:自动建单时从规则复制维度三件套;规则缺失则按 rule_type 推断 rule_mechanism。
- /// </summary>
- 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);
- }
- /// <summary>
- /// 主动提报推断 ExceptionTypeCode:场景下取启用且 SortNo 最小的一条。
- /// baseline 异常类型当前 tenant_id=0/factory_id=0(全局基线),所以匹配条件为
- /// 本租户覆盖 OR 平台默认 (tenant_id = 0)。ClearFilter 兜底全局多租户过滤器。
- /// 找不到返回 null(保持兼容)。
- /// </summary>
- private async Task<string?> 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();
- }
- /// <summary>
- /// TB001 异常提报审批流:自动监控 + 主动提报后软触发,失败仅 warn 日志,不阻断建单。
- /// S8-EXCEPTION-FLOW-TENANT-CONTEXT-1:统一走 FlowEngineService 的受信任租户重载,
- /// 传入 entity.TenantId(该异常自身已确认的归属租户,三个调用方——自动建单的后台 Job 路径
- /// 与手工提报的 HTTP 路径——均已在建单前完成租户解析,此处直接复用,不再依赖
- /// _userManager.TenantId 隐式取值;后台 Job 场景下 HttpContext 为 null 会导致其恒为 0。
- /// </summary>
- 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<string, object>
- {
- ["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);
- }
- }
- /// <summary>
- /// S8-MANUAL-DIRECT-ASSIGN-1:主动提报建单后,通知被指定的处理人。
- ///
- /// <para><b>为什么走显式收件人、而不是配置路径</b>:配置路径按
- /// <c>(租户, rule_code, 事件)</c> 查 <c>ado_s8_notification_recipient</c>,
- /// 主动提报的 <c>source_rule_code</c> 恒为 NULL,兜底键 <c>'*'</c> 至今没有写入方也没有配置界面 ——
- /// 于是 <c>DispatchByLayerAsync</c> 会在 <c>Source == "NONE"</c> 处直接返回,一个字都发不出去。
- /// 而这里的收件人根本不需要"查":它就是提报人刚刚在页面上指定的那个人,
- /// 是业务动作的一部分,不是一项配置。</para>
- ///
- /// <para><b>为什么放在事务之后</b>:与自动建单侧
- /// (<c>S8WatchSchedulerService.TryDispatchLayerNotificationAsync</c>)同一条纪律 ——
- /// 异常已经建成,通知发不出去是通知的问题,不该把已经成立的业务事实回滚掉。
- /// 因此全程 try/catch 只记 Warning,绝不外抛。</para>
- ///
- /// <para><b>刻意只发处理人</b>:复检人此刻还轮不到他动手(要等处理人提交复检),
- /// 现在通知他只会制造一条无法行动的消息。VERIFICATION_SUBMITTED 事件本身已在
- /// <c>S8TaskFlowService</c> 里接好线,待其收件人配置补齐后自然生效。</para>
- /// </summary>
- private async Task TryDispatchCreatedToAssigneeAsync(AdoS8Exception entity, S8OperatorUser assignee)
- {
- try
- {
- await _notificationLayerResolver.DispatchToExplicitUsersAsync(
- new S8NotificationLayerResolver.DispatchByLayerInput
- {
- TenantId = entity.TenantId,
- ExceptionId = entity.Id,
- ExceptionNo = entity.ExceptionCode,
- SceneCode = entity.SceneCode ?? string.Empty,
- Severity = entity.Severity ?? string.Empty,
- Status = entity.Status,
- SourceRuleCode = entity.SourceRuleCode,
- EventCode = S8NotifyEventCode.ExceptionCreated,
- ExceptionRef = entity,
- Title = entity.Title ?? string.Empty,
- Content = $"异常 {entity.ExceptionCode}:{entity.Title}(主动提报,已指派给你处理)",
- },
- new[] { assignee.UserId });
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex,
- "s8_manual_created_notify_failed exceptionId={Id} exceptionCode={Code} assignee={Assignee}",
- entity.Id, entity.ExceptionCode, assignee.UserId);
- }
- }
- public async Task<object> 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<object>(),
- defaultOccurrenceDeptId = defaultOccDeptId,
- defaultOccurrenceDeptName = defaultOccDeptName,
- };
- }
- public async Task<AdoS8ManualReportResultDto> 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("处理部门不能为空,请选择有效处理部门");
- // ── 主动提报点对点派单 V1:提报即定人,不进认领池 ──
- //
- // 三条必填放在这里(而不是靠前端约束)的原因:这三样一旦缺失,单子仍然能建成,
- // 只是变成一张「没人负责、没人复检、没说清发生了什么」的空壳挂在列表里,
- // 而调用方看到的是 200 成功。缺失必须在入口就变成可读的 400,不能顺延到运营侧才发现。
- if (dto.AssigneeUserId is not > 0)
- throw new S8BizException("请选择处理人,主动提报需明确由谁处理");
- if (dto.VerifierUserId is not > 0)
- throw new S8BizException("请选择复检人,主动提报需明确由谁复检");
- if (string.IsNullOrWhiteSpace(dto.Description))
- throw new S8BizException("详细说明不能为空,请描述异常的具体情况");
- // 同一个人既处理又复检,复检就只是走个形式;这是业务规则,不是数据完整性问题,故单独成条并给出理由。
- if (dto.AssigneeUserId == dto.VerifierUserId)
- throw new S8BizException("处理人与复检人不能是同一账号,否则复检失去意义");
- // 账号合法性统一委派给 IS8UserScopeValidator(租户内 + 启用),与认领 / 转派 / 提交复检同源判据。
- // 不在本文件里手写 SysUser 查询:判据一旦有第二份,迟早分叉成「提报能选、认领校验不过」这种最难查的形态。
- var assignee = await _userScope.EnsureActiveUserAsync(tenantId, dto.AssigneeUserId!.Value, "处理人");
- var verifier = await _userScope.EnsureActiveUserAsync(tenantId, dto.VerifierUserId!.Value, "复检人");
- // 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",
- // 主动提报点对点派单 V1:处理人在提报时就已确定,没有「待认领」这个中间态可言,
- // 建单直接落 ASSIGNED。留 NEW 只会制造一个谁都不会去认领的空窗口 ——
- // 单子已经有主,却在列表里显示为待认领,反而误导运营。
- Status = "ASSIGNED",
- 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,
- // 三个字段来自同一次提报动作,必须同批写入:只写 Status 不写人,
- // 单子会以「已分派但无处理人」的形态存在,而这正是列表 / 通知 / 工作台都解释不了的那种脏状态。
- AssigneeUserId = assignee.UserId,
- VerifierUserId = verifier.UserId,
- // 与 CreatedAt 共用同一个 now:点对点提报的「建单」与「派单」是同一瞬间发生的事,
- // 两个时间戳若各取一次 DateTime.Now,事后按时间排序会出现毫秒级倒挂。
- AssignedAt = now,
- 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,
- // 点对点派单后单据**从未处于 NEW**:它在同一条 INSERT 里就直接落到 ASSIGNED。
- // 这里若沿用旧的 "NEW",时间线就会记录一个真实世界不存在的状态,
- // 而 from/to 是经 S8DecisionService 对外暴露的字段 —— 事后复盘会被它误导。
- // 自动建单路径(CreateFromWatchAsync / CreateFromHitAsync)确实是 NEW,那两处不动。
- ToStatus = "ASSIGNED",
- OperatorUserId = currentUserId,
- ActionRemark = "主动提报",
- CreatedAt = DateTime.Now
- });
- // 派单单独记一条,不与 CREATE 合并、也不复用 CLAIM:
- // CLAIM 的语义是「处理人自己把单子捞走」,这里是「提报人把单子指给别人」,
- // 责任主体正好相反。共用一个 ActionCode 会让时间线再也分不清是谁做的决定。
- // FromStatus 留 null:这一步没有前置状态可言——单据是在同一事务里刚刚诞生的。
- await _timelineRep.InsertAsync(new AdoS8ExceptionTimeline
- {
- ExceptionId = entity.Id,
- ActionCode = "MANUAL_ASSIGN",
- ActionLabel = "指派处理人",
- FromStatus = null,
- ToStatus = "ASSIGNED",
- OperatorUserId = currentUserId,
- // 记显示名而非裸 ID:时间线是给人看的,事后追责时没人愿意再去反查一串数字。
- ActionRemark = $"主动提报指派:处理人 {assignee.DisplayName},复检人 {verifier.DisplayName}",
- CreatedAt = DateTime.Now
- });
- }, ex => throw ex);
- await TryStartIntakeFlowAsync(entity);
- await TryDispatchCreatedToAssigneeAsync(entity, assignee);
- return new AdoS8ManualReportResultDto
- {
- ExceptionId = entity.Id,
- ExceptionCode = entity.ExceptionCode,
- TaskId = entity.Id
- };
- }
- /// <summary>
- /// G01-06:自动建单分支(非第二套创建主链)。
- /// 这是本服务内的自动监控建单路径,与 <see cref="CreateAsync"/> 并列,
- /// 复用同一仓储(_rep / _timelineRep)、同一事务边界、同一 ExceptionCode 生成规则、
- /// 同一时间线主链(ActionCode="CREATE"、ToStatus="NEW");仅差异点:
- /// - SourceType 标识为自动监控来源
- /// - 填入 SourceRuleId / SourceDataSourceId / SourcePayload / RelatedObjectCode 追溯
- /// - ExceptionTypeCode 固定 EQUIP_FAULT(G-01 首版唯一映射)
- /// - SceneCode 固定 S2(G-01 首版唯一场景,迁移后从 S2S6_PRODUCTION 切到单模块 S2)
- /// 不做补偿、重试、对账;失败由调用方接住。
- /// </summary>
- public async Task<AdoS8Exception> 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;
- }
- /// <summary>
- /// R2 自动建单分支(TIMEOUT 等新 evaluator 走此路径)。
- /// 与 <see cref="CreateFromWatchAsync"/> 并列:复用同一仓储 / 事务 / 时间线 ActionCode;
- /// 差异点:消费 <see cref="S8RuleHit"/> 一份命中模型,把 R2 新列(DedupKey / LastDetectedAt /
- /// SourceRuleCode / SourceObjectType / SourceObjectId)落齐;ExceptionTypeCode 由 hit 自带,
- /// 不再硬编码 EQUIP_FAULT。RecoveredAt 本轮不写。
- /// 调用方负责前置检查 ExceptionTypeCode 是否在 baseline;本方法不再二次校验。
- /// </summary>
- public async Task<AdoS8Exception> 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<AdoS8Exception?> 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<AdoS8Evidence> 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;
- }
- }
|