Files
Deal/src/core/Deal.Modules.Kanban/Application/ColumnRules/ColumnMatcher.cs
T
stepan 1fbe29a838 Вынести магические строки статусов, видов и ключей в каталоги
Литералы статусов/видов/фаз и wire-ключей заменены каталогами: значения сущностей и репозиториев — TenantStatuses/TenantLimitPeriods/InviteStatuses/ContainerKinds/ContainerSpaces/CardIds/Discovery*/PipelineQueueStatuses; контакты и виды вложений — CardContactTypes/CardFileKinds; источники и этапы отсева — PipelineRejectStages/PipelineRejectSources; ключи промптов/провайдеров — SettingsFieldKeys; wire-ключи канбана — KanbanWireKeys; режимы правил — ColumnRuleModes; операции/фазы/виды диалогов/контакты Telegram и общий BoolText — в Deal.Contracts/Deal.SharedKernel; имена метрик — DealMetrics. Значения не менялись.
2026-09-13 20:51:36 +03:00

215 lines
8.9 KiB
C#

using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Kanban.Application.ColumnRules;
/// <summary>
/// Матчинг текста сообщения по правилам колонки
/// </summary>
public static class ColumnMatcher
{
/// <summary>
/// Соответствует ли текст правилам колонки.
/// </summary>
/// <param name="rules">Правила колонки (null/пустые → False: пустая колонка не матчит).</param>
/// <param name="text">Текст сообщения/карточки.</param>
/// <param name="rates">Курсы для конвертации бюджета (см. <see cref="BudgetInRange"/>); null — если бюджет в другой валюте, суммы не конвертируются и группа бюджета не совпадает.</param>
/// <returns>True — текст прошёл правила в режиме all/any.</returns>
public static bool MatchText(
ContainerRulesDto? rules,
string? text,
IReadOnlyDictionary<string, double>? rates = null)
{
if (rules is null)
{
return false;
}
string lower = ContentNormalizer.ContentText(text ?? string.Empty).ToLowerInvariant();
IReadOnlyList<string> keywords = NormalizeTerms(rules.Keywords);
IReadOnlyList<string> stack = NormalizeTerms(rules.Stack);
IReadOnlyList<string> direction = NormalizeTerms(rules.Direction);
IReadOnlyList<string> grade = NormalizeTerms(rules.Grade);
IReadOnlyList<string> gradeTerms = GradeAliases.ExpandTerms(grade);
IReadOnlyList<string> levels = NormalizeTerms(rules.Levels);
IReadOnlyList<string> levelTerms = GradeAliases.ExpandTerms(levels);
IReadOnlyList<string> locations = NormalizeTerms(rules.Locations);
IReadOnlyList<string> types = NormalizeTerms(rules.Types);
IReadOnlyList<string> typeTerms = TypeAliases.ExpandTerms(types);
bool budgetEnabled = rules.Budget.HasBudget();
bool pricesEnabled = rules.Prices.HasBudget();
bool anyEnabled = keywords.Count > 0 || stack.Count > 0 || direction.Count > 0 || grade.Count > 0
|| levels.Count > 0 || locations.Count > 0 || types.Count > 0 || budgetEnabled || pricesEnabled;
if (!anyEnabled)
{
return false;
}
// Значения групп: пустая (выключенная) группа считается True, но в свёртку не входит.
bool keywordsOk = keywords.Count == 0 || ContainsAny(lower, keywords);
bool stackOk = stack.Count == 0 || ContainsAny(lower, stack);
bool directionOk = direction.Count == 0 || ContainsAny(lower, direction);
bool gradeOk = gradeTerms.Count == 0 || ContainsAny(lower, gradeTerms);
bool levelsOk = levelTerms.Count == 0 || ContainsAny(lower, levelTerms);
bool locationsOk = locations.Count == 0 || ContainsAny(lower, locations);
bool typesOk = typeTerms.Count == 0 || ContainsAny(lower, typeTerms);
bool budgetOk = !budgetEnabled || BudgetInRange.IsInRange(AmountParser.Parse(text), rules.Budget!, rates);
bool pricesOk = !pricesEnabled || BudgetInRange.IsInRange(AmountParser.Parse(text), rules.Prices!, rates);
if (string.Equals(rules.Mode, ColumnRuleModes.Any, StringComparison.OrdinalIgnoreCase))
{
// «любое»: совпасть должна хотя бы одна ВКЛЮЧЁННАЯ (непустая) группа.
return (keywords.Count > 0 && keywordsOk)
|| (stack.Count > 0 && stackOk)
|| (direction.Count > 0 && directionOk)
|| (grade.Count > 0 && gradeOk)
|| (levels.Count > 0 && levelsOk)
|| (locations.Count > 0 && locationsOk)
|| (types.Count > 0 && typesOk)
|| (budgetEnabled && budgetOk)
|| (pricesEnabled && pricesOk);
}
// all: каждая включённая группа обязана совпасть.
return (keywords.Count == 0 || keywordsOk)
&& (stack.Count == 0 || stackOk)
&& (direction.Count == 0 || directionOk)
&& (grade.Count == 0 || gradeOk)
&& (levels.Count == 0 || levelsOk)
&& (locations.Count == 0 || locationsOk)
&& (types.Count == 0 || typesOk)
&& (!budgetEnabled || budgetOk)
&& (!pricesEnabled || pricesOk);
}
/// <summary>
/// Число совпавших ТЕРМОВ правил.
/// </summary>
/// <param name="rules">Правила колонки.</param>
/// <param name="text">Текст сообщения.</param>
/// <returns>Суммарный балл (0 — текст пуст или совпадений нет).</returns>
public static int ScoreText(ContainerRulesDto? rules, string? text)
{
if (rules is null || string.IsNullOrEmpty(text))
{
return 0;
}
string lower = ContentNormalizer.ContentText(text).ToLowerInvariant();
int score = 0;
score += CountMatchedTerms(lower, rules.Direction);
score += CountMatchedTerms(lower, rules.Keywords);
score += CountMatchedTerms(lower, rules.Stack);
score += CountMatchedTerms(lower, rules.Locations);
foreach (string alias in GradeAliases.ExpandTerms(NormalizeTerms(rules.Grade)))
{
if (lower.Contains(alias, StringComparison.Ordinal))
{
score += 1;
}
}
foreach (string alias in GradeAliases.ExpandTerms(NormalizeTerms(rules.Levels)))
{
if (lower.Contains(alias, StringComparison.Ordinal))
{
score += 1;
}
}
foreach (string alias in TypeAliases.ExpandTerms(NormalizeTerms(rules.Types)))
{
if (lower.Contains(alias, StringComparison.Ordinal))
{
score += 1;
}
}
return score;
}
/// <summary>
/// Есть ли в правилах хотя бы одна реально работающая группа фильтров.
/// </summary>
/// <param name="rules">Правила колонки (null → False).</param>
/// <returns>True — есть непустой терм группы или бюджет с границей.</returns>
public static bool HasActiveRules(ContainerRulesDto? rules)
{
if (rules is null)
{
return false;
}
return rules.Direction.HasAnyTerm()
|| rules.Keywords.HasAnyTerm()
|| rules.Stack.HasAnyTerm()
|| rules.Grade.HasAnyTerm()
|| rules.Levels.HasAnyTerm()
|| rules.Locations.HasAnyTerm()
|| rules.Types.HasAnyTerm()
|| (rules.Budget is not null && (rules.Budget.From is not null || rules.Budget.To is not null))
|| (rules.Prices is not null && (rules.Prices.From is not null || rules.Prices.To is not null));
}
// Совпал ли хотя бы один терм (подстрока в тексте).
// lower: Текст в нижнем регистре (очищен от ссылок).
// terms: Термы в нижнем регистре.
// Возвращает: True — есть совпадение подстрокой.
private static bool ContainsAny(string lower, IReadOnlyList<string> terms)
{
foreach (string term in terms)
{
if (lower.Contains(term, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
// Сколько термов списка совпало в тексте (для score).
// lower: Текст в нижнем регистре.
// rawTerms: Термы как сохранены (регистр не важен).
// Возвращает: Число совпавших термов.
private static int CountMatchedTerms(string lower, IReadOnlyList<string>? rawTerms)
{
if (rawTerms is null)
{
return 0;
}
int count = 0;
foreach (string? raw in rawTerms)
{
string term = (raw ?? string.Empty).Trim().ToLowerInvariant();
if (term.Length > 0 && lower.Contains(term, StringComparison.Ordinal))
{
count += 1;
}
}
return count;
}
private static IReadOnlyList<string> NormalizeTerms(IReadOnlyList<string>? terms)
{
var result = new List<string>();
if (terms is null)
{
return result;
}
foreach (string? raw in terms)
{
string term = (raw ?? string.Empty).Trim().ToLowerInvariant();
if (term.Length > 0)
{
result.Add(term);
}
}
return result;
}
}