Литералы статусов/видов/фаз и wire-ключей заменены каталогами: значения сущностей и репозиториев — TenantStatuses/TenantLimitPeriods/InviteStatuses/ContainerKinds/ContainerSpaces/CardIds/Discovery*/PipelineQueueStatuses; контакты и виды вложений — CardContactTypes/CardFileKinds; источники и этапы отсева — PipelineRejectStages/PipelineRejectSources; ключи промптов/провайдеров — SettingsFieldKeys; wire-ключи канбана — KanbanWireKeys; режимы правил — ColumnRuleModes; операции/фазы/виды диалогов/контакты Telegram и общий BoolText — в Deal.Contracts/Deal.SharedKernel; имена метрик — DealMetrics. Значения не менялись.
215 lines
8.9 KiB
C#
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;
|
|
}
|
|
}
|