|
|
@@ -0,0 +1,193 @@
|
|
|
+using System.Data;
|
|
|
+using System.Globalization;
|
|
|
+using System.Text.Json;
|
|
|
+using Admin.NET.Plugin.AiDOP.Entity.S8;
|
|
|
+using SqlSugar;
|
|
|
+
|
|
|
+namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules;
|
|
|
+
|
|
|
+/// <summary>
|
|
|
+/// R3 SHORTAGE 类规则 evaluator MVP("实际数量未达目标")。
|
|
|
+/// params_json 约定(首版最小集合):
|
|
|
+/// { targetQtyField, actualQtyField, objectCodeField, objectIdField,
|
|
|
+/// toleranceAbs, toleranceRatio, exceptionTypeCode }
|
|
|
+/// 判定:
|
|
|
+/// shortage = target - actual;
|
|
|
+/// target <= 0 不命中(防除零 / 脏数据);
|
|
|
+/// actual 解析失败不命中(保守,避免误建单);
|
|
|
+/// shortage > toleranceAbs 且 shortage / target > toleranceRatio → HIT。
|
|
|
+/// 不做严重度阶梯、不做 SLA 升级、不做事件触发。
|
|
|
+/// </summary>
|
|
|
+public class S8ShortageRuleEvaluator : IS8RuleEvaluator, ITransient
|
|
|
+{
|
|
|
+ public const string RuleTypeCode = "SHORTAGE";
|
|
|
+ public string RuleType => RuleTypeCode;
|
|
|
+
|
|
|
+ private const string SqlDataSourceType = "SQL";
|
|
|
+
|
|
|
+ private readonly SqlSugarRepository<AdoS8DataSource> _dataSourceRep;
|
|
|
+
|
|
|
+ public S8ShortageRuleEvaluator(SqlSugarRepository<AdoS8DataSource> dataSourceRep)
|
|
|
+ {
|
|
|
+ _dataSourceRep = dataSourceRep;
|
|
|
+ }
|
|
|
+
|
|
|
+ public async Task<List<S8RuleHit>> EvaluateAsync(
|
|
|
+ long tenantId,
|
|
|
+ long factoryId,
|
|
|
+ AdoS8WatchRule rule,
|
|
|
+ IReadOnlyList<AdoS8AlertRule> alertRules,
|
|
|
+ CancellationToken cancellationToken = default)
|
|
|
+ {
|
|
|
+ var hits = new List<S8RuleHit>();
|
|
|
+
|
|
|
+ if (string.IsNullOrWhiteSpace(rule.Expression) || string.IsNullOrWhiteSpace(rule.ParamsJson))
|
|
|
+ return hits;
|
|
|
+
|
|
|
+ S8ShortageParams parameters;
|
|
|
+ try { parameters = S8ShortageParams.Parse(rule.ParamsJson!); }
|
|
|
+ catch { return hits; }
|
|
|
+
|
|
|
+ if (string.IsNullOrWhiteSpace(parameters.TargetQtyField)
|
|
|
+ || string.IsNullOrWhiteSpace(parameters.ActualQtyField)
|
|
|
+ || string.IsNullOrWhiteSpace(parameters.ExceptionTypeCode))
|
|
|
+ return hits;
|
|
|
+
|
|
|
+ var dataSource = await _dataSourceRep.AsQueryable()
|
|
|
+ .Where(x => x.Id == rule.DataSourceId
|
|
|
+ && x.TenantId == tenantId
|
|
|
+ && x.FactoryId == factoryId
|
|
|
+ && x.Enabled)
|
|
|
+ .FirstAsync();
|
|
|
+ if (dataSource == null
|
|
|
+ || string.IsNullOrWhiteSpace(dataSource.Endpoint)
|
|
|
+ || !string.Equals(dataSource.Type?.Trim(), SqlDataSourceType, StringComparison.OrdinalIgnoreCase))
|
|
|
+ return hits;
|
|
|
+
|
|
|
+ DataTable table;
|
|
|
+ try
|
|
|
+ {
|
|
|
+ using var db = CreateSqlScope(dataSource.Endpoint!);
|
|
|
+ table = await db.Ado.GetDataTableAsync(rule.Expression!);
|
|
|
+ }
|
|
|
+ catch
|
|
|
+ {
|
|
|
+ return hits;
|
|
|
+ }
|
|
|
+
|
|
|
+ var detectedAt = DateTime.Now;
|
|
|
+ var sourceObjectType = string.IsNullOrWhiteSpace(rule.SourceObjectType)
|
|
|
+ ? rule.WatchObjectType
|
|
|
+ : rule.SourceObjectType!;
|
|
|
+
|
|
|
+ foreach (DataRow row in table.Rows)
|
|
|
+ {
|
|
|
+ var target = ReadDecimal(row, parameters.TargetQtyField!);
|
|
|
+ var actual = ReadDecimal(row, parameters.ActualQtyField!);
|
|
|
+
|
|
|
+ if (target == null || target.Value <= 0m) continue; // 防除零 / 脏数据
|
|
|
+ if (actual == null) continue; // 保守:缺 actual 不建单
|
|
|
+
|
|
|
+ var shortage = target.Value - actual.Value;
|
|
|
+ if (shortage <= parameters.ToleranceAbs) continue;
|
|
|
+
|
|
|
+ var ratio = shortage / target.Value;
|
|
|
+ if (ratio <= parameters.ToleranceRatio) continue;
|
|
|
+
|
|
|
+ var objectCodeField = string.IsNullOrWhiteSpace(parameters.ObjectCodeField)
|
|
|
+ ? "related_object_code"
|
|
|
+ : parameters.ObjectCodeField!;
|
|
|
+ var relatedObjectCode = ReadString(row, objectCodeField) ?? string.Empty;
|
|
|
+ if (string.IsNullOrWhiteSpace(relatedObjectCode)) continue;
|
|
|
+
|
|
|
+ var sourceObjectId = string.IsNullOrWhiteSpace(parameters.ObjectIdField)
|
|
|
+ ? relatedObjectCode
|
|
|
+ : ReadString(row, parameters.ObjectIdField!) ?? relatedObjectCode;
|
|
|
+
|
|
|
+ var dedupKey = BuildDedupKey(tenantId, factoryId, rule.RuleCode, sourceObjectType, sourceObjectId);
|
|
|
+
|
|
|
+ hits.Add(new S8RuleHit
|
|
|
+ {
|
|
|
+ SourceRuleId = rule.Id,
|
|
|
+ SourceRuleCode = rule.RuleCode,
|
|
|
+ SourceObjectType = sourceObjectType,
|
|
|
+ SourceObjectId = sourceObjectId,
|
|
|
+ RelatedObjectCode = relatedObjectCode,
|
|
|
+ ExceptionTypeCode = parameters.ExceptionTypeCode!,
|
|
|
+ SceneCode = rule.SceneCode,
|
|
|
+ Severity = string.IsNullOrWhiteSpace(rule.Severity) ? "MEDIUM" : rule.Severity,
|
|
|
+ DedupKey = dedupKey,
|
|
|
+ SourcePayload = BuildPayload(row, sourceObjectType, sourceObjectId, target.Value, actual.Value, shortage, ratio, parameters),
|
|
|
+ DetectedAt = detectedAt,
|
|
|
+ Title = $"[数量不足] {sourceObjectType} {sourceObjectId} 缺口 {shortage:0.##}(目标 {target.Value:0.##} / 实际 {actual.Value:0.##})",
|
|
|
+ DataSourceId = dataSource.Id,
|
|
|
+ OccurrenceDeptId = ReadLong(row, "occurrence_dept_id"),
|
|
|
+ ResponsibleDeptId = ReadLong(row, "responsible_dept_id")
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ return hits;
|
|
|
+ }
|
|
|
+
|
|
|
+ private SqlSugarScope CreateSqlScope(string connectionString)
|
|
|
+ {
|
|
|
+ var dbType = _dataSourceRep.Context.CurrentConnectionConfig.DbType;
|
|
|
+ return new SqlSugarScope(new ConnectionConfig
|
|
|
+ {
|
|
|
+ ConfigId = $"s8-shortage-eval-{Guid.NewGuid():N}",
|
|
|
+ DbType = dbType,
|
|
|
+ ConnectionString = connectionString,
|
|
|
+ InitKeyType = InitKeyType.Attribute,
|
|
|
+ IsAutoCloseConnection = true
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>构造 R3 dedup_key 稳定字符串:T{tenant}:F{factory}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}。internal 暴露供测试。</summary>
|
|
|
+ internal static string BuildDedupKey(long tenantId, long factoryId, string ruleCode, string sourceObjectType, string sourceObjectId) =>
|
|
|
+ $"T{tenantId}:F{factoryId}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}";
|
|
|
+
|
|
|
+ private static string BuildPayload(DataRow row, string sourceObjectType, string sourceObjectId,
|
|
|
+ decimal target, decimal actual, decimal shortage, decimal ratio, S8ShortageParams parameters)
|
|
|
+ {
|
|
|
+ var payload = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
|
|
|
+ foreach (DataColumn column in row.Table.Columns)
|
|
|
+ {
|
|
|
+ var value = row[column];
|
|
|
+ payload[column.ColumnName] = value == DBNull.Value ? null : value;
|
|
|
+ }
|
|
|
+ payload["target_qty"] = target;
|
|
|
+ payload["actual_qty"] = actual;
|
|
|
+ payload["shortage_qty"] = shortage;
|
|
|
+ payload["shortage_ratio"] = ratio;
|
|
|
+ payload["__ruleType"] = RuleTypeCode;
|
|
|
+ payload["__sourceObjectType"] = sourceObjectType;
|
|
|
+ payload["__sourceObjectId"] = sourceObjectId;
|
|
|
+ payload["__exceptionTypeCode"] = parameters.ExceptionTypeCode;
|
|
|
+ return JsonSerializer.Serialize(payload);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static string? ReadString(DataRow row, string columnName)
|
|
|
+ {
|
|
|
+ if (string.IsNullOrWhiteSpace(columnName)) return null;
|
|
|
+ if (!row.Table.Columns.Contains(columnName)) return null;
|
|
|
+ var v = row[columnName];
|
|
|
+ return v == DBNull.Value ? null : Convert.ToString(v)?.Trim();
|
|
|
+ }
|
|
|
+
|
|
|
+ private static decimal? ReadDecimal(DataRow row, string columnName)
|
|
|
+ {
|
|
|
+ if (string.IsNullOrWhiteSpace(columnName)) return null;
|
|
|
+ if (!row.Table.Columns.Contains(columnName)) return null;
|
|
|
+ var v = row[columnName];
|
|
|
+ if (v == DBNull.Value) return null;
|
|
|
+ return decimal.TryParse(Convert.ToString(v, CultureInfo.InvariantCulture), NumberStyles.Any, CultureInfo.InvariantCulture, out var r) ? r : null;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static long? ReadLong(DataRow row, string columnName)
|
|
|
+ {
|
|
|
+ if (!row.Table.Columns.Contains(columnName)) return null;
|
|
|
+ var v = row[columnName];
|
|
|
+ if (v == DBNull.Value) return null;
|
|
|
+ return long.TryParse(Convert.ToString(v, CultureInfo.InvariantCulture), out var r) ? r : null;
|
|
|
+ }
|
|
|
+}
|