Deal — единая кодовая база
ci / build-test (push) Canceled after 0s

SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/
Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue,
контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер),
Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог).

Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0,
тесты 1340/130/52/38/9 зелёные.
This commit is contained in:
Rustam Khalimov
2026-09-11 23:56:47 +03:00
commit 27c7831910
1383 changed files with 158436 additions and 0 deletions
@@ -0,0 +1,371 @@
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Kanban.Application.Abstractions;
/// <summary>
/// Единый порт хранилища карточек и контейнеров.
/// </summary>
public interface ICardStore
{
// ── Контейнеры (колонки/стадии/зоны) ─────────────────────────────────
/// <summary>
/// Контейнеры пространства в порядке показа
/// </summary>
/// <param name="space">Пространство (dashboard/selected) либо null — все контейнеры.</param>
/// <returns>Контейнеры (DTO, счётчики заполняет сервис); пусто — контейнеров нет.</returns>
public Task<IReadOnlyList<ContainerDto>> ListContainersAsync(string? space, CancellationToken ct);
/// <summary>
/// Один контейнер по id
/// </summary>
/// <param name="containerId">Id контейнера (inbox/archive/trash/стадия/<c>b_...</c>).</param>
/// <returns>Контейнер или null, если строки нет.</returns>
public Task<ContainerDto?> GetContainerAsync(string containerId, CancellationToken ct);
/// <summary>
/// Создаёт контейнер
/// </summary>
/// <param name="container">Полный контейнер для вставки, включая <see cref="ContainerDto.Order"/>.</param>
public Task CreateContainerAsync(ContainerDto container, CancellationToken ct);
/// <summary>
/// Обновляет контейнер целиком
/// </summary>
/// <param name="container">Контейнер с изменёнными полями (идентифицируется по Id).</param>
public Task UpdateContainerAsync(ContainerDto container, CancellationToken ct);
/// <summary>
/// Удаляет контейнер, предварительно перенося его карточки в «Неразобранное».
/// </summary>
/// <param name="containerId">Id удаляемого контейнера.</param>
/// <returns>Сколько карточек перенесено в inbox (0 — контейнера/карточек не было; ответ {ok, movedToInbox}).</returns>
public Task<int> DeleteContainerAsync(string containerId, CancellationToken ct);
/// <summary>
/// Переставляет контейнеры пространства
/// </summary>
/// <param name="space">Пространство переставляемых контейнеров.</param>
/// <param name="containerIds">Id контейнеров в новом порядке.</param>
public Task ReorderContainersAsync(
string space,
IReadOnlyList<string> containerIds,
CancellationToken ct);
// ── Карточки ──────────────────────────────────────────────────────────
/// <summary>
/// Карточки колонки или всех колонок дашборда, ORDER BY received_at DESC.
/// </summary>
/// <param name="query">Фильтр: Col — конкретная колонка либо null (все колонки дашборда).</param>
/// <returns>Полные карточки (DTO): JSON-поля разобраны, комментарии приложены, time посчитан.</returns>
public Task<IReadOnlyList<CardDto>> ListCardsAsync(CardsQuery query, CancellationToken ct);
/// <summary>
/// Карточки пространства «Выбранные», ORDER BY updated_at DESC.
/// </summary>
/// <param name="containerId">Фильтр по контейнеру-стадии (id каталога <see cref="Deal.Modules.Cards.Application.Models.CardsDefaultContainers"/>); null — все стадии.</param>
/// <returns>Полные карточки (DTO): JSON-поля разобраны, комментарии приложены, time посчитан.</returns>
public Task<IReadOnlyList<CardDto>> ListSelectedCardsAsync(string? containerId, CancellationToken ct);
/// <summary>
/// Полнотекстовый поиск карточек.
/// </summary>
/// <param name="q">Поисковый запрос (уже Trim+lowercase, как в отсев-поиске; короче 2 символов сервис не пропускает).</param>
/// <param name="limit">Ограничение результата.</param>
/// <returns>Полные карточки (комментарии приложены, time посчитан): FTS-кандидаты по убыванию ts_rank (SearchTsv @@ plainto_tsquery), затем LIKE-дополнение, внутри — ReceivedAt DESC.</returns>
public Task<IReadOnlyList<CardDto>> SearchCardsAsync(
string q,
int limit,
CancellationToken ct);
/// <summary>
/// Одна карточка по id
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <returns>Карточка или null, если строки нет.</returns>
public Task<CardDto?> GetCardAsync(string cardId, CancellationToken ct);
/// <summary>
/// Карточка по исходному сообщению
/// </summary>
/// <param name="source">Ссылка на источник: вид + оригинал + внешний id.</param>
/// <returns>Карточка или null, если сообщение не становилось карточкой.</returns>
public Task<CardDto?> GetCardBySourceAsync(
SourceRef source,
CancellationToken ct);
/// <summary>
/// Создаёт карточку из готового снимка.
/// </summary>
/// <param name="snapshot">Полное состояние новой карточки (id сгенерирован модулем, см. CardSnapshot).</param>
public Task AddCardAsync(CardSnapshot snapshot, CancellationToken ct);
/// <summary>
/// Точечная правка полей по присутствующим в патче + bump UpdatedAt.
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="patch">Изменяемые поля (null — поле не меняется; JSON-поля — полная замена).</param>
/// <returns>True — строка обновлена; false — карточки нет (404-семантика сервиса).</returns>
public Task<bool> PatchCardAsync(
string cardId,
CardPatch patch,
CancellationToken ct);
/// <summary>
/// Атомарно дописывает ссылку в JSON-массив links ОДНИМ UPDATE
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="link">Готовая ссылка {id,name,url} (id сгенерирован модулем).</param>
/// <returns>True — строка обновлена; false — карточки нет (404-семантика сервиса).</returns>
public Task<bool> AddLinkAsync(
string cardId,
CardLinkDto link,
CancellationToken ct);
/// <summary>
/// Атомарно убирает из JSON-массива links элемент с указанным id ОДНИМ UPDATE
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="linkId">Id удаляемой ссылки (<c>pl_...</c>).</param>
/// <returns>True — строка обновлена; false — карточки нет (404-семантика сервиса).</returns>
public Task<bool> RemoveLinkAsync(
string cardId,
string linkId,
CancellationToken ct);
/// <summary>
/// Атомарно дописывает метаданные файла в JSON-массив files ОДНИМ UPDATE
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="file">Готовые метаданные {id,name,size,kind,label,objectKey} (id сгенерирован модулем).</param>
/// <returns>True — строка обновлена; false — карточки нет (404-семантика сервиса).</returns>
public Task<bool> AddFileAsync(
string cardId,
CardFileDto file,
CancellationToken ct);
/// <summary>
/// Атомарно убирает из JSON-массива files элемент с указанным id ОДНИМ UPDATE
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="fileId">Id удаляемой записи файла (<c>pf_...</c>).</param>
/// <returns>True — строка обновлена; false — карточки нет (404-семантика сервиса).</returns>
public Task<bool> RemoveFileAsync(
string cardId,
string fileId,
CancellationToken ct);
/// <summary>
/// Смена контейнера-стадии
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="containerId">Новый контейнер-стадия (валидирует сервис каталогом <see cref="Deal.Modules.Cards.Application.Models.CardsDefaultContainers"/>).</param>
/// <param name="historyEntry">Готовая запись истории {id,at,stage} (id сгенерирован модулем).</param>
/// <param name="atMs">Время переноса, epoch-ms (пишется в updated_at и в запись истории).</param>
/// <returns>True — строка обновлена; false — карточки нет (404-семантика сервиса).</returns>
public Task<bool> MoveCardStageAsync(
string cardId,
string containerId,
CardHistoryDto historyEntry,
long atMs,
CancellationToken ct);
/// <summary>
/// Устанавливает напоминание
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="atMs">Время напоминания, epoch-ms.</param>
public Task SetReminderAsync(
string cardId,
long atMs,
CancellationToken ct);
/// <summary>
/// Снимает напоминание
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
public Task ClearReminderAsync(string cardId, CancellationToken ct);
/// <summary>
/// Полная ручная очистка контейнера-стадии
/// </summary>
/// <param name="containerId">Очищаемая стадия (допустимость — только <c>rejected</c> — валидирует сервис).</param>
/// <returns>Сколько карточек удалено (0 — стадия пуста).</returns>
public Task<int> ClearStageAsync(string containerId, CancellationToken ct);
/// <summary>
/// Наступившие напоминания стадии hold
/// </summary>
/// <param name="now">Текущий момент (UTC) для сравнения с ReminderAt.</param>
/// <returns>Due-строки {id,title,containerId} в порядке наступления; пусто — сработавших нет.</returns>
public Task<IReadOnlyList<CardReminderDueDto>> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct);
/// <summary>
/// Помечает due-карточки сработавшими
/// </summary>
/// <param name="cardIds">Id карточек, чьи напоминания «выстрелили».</param>
public Task MarkRemindersFiredAsync(IReadOnlyList<string> cardIds, CancellationToken ct);
/// <summary>
/// Очищает протухшие напоминания при выключенной настройке
/// </summary>
/// <param name="now">Текущий момент (UTC).</param>
/// <returns>Сколько строк очищено.</returns>
public Task<int> ClearExpiredRemindersAsync(DateTimeOffset now, CancellationToken ct);
/// <summary>
/// Меняет колонку/состояние карточки
/// </summary>
/// <param name="update">Новое состояние колонки карточки.</param>
public Task UpdateColumnAsync(CardColumnUpdateDto update, CancellationToken ct);
/// <summary>
/// Применяет результат ручной переклассификации к существующей карточке ОДНИМ обновлением
/// </summary>
/// <param name="update">Поля классификации (полная замена; см. CardReclassificationDto).</param>
/// <returns>True — строка обновлена; false — карточки нет (404-семантика сервиса).</returns>
public Task<bool> ApplyReclassificationAsync(CardReclassificationDto update, CancellationToken ct);
/// <summary>
/// Снимает флаг «новое»
/// </summary>
/// <param name="cardId">Id карточки либо null.</param>
/// <param name="col">Колонка либо null.</param>
public Task UpdateSeenAsync(
string? cardId,
string? col,
CancellationToken ct);
/// <summary>
/// Удаляет карточку навсегда
/// </summary>
/// <param name="cardId">Id карточки.</param>
public Task DeleteForeverAsync(string cardId, CancellationToken ct);
/// <summary>
/// Полная очистка служебной колонки
/// </summary>
/// <param name="col">Очищаемая колонка (trash/archive).</param>
/// <returns>Сколько карточек удалено (0 — колонка пуста).</returns>
public Task<int> ClearColAsync(string col, CancellationToken ct);
/// <summary>
/// Счётчики карточек по колонкам
/// </summary>
/// <returns>Словарь col → {count, new}; ключи — только колонки с карточками.</returns>
public Task<IReadOnlyDictionary<string, CardColumnCountDto>> CountCardsByColAsync(CancellationToken ct);
// ── Комментарии (LeadComments) ────────────────────────────────────────
/// <summary>
/// Комментарии карточки
/// </summary>
/// <param name="cardId">Id карточки.</param>
/// <returns>Комментарии (time — human-метка от CreatedAt); пусто — комментариев нет.</returns>
public Task<IReadOnlyList<CardCommentDto>> ListCommentsAsync(string cardId, CancellationToken ct);
/// <summary>
/// Добавляет комментарий.
/// </summary>
/// <param name="commentId">Готовый id (<c>cm_...</c>, генерирует модуль).</param>
/// <param name="cardId">Id карточки.</param>
/// <param name="by">Автор («Вы» — свои комментарии).</param>
/// <param name="text">Текст (непустой — валидирует сервис, 400 «Пустой комментарий»).</param>
public Task AddCommentAsync(
string commentId,
string cardId,
string by,
string text,
CancellationToken ct);
// ── Журнал действий (CardMoves = learning_log) ────────────────────────
/// <summary>
/// Пишет строку журнала действия пользователя
/// </summary>
/// <param name="move">Запись журнала (id <c>lm_...</c> сгенерирован модулем).</param>
public Task AddMoveAsync(CardMoveDto move, CancellationToken ct);
/// <summary>
/// Число записей журнала = счётчик learning.
/// </summary>
/// <returns>Количество строк CardMoves.</returns>
public Task<int> CountMovesAsync(CancellationToken ct);
/// <summary>
/// Свежие примеры разметки пользователя для few-shot ИИ-классификации.
/// </summary>
/// <param name="limit">Максимум примеров.</param>
/// <returns>Примеры «текст → колонка», свежие первыми; пусто — истории разметки нет.</returns>
public Task<IReadOnlyList<AiMarkupExampleDto>> GetAiMarkupExamplesAsync(int limit, CancellationToken ct);
/// <summary>
/// Кандидаты на автоархив
/// </summary>
/// <param name="receivedBeforeUtc">Граница: received_at &lt; now archiveAfterDays.</param>
/// <returns>Id карточек-кандидатов (колонка при архивации пишется archive через UpdateColumnAsync).</returns>
public Task<IReadOnlyList<string>> ListArchiveCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct);
/// <summary>
/// Архивирует пачку карточек ОДНИМ UPDATE
/// </summary>
/// <param name="cardIds">Id карточек-кандидатов (список из ListArchiveCandidatesAsync).</param>
/// <param name="archivedAt">Момент архивации (один «now» тика).</param>
/// <returns>Сколько карточек реально архивировано (0 — кандидатов не было/уже не в рабочих колонках).</returns>
public Task<int> ArchiveAsync(
IReadOnlyList<string> cardIds,
DateTimeOffset archivedAt,
CancellationToken ct);
/// <summary>
/// Кандидаты на очистку архива
/// </summary>
/// <param name="archivedBeforeUtc">Граница: archived_at &lt; now archiveClearDays.</param>
/// <returns>Id карточек для жёсткого удаления.</returns>
public Task<IReadOnlyList<string>> ListExpiredArchiveCandidatesAsync(DateTimeOffset archivedBeforeUtc, CancellationToken ct);
/// <summary>
/// Кандидаты на очистку корзины
/// </summary>
/// <param name="receivedBeforeUtc">Граница: received_at &lt; now trashClearDays.</param>
/// <returns>Id карточек для жёсткого удаления.</returns>
public Task<IReadOnlyList<string>> ListTrashCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct);
/// <summary>
/// Жёстко удаляет пачку карточек
/// </summary>
/// <param name="cardIds">Id карточек на удаление.</param>
/// <returns>Сколько карточек удалено.</returns>
public Task<int> PurgeAsync(IReadOnlyList<string> cardIds, CancellationToken ct);
/// <summary>
/// Карточки для пересчёта ConvFrom/ConvTo/ConvCur
/// </summary>
/// <returns>Карточки с бюджетом (используются Budget/бюджетные поля; служебные колонки исключены).</returns>
public Task<IReadOnlyList<CardDto>> ListCardsForConversionAsync(CancellationToken ct);
/// <summary>
/// Записывает пересчитанную конверсию бюджета карточки
/// </summary>
/// <param name="cardId">Id карточки.</param>
/// <param name="convFrom">Сконвертированная нижняя граница, либо null.</param>
/// <param name="convTo">Сконвертированная верхняя граница, либо null.</param>
/// <param name="convCur">Валюта конверсии (целевая валюта тенанта); пусто — конверсия снята.</param>
public Task UpdateConversionAsync(
string cardId,
double? convFrom,
double? convTo,
string convCur,
CancellationToken ct);
/// <summary>
/// Карточки «Неразобранного» с исходным текстом — вход эвристики suggest.
/// </summary>
/// <returns>Карточки col='inbox' с непустым текстом источника.</returns>
public Task<IReadOnlyList<CardDto>> ListInboxWithSourceAsync(CancellationToken ct);
}
@@ -0,0 +1,54 @@
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Kanban.Application.Abstractions;
/// <summary>
/// Порт хранилища обучения ML
/// </summary>
public interface IMlLearningStore
{
/// <summary>
/// Число записей журнала обучения = счётчик learning
/// </summary>
/// <returns>Количество строк журнала CardMoves.</returns>
public Task<int> CountLearningAsync(CancellationToken ct);
/// <summary>
/// Число строк очереди обучения = счётчик outbox
/// </summary>
/// <returns>Количество строк очереди MlOutbox.</returns>
public Task<int> CountOutboxAsync(CancellationToken ct);
/// <summary>
/// Пишет строку очереди обучения
/// </summary>
/// <param name="id">Готовый id строки (<c>mle_...</c>, генерирует вызывающий).</param>
/// <param name="text">Текст обучающего примера (непустой, ≤6000 символов).</param>
/// <param name="label">Метка обучения: id доски, <c>spam</c> либо <c>t:hire|t:order</c>.</param>
/// <param name="delta">Вес сигнала (1.0 — учить, −1.0 — снять метку).</param>
public Task AddOutboxAsync(
string id,
string text,
string label,
double delta,
CancellationToken ct);
/// <summary>
/// Полная очистка очереди обучения
/// </summary>
public Task ClearOutboxAsync(CancellationToken ct);
/// <summary>
/// Забирает следующую порцию очереди обучения
/// </summary>
/// <param name="limit">Размер порции (флашер шлёт по 10 строк за батч).</param>
/// <returns>До <paramref name="limit"/> строк очереди в порядке created_at.</returns>
public Task<IReadOnlyList<MlOutboxEntryDto>> TakeOutboxBatchAsync(int limit, CancellationToken ct);
/// <summary>
/// Удаляет отправленные строки очереди по id.
/// </summary>
/// <param name="ids">Id строк, успешно отправленных в ML-сервис.</param>
/// <returns>Задача завершается после удаления строк.</returns>
public Task DeleteOutboxAsync(IReadOnlyCollection<string> ids, CancellationToken ct);
}
@@ -0,0 +1,193 @@
using System.Globalization;
using System.Text.RegularExpressions;
namespace Deal.Modules.Kanban.Application.ColumnRules;
/// <summary>
/// Парсер сумм из текста сообщения.
/// </summary>
public static class AmountParser
{
private const string Amount = @"\d[\d\s\u00a0]*(?:[.,]\d+)?[кkКK]?";
private static readonly (string Symbol, string Code)[] CurrencySymbols =
{
("$", "USD"), ("€", "EUR"), ("₽", "RUB"), ("₮", "USDT"), ("£", "GBP"), ("¥", "CNY"),
};
private static readonly (string Word, string Code)[] CurrencyWords =
{
("usd", "USD"), ("eur", "EUR"), ("rub", "RUB"), ("usdt", "USDT"), ("gbp", "GBP"), ("cny", "CNY"),
("руб", "RUB"), ("долл", "USD"), ("бакс", "USD"),
};
private static readonly Regex WordRangeRe = new(@"\bот\s+(" + Amount + @")\s+до\s+(" + Amount + @")", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
private static readonly Regex ToOnlyRe = new(@"\bдо\s+(" + Amount + @")", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
private static readonly Regex FromOnlyRe = new(@"\bот\s+(" + Amount + @")", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
private static readonly Regex NumericRangeRe = new("(" + Amount + @")\s*(?:[-–—]|\s+до\s+)\s*(" + Amount + @")", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
private static readonly Regex AnyAmountRe = new(Amount, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
/// <summary>
/// Разбирает суммы в тексте.
/// </summary>
/// <param name="text">Текст сообщения (сырой, как сохранил источник).</param>
/// <returns>Список распознанных сумм/диапазонов с кодом валюты; пустой список — сумм с валютой нет. Порядок — по проходам парсера, а не по позиции в тексте.</returns>
public static IReadOnlyList<AmountRange> Parse(string? text)
{
string t = text ?? string.Empty;
if (string.IsNullOrWhiteSpace(t))
{
return Array.Empty<AmountRange>();
}
var result = new List<AmountRange>();
var occupied = new List<(int Start, int End)>();
static bool IsFree(
List<(int Start, int End)> spans,
int start,
int end)
{
foreach ((int os, int oe) in spans)
{
if (start < oe && end > os)
{
return false;
}
}
return true;
}
void Add(
double? from,
double? to,
string? cur,
int start,
int end)
{
if (cur is not null && (from is not null || to is not null) && IsFree(occupied, start, end))
{
result.Add(new AmountRange(from, to, cur));
occupied.Add((start, end));
}
}
// 1) «от A до B» (+ валюта после B).
foreach (Match m in WordRangeRe.Matches(t))
{
double? a = NormalizeAmount(m.Groups[1].Value);
double? b = NormalizeAmount(m.Groups[2].Value);
string? cur = CurFromTail(t, m.Groups[2].Index + m.Groups[2].Length);
if (a is not null && b is not null && cur is not null)
{
Add(a, b, cur, m.Groups[1].Index, m.Groups[2].Index + m.Groups[2].Length);
}
}
// 2) «до B» (без пары «от … до») — верхняя граница.
foreach (Match m in ToOnlyRe.Matches(t))
{
double? b = NormalizeAmount(m.Groups[1].Value);
string? cur = CurFromTail(t, m.Groups[1].Index + m.Groups[1].Length);
if (b is not null && cur is not null)
{
Add(null, b, cur, m.Groups[1].Index, m.Groups[1].Index + m.Groups[1].Length);
}
}
// 3) «от A» без верхней границы — считаем одной суммой.
foreach (Match m in FromOnlyRe.Matches(t))
{
double? a = NormalizeAmount(m.Groups[1].Value);
string? cur = CurFromTail(t, m.Groups[1].Index + m.Groups[1].Length);
if (a is not null && cur is not null)
{
Add(a, a, cur, m.Groups[1].Index, m.Groups[1].Index + m.Groups[1].Length);
}
}
// 4) Числовые диапазоны «A–B» / «A - B» / «A до B».
foreach (Match m in NumericRangeRe.Matches(t))
{
double? a = NormalizeAmount(m.Groups[1].Value);
double? b = NormalizeAmount(m.Groups[2].Value);
string? cur = CurFromTail(t, m.Groups[2].Index + m.Groups[2].Length)
?? CurFromTail(t, m.Groups[1].Index + m.Groups[1].Length);
if (a is not null && b is not null && cur is not null)
{
Add(a, b, cur, m.Groups[1].Index, m.Groups[2].Index + m.Groups[2].Length);
}
}
// 5) Одиночные суммы с валютой (не вошедшие в конструкции выше).
foreach (Match m in AnyAmountRe.Matches(t))
{
double? a = NormalizeAmount(m.Value);
string? cur = CurFromTail(t, m.Index + m.Length);
if (a is not null && cur is not null)
{
Add(a, a, cur, m.Index, m.Index + m.Length);
}
}
return result;
}
private static double? NormalizeAmount(string raw)
{
string s = raw.Replace("\u00a0", " ").Replace(" ", string.Empty).Replace(",", ".");
double multiplier = 1.0;
if (s.Length > 0)
{
char last = s[^1];
if (char.ToLowerInvariant(last) == 'k' || char.ToLowerInvariant(last) == 'к')
{
multiplier = 1000.0;
s = s[..^1];
}
}
return double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out double value)
? value * multiplier
: null;
}
private static string? CurFromTail(string text, int position)
{
int tailLength = Math.Min(12, text.Length - position);
string tail = text.Substring(Math.Max(0, position), Math.Max(0, tailLength)).ToLowerInvariant().Trim();
foreach ((string symbol, string code) in CurrencySymbols)
{
if (tail.StartsWith(symbol, StringComparison.Ordinal))
{
return code;
}
}
foreach ((string word, string code) in CurrencyWords)
{
if (tail.StartsWith(word, StringComparison.Ordinal))
{
return code;
}
}
int headStart = Math.Max(0, position - 3);
string head = text.Substring(headStart, position - headStart).Trim();
foreach ((string symbol, string code) in CurrencySymbols)
{
if (head.EndsWith(symbol, StringComparison.Ordinal))
{
return code;
}
}
return null;
}
}
@@ -0,0 +1,9 @@
namespace Deal.Modules.Kanban.Application.ColumnRules;
/// <summary>
/// Одна распознанная сумма/диапазон из текста сообщения.
/// </summary>
/// <param name="From">Нижняя граница (null — «до B» без нижней).</param>
/// <param name="To">Верхняя граница (null не возвращается: «от A» даёт from=to=A).</param>
/// <param name="Cur">Код валюты (непустой).</param>
public sealed record AmountRange(double? From, double? To, string Cur);
@@ -0,0 +1,73 @@
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application.Services;
namespace Deal.Modules.Kanban.Application.ColumnRules;
/// <summary>
/// Проверка попадания распознанных сумм в бюджетный диапазон правил колонки.
/// </summary>
public static class BudgetInRange
{
/// <summary>
/// Попадает ли хотя бы одна распознанная сумма в диапазон правил.
/// </summary>
/// <param name="amounts">Суммы из текста (<see cref="AmountParser.Parse"/>).</param>
/// <param name="budget">Бюджет правил колонки.</param>
/// <param name="rates">Курсы к рублю «код → курс» либо null (конвертация невозможна).</param>
/// <returns>True — есть сумма, которая после конвертации (при необходимости) ≥ from и ≤ to; обе границы null → True; пустые amounts или непереводимая валюта → False.</returns>
public static bool IsInRange(
IReadOnlyList<AmountRange> amounts,
BudgetRangeDto budget,
IReadOnlyDictionary<string, double>? rates)
{
double? lo = budget.From;
double? hi = budget.To;
if (lo is null && hi is null)
{
return true;
}
string targetCurrency = string.IsNullOrWhiteSpace(budget.Cur) ? "USD" : budget.Cur.ToUpperInvariant();
foreach (AmountRange amount in amounts)
{
double? rawValue = amount.From ?? amount.To;
if (rawValue is null)
{
continue;
}
double? value;
if (string.Equals(amount.Cur, targetCurrency, StringComparison.OrdinalIgnoreCase))
{
value = rawValue;
}
else if (rates is null)
{
continue;
}
else
{
value = RatesService.ConvertAmount(rawValue, amount.Cur, targetCurrency, rates);
}
if (value is null)
{
continue;
}
if (lo is not null && value < lo)
{
continue;
}
if (hi is not null && value > hi)
{
continue;
}
return true;
}
return false;
}
}
@@ -0,0 +1,18 @@
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Kanban.Application.ColumnRules;
// Расширения бюджетной группы правил колонки.
internal static class BudgetRangeDtoExtensions
{
/// <summary>
/// Активна ли бюджетная группа
/// </summary>
/// <param name="budget">Поле budget/prices правил (может быть null).</param>
/// <returns>True — группа бюджета участвует в матчинге.</returns>
public static bool HasBudget(this BudgetRangeDto? budget)
{
return budget is not null
&& (budget.From is not null || budget.To is not null || !string.IsNullOrWhiteSpace(budget.Cur));
}
}
@@ -0,0 +1,47 @@
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Kanban.Application.ColumnRules;
/// <summary>
/// Слова-исключения колонки
/// </summary>
public static class ColumnExclusions
{
/// <summary>
/// Какие слова-исключения колонки есть в тексте.
/// </summary>
/// <param name="rules">Правила колонки (null → пустой список).</param>
/// <param name="text">Текст сообщения.</param>
/// <returns>Совпавшие термины как сохранены (с регистром пользователя); пусто — совпадений нет.</returns>
public static IReadOnlyList<string> ExcludedTerms(ContainerRulesDto? rules, string? text)
{
var result = new List<string>();
if (rules is null)
{
return result;
}
string lower = ContentNormalizer.ContentText(text ?? string.Empty).ToLowerInvariant();
foreach (string? raw in rules.Exclude)
{
string term = (raw ?? string.Empty).Trim();
if (term.Length > 0 && lower.Contains(term.ToLowerInvariant(), StringComparison.Ordinal))
{
result.Add(term);
}
}
return result;
}
/// <summary>
/// Есть ли в тексте слово-исключение колонки.
/// </summary>
/// <param name="rules">Правила колонки.</param>
/// <param name="text">Текст сообщения.</param>
/// <returns>True — хотя бы один exclude-термин найден (вето).</returns>
public static bool IsExcluded(ContainerRulesDto? rules, string? text)
{
return ExcludedTerms(rules, text).Count > 0;
}
}
@@ -0,0 +1,214 @@
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, "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;
}
}
@@ -0,0 +1,89 @@
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Kanban.Application.ColumnRules;
/// <summary>
/// Чистые правила колонок — единая точка входа модуля.
/// </summary>
public static class ColumnRules
{
/// <summary>
/// Пропускает ли колонка этот текст.
/// </summary>
/// <param name="rules">Правила колонки; null = «правил нет» (принимает любой текст).</param>
/// <param name="text">Текст сообщения/карточки.</param>
/// <param name="rates">Курсы для конвертации бюджета (см. <see cref="ColumnMatcher.MatchText"/>).</param>
/// <returns>False — сработало слово-исключение (veto) либо текст не прошёл активные правила; True — правил нет/неактивны или текст прошёл правила.</returns>
public static bool ContainerAccepts(
ContainerRulesDto? rules,
string? text,
IReadOnlyDictionary<string, double>? rates = null)
{
if (ColumnExclusions.IsExcluded(rules, text))
{
return false;
}
if (!ColumnMatcher.HasActiveRules(rules))
{
return true;
}
return ColumnMatcher.MatchText(rules, text, rates);
}
/// <summary>
/// Совпал ли текст с правилами колонки
/// </summary>
/// <param name="rules">Правила колонки.</param>
/// <param name="text">Текст сообщения.</param>
/// <param name="rates">Курсы для конвертации бюджета.</param>
/// <returns>True — текст прошёл правила в режиме all/any (исключения не учитываются).</returns>
public static bool Matches(
ContainerRulesDto? rules,
string? text,
IReadOnlyDictionary<string, double>? rates = null)
{
return ColumnMatcher.MatchText(rules, text, rates);
}
/// <summary>
/// Есть ли в правилах активная группа фильтров.
/// </summary>
/// <param name="rules">Правила колонки.</param>
/// <returns>True — правила реально фильтруют (см. <see cref="ColumnMatcher.HasActiveRules"/>).</returns>
public static bool HasActiveRules(ContainerRulesDto? rules)
{
return ColumnMatcher.HasActiveRules(rules);
}
/// <summary>
/// Совпавшие критерии правил для размещения карточки в колонку-доску.
/// </summary>
/// <param name="rules">Правила колонки-доски; null — доски нет/правил нет.</param>
/// <param name="text">Текст карточки для правил.</param>
/// <param name="rates">Курсы для конвертации бюджета.</param>
/// <returns>Список совпавших критериев (MatchHitDto label/term/word); для пустых/null правил и колонок без активных правил — []. Исключения (veto) в список не входят.</returns>
public static IReadOnlyList<MatchHitDto> ComputeHits(
ContainerRulesDto? rules,
string? text,
IReadOnlyDictionary<string, double>? rates = null)
{
if (!ColumnMatcher.HasActiveRules(rules))
{
return Array.Empty<MatchHitDto>();
}
return MatchHitBuilder.BuildHits(rules, text, rates);
}
/// <summary>
/// Русская расшифровка правил для UI/note
/// </summary>
/// <param name="rules">Правила колонки.</param>
/// <returns>«все условия · стек: …» или «без правил (решает ИИ/ML)».</returns>
public static string Describe(ContainerRulesDto? rules)
{
return RulesDescriber.Describe(rules);
}
}
@@ -0,0 +1,25 @@
using System.Text.RegularExpressions;
namespace Deal.Modules.Kanban.Application.ColumnRules;
/// <summary>
/// Нормализация текста сообщения перед матчингом правил колонок.
/// </summary>
public static class ContentNormalizer
{
private static readonly Regex MarkdownLink = new(@"\[[^\]]*\]\([^)\s]+\)");
private static readonly Regex RawUrl = new(@"https?://[^\s<>""']+|www\.[^\s<>""']+");
/// <summary>
/// Возвращает текст с вырезанными ссылками
/// </summary>
/// <param name="text">Исходный текст (null → пустая строка).</param>
/// <returns>Текст, где на месте ссылок — пробелы (слова ссылок не участвуют в поиске).</returns>
public static string ContentText(string? text)
{
string s = text ?? string.Empty;
s = MarkdownLink.Replace(s, " ");
return RawUrl.Replace(s, " ");
}
}
@@ -0,0 +1,51 @@
namespace Deal.Modules.Kanban.Application.ColumnRules;
/// <summary>
/// Синонимы грейдов/уровней правил колонки.
/// </summary>
public static class GradeAliases
{
private static readonly IReadOnlyDictionary<string, string[]> Aliases = new Dictionary<string, string[]>
{
["junior"] = new[] { "junior", "джун", "джуниор" },
["middle"] = new[] { "middle", "mid", "мидл" },
["senior"] = new[] { "senior", "сеньор", "сеньйор" },
["lead"] = new[] { "lead", "тимлид", "тиэмлид", "team lead", "teamlead", "tech lead", "техлид" },
["architect"] = new[] { "architect", "архитектор" },
["intern"] = new[] { "intern", "стажёр", "стажер", "trainee" },
};
/// <summary>
/// Расширяет теги правил синонимами.
/// </summary>
/// <param name="tags">Теги группы grade (как сохранил пользователь/фронт).</param>
/// <returns>Список термов для поиска: для известного тега — его синонимы, для неизвестного — сам тег в нижнем регистре; пустые теги пропускаются.</returns>
public static IReadOnlyList<string> ExpandTerms(IEnumerable<string?>? tags)
{
var result = new List<string>();
if (tags is null)
{
return result;
}
foreach (string? raw in tags)
{
string key = (raw ?? string.Empty).Trim().ToLowerInvariant();
if (key.Length == 0)
{
continue;
}
if (Aliases.TryGetValue(key, out string[]? synonyms))
{
result.AddRange(synonyms);
}
else
{
result.Add(key);
}
}
return result;
}
}
@@ -0,0 +1,174 @@
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Kanban.Application.ColumnRules;
/// <summary>
/// Список совпавших критериев правил — «почему карточка в колонке».
/// </summary>
public static class MatchHitBuilder
{
private const string DirectionLabel = "Направление";
// Метка группы «ключевые слова».
private const string KeywordsLabel = "Слова";
// Метка группы «стек».
private const string StackLabel = "Стек";
// Метка группы «грейд/уровень».
private const string GradeLabel = "Грейд/уровень";
private const string LevelsLabel = "Уровень";
private const string LocationsLabel = "Локация";
private const string TypesLabel = "Тип";
// Метка группы «бюджет».
private const string BudgetLabel = "Бюджет";
private const string PricesLabel = "Цена";
/// <summary>
/// Совпавшие критерии фильтра колонки.
/// </summary>
/// <param name="rules">Правила колонки (null → пустой список).</param>
/// <param name="text">Текст сообщения.</param>
/// <param name="rates">Курсы для конвертации бюджета (см. <see cref="BudgetInRange"/>).</param>
/// <returns>Список совпадений по группам; порядок — direction → keywords → stack → grade → levels → locations → types → budget → prices.</returns>
public static IReadOnlyList<MatchHitDto> BuildHits(
ContainerRulesDto? rules,
string? text,
IReadOnlyDictionary<string, double>? rates = null)
{
var result = new List<MatchHitDto>();
if (rules is null)
{
return result;
}
string lower = ContentNormalizer.ContentText(text ?? string.Empty).ToLowerInvariant();
// Направление / слова / стек / локация: каждый совпавший терм — отдельный hit (term как сохранён).
result.AddRange(MatchedTermHits(rules.Direction, DirectionLabel, lower));
result.AddRange(MatchedTermHits(rules.Keywords, KeywordsLabel, lower));
result.AddRange(MatchedTermHits(rules.Stack, StackLabel, lower));
result.AddRange(MatchedTermHits(rules.Locations, LocationsLabel, lower));
AddAliasHits(result, rules.Grade, GradeAliases.ExpandTerms, GradeLabel, lower);
AddAliasHits(result, rules.Levels, GradeAliases.ExpandTerms, LevelsLabel, lower);
// Тип: синонимы «vacancy/freelance/announcement» (TypeAliases).
AddAliasHits(result, rules.Types, TypeAliases.ExpandTerms, TypesLabel, lower);
AddRangeHit(result, rules.Budget, BudgetLabel, text, rates);
AddRangeHit(result, rules.Prices, PricesLabel, text, rates);
return result;
}
// Добавляет hits группы с синонимами: на каждый тег — первый найденный синоним (term — тег,
// word — фактический синоним).
// result: Накопитель hits.
// rawTerms: Теги группы как сохранены.
// expand: Функция расширения тега синонимами.
// label: Метка группы.
// lower: Текст в нижнем регистре.
private static void AddAliasHits(
List<MatchHitDto> result,
IReadOnlyList<string>? rawTerms,
Func<IEnumerable<string?>?, IReadOnlyList<string>> expand,
string label,
string lower)
{
if (rawTerms is null)
{
return;
}
foreach (string? raw in rawTerms)
{
string term = (raw ?? string.Empty).Trim();
if (term.Length == 0)
{
continue;
}
foreach (string alias in expand(new[] { raw }))
{
if (lower.Contains(alias, StringComparison.Ordinal))
{
result.Add(new MatchHitDto(label, term, alias));
break;
}
}
}
}
// Добавляет hit диапазона (бюджет/цена), если сумма текста попадает в границы.
// result: Накопитель hits.
// range: Диапазон группы (null — группа выключена).
// label: Метка группы.
// text: Текст сообщения.
// rates: Курсы конвертации.
private static void AddRangeHit(
List<MatchHitDto> result,
BudgetRangeDto? range,
string label,
string? text,
IReadOnlyDictionary<string, double>? rates)
{
if (range is not null
&& (range.From is not null || range.To is not null || !string.IsNullOrWhiteSpace(range.Cur))
&& BudgetInRange.IsInRange(AmountParser.Parse(text), range, rates))
{
result.Add(new MatchHitDto(label, DescribeRange(range), Word: null));
}
}
private static IEnumerable<MatchHitDto> MatchedTermHits(
IReadOnlyList<string>? rawTerms,
string label,
string lower)
{
if (rawTerms is null)
{
yield break;
}
foreach (string? raw in rawTerms)
{
string term = (raw ?? string.Empty).Trim();
if (term.Length > 0 && lower.Contains(term.ToLowerInvariant(), StringComparison.Ordinal))
{
yield return new MatchHitDto(label, term, Word: null);
}
}
}
private static string DescribeRange(BudgetRangeDto budget)
{
string cur = (budget.Cur ?? string.Empty).ToUpperInvariant();
if (budget.From is not null && budget.To is not null)
{
return $"от {FormatAmount(budget.From.Value)} до {FormatAmount(budget.To.Value)} {cur}".Trim();
}
if (budget.To is not null)
{
return $"до {FormatAmount(budget.To.Value)} {cur}".Trim();
}
if (budget.From is not null)
{
return $"от {FormatAmount(budget.From.Value)} {cur}".Trim();
}
return "бюджет";
}
private static string FormatAmount(double value)
{
return value.ToString("G6", System.Globalization.CultureInfo.InvariantCulture).Replace('E', 'e');
}
}
@@ -0,0 +1,114 @@
using System.Globalization;
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Kanban.Application.ColumnRules;
/// <summary>
/// Человекочитаемое описание правил колонки для note/подсказки.
/// </summary>
public static class RulesDescriber
{
/// <summary>
/// Описывает правила колонки.
/// </summary>
/// <param name="rules">Правила колонки (null/пустые → «без правил (решает ИИ/ML)»).</param>
/// <returns>Строка-расшифровка для UI/промпта.</returns>
public static string Describe(ContainerRulesDto? rules)
{
if (rules is null)
{
return NoRulesText;
}
var parts = new List<string>();
if (rules.Direction.Count > 0)
{
parts.Add($"направление: {JoinLimited(rules.Direction, DirectionLimit)}");
}
if (rules.Stack.Count > 0)
{
parts.Add($"стек: {JoinLimited(rules.Stack, TermsLimit)}");
}
if (rules.Keywords.Count > 0)
{
parts.Add($"слова: {JoinLimited(rules.Keywords, TermsLimit)}");
}
if (rules.Grade.Count > 0)
{
parts.Add($"грейд: {JoinLimited(rules.Grade, TermsLimit)}");
}
if (rules.Levels is { Count: > 0 })
{
parts.Add($"уровень: {JoinLimited(rules.Levels, TermsLimit)}");
}
if (rules.Locations is { Count: > 0 })
{
parts.Add($"локация: {JoinLimited(rules.Locations, TermsLimit)}");
}
if (rules.Types is { Count: > 0 })
{
parts.Add($"тип: {JoinLimited(rules.Types, TermsLimit)}");
}
if (rules.Exclude.Count > 0)
{
parts.Add($"исключено: {JoinLimited(rules.Exclude, TermsLimit)}");
}
AddRangePart(parts, rules.Budget, "бюджет");
AddRangePart(parts, rules.Prices, "цена");
if (parts.Count == 0)
{
return NoRulesText;
}
string mode = string.Equals(rules.Mode, "any", StringComparison.OrdinalIgnoreCase)
? AnyModePrefix
: AllModePrefix;
return mode + " · " + string.Join("; ", parts);
}
private const string NoRulesText = "без правил (решает ИИ/ML)";
private const string AllModePrefix = "все условия";
// Префикс режима «любое из условий».
private const string AnyModePrefix = "любое из условий";
private const int DirectionLimit = 6;
private const int TermsLimit = 8;
// Добавляет в описание диапазон группы (бюджет/цена), если задана хотя бы одна граница.
// parts: Накопитель частей описания.
// range: Диапазон группы (null — группа выключена).
// title: Подпись группы («бюджет»/«цена»).
private static void AddRangePart(
List<string> parts,
BudgetRangeDto? range,
string title)
{
if (range is null || (range.From is null && range.To is null))
{
return;
}
string lo = range.From is not null ? range.From.Value.ToString(CultureInfo.InvariantCulture) : string.Empty;
string hi = range.To is not null ? range.To.Value.ToString(CultureInfo.InvariantCulture) : string.Empty;
string cur = range.Cur ?? string.Empty;
parts.Add($"{title}: {lo}{hi} {cur}".Replace(" ", "").Replace(" ", ""));
}
private static string JoinLimited(IReadOnlyList<string> terms, int limit)
{
int count = Math.Min(terms.Count, limit);
return string.Join(", ", terms.Take(count));
}
}
@@ -0,0 +1,28 @@
namespace Deal.Modules.Kanban.Application.ColumnRules;
// Расширения списков термов правил колонки.
internal static class TermListExtensions
{
/// <summary>
/// Есть ли в списке непустой
/// </summary>
/// <param name="terms">Список термов группы (может быть null).</param>
/// <returns>True — хотя бы один терм непустой.</returns>
public static bool HasAnyTerm(this IReadOnlyList<string>? terms)
{
if (terms is null)
{
return false;
}
foreach (string? raw in terms)
{
if (!string.IsNullOrWhiteSpace(raw))
{
return true;
}
}
return false;
}
}
@@ -0,0 +1,49 @@
namespace Deal.Modules.Kanban.Application.ColumnRules;
/// <summary>
/// Синонимы типов заявки для группы <c>types</c> правил колонки и глобальных исключений.
/// </summary>
public static class TypeAliases
{
// Карта «тег типа → синонимы» в нижнем регистре.
private static readonly IReadOnlyDictionary<string, string[]> Aliases = new Dictionary<string, string[]>
{
["vacancy"] = new[] { "вакансия", "вакансию", "вакансии", "вакант", "найм", "нанимаем", "full-time", "занятость", "в штат", "в команду" },
["freelance"] = new[] { "фриланс", "заказ", "закажу", "проект", "подработка", "подряд", "разово", "услуга" },
["announcement"] = new[] { "объявление", "продам", "куплю", "отдам", "продаётся", "продается" },
};
/// <summary>
/// Расширяет теги типов синонимами.
/// </summary>
/// <param name="tags">Теги группы types (как сохранил пользователь/фронт).</param>
/// <returns>Список термов для поиска: для известного тега — его синонимы, для неизвестного — сам тег в нижнем регистре; пустые теги пропускаются.</returns>
public static IReadOnlyList<string> ExpandTerms(IEnumerable<string?>? tags)
{
var result = new List<string>();
if (tags is null)
{
return result;
}
foreach (string? raw in tags)
{
string key = (raw ?? string.Empty).Trim().ToLowerInvariant();
if (key.Length == 0)
{
continue;
}
if (Aliases.TryGetValue(key, out string[]? synonyms))
{
result.AddRange(synonyms);
}
else
{
result.Add(key);
}
}
return result;
}
}
@@ -0,0 +1,13 @@
namespace Deal.Modules.Kanban.Application.Extensions;
// Расширения символов для нормализации бюджетной валюты.
internal static class CharExtensions
{
/// <summary>
/// Буква кода валюты
/// </summary>
/// <param name="c">Символ (строка уже в верхнем регистре).</param>
/// <returns>True — буква, участвующая в распознавании валюты.</returns>
public static bool IsCurrencyLetter(this char c) =>
c is >= 'A' and <= 'Z' or >= 'А' and <= 'Я';
}
@@ -0,0 +1,8 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Результат добавления комментария — CardsService.AddCommentAsync.
/// </summary>
/// <param name="Error">Текст 400 (пустой комментарий) либо null.</param>
/// <param name="Comments">Список комментариев после добавления либо null (400/карточки нет).</param>
public sealed record AddCommentResultDto(string? Error, IReadOnlyList<CardCommentDto>? Comments);
@@ -0,0 +1,8 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Пример разметки пользователя для few-shot ИИ-классификации — результат ICardStore.GetAiMarkupExamplesAsync.
/// </summary>
/// <param name="Text">Исходный текст карточки (source_msg сообщения-источника).</param>
/// <param name="Board">Колонка-назначение переноса (id доски, не служебная).</param>
public sealed record AiMarkupExampleDto(string Text, string Board);
@@ -0,0 +1,6 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Бюджетный диапазон в правилах доски — поле <c>budget</c> объекта rules.
/// </summary>
public sealed record BudgetRangeDto(double? From, double? To, string Cur);
@@ -0,0 +1,6 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Бюджет карточки — поля <c>budget</c>/<c>converted</c> карточки.
/// </summary>
public sealed record CardBudgetDto(double? From, double? To, string Cur);
@@ -0,0 +1,6 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Счётчик колонки в ответе counts — значение словаря «col → {count, new}».
/// </summary>
public sealed record CardColumnCountDto(int Count, int New);
@@ -0,0 +1,12 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Перенос карточки в другую колонку/состояние — параметр ICardStore.UpdateColumnAsync.
/// </summary>
public sealed record CardColumnUpdateDto(
string CardId,
string Col,
bool IsNew,
string? PrevCol,
DateTimeOffset? ArchivedAt,
IReadOnlyList<MatchHitDto> MatchHits);
@@ -0,0 +1,10 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Комментарий карточки — элемент массива <c>comments</c>.
/// </summary>
public sealed record CardCommentDto(
string Id,
string By,
string Text,
string Time);
@@ -0,0 +1,6 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Контакт карточки — элемент массива <c>contacts</c>.
/// </summary>
public sealed record CardContactDto(string Type, string Value);
@@ -0,0 +1,33 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Ответ GET /api/cards/counts.
/// </summary>
public sealed record CardCountsDto
{
/// <summary>
/// Суммарное число «новых» карточек по всем колонкам.
/// </summary>
public int New { get; init; }
/// <summary>
/// Счётчики колонок
/// </summary>
public IReadOnlyDictionary<string, CardColumnCountDto> Columns { get; init; } =
new Dictionary<string, CardColumnCountDto>();
/// <summary>
/// Число обучающих действий
/// </summary>
public int Learning { get; init; }
/// <summary>
/// Обработано ML-решений.
/// </summary>
public int Ml { get; init; }
/// <summary>
/// Обработано ИИ-решений.
/// </summary>
public int Ai { get; init; }
}
@@ -0,0 +1,153 @@
using System.Text.Json.Serialization;
using Deal.Modules.Cards.Application.Sources;
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Карточка — единая сущность всех дашбордов
/// </summary>
public sealed record CardDto
{
/// <summary>
/// Короткий id карточки
/// </summary>
public string Id { get; init; } = string.Empty;
/// <summary>
/// Колонка карточки
/// </summary>
public string Col { get; init; } = string.Empty;
/// <summary>
/// Id контейнера
/// </summary>
public string ContainerId => Col;
/// <summary>
/// Флаг «новое»
/// </summary>
public bool IsNew { get; init; }
/// <summary>
/// Признак «карточка создана локально»
/// </summary>
public bool Local { get; init; }
/// <summary>
/// Найм (вакансия) vs разовая сделка (маркерная гипотеза, не ИИ).
/// </summary>
public bool IsVacancy { get; init; }
/// <summary>
/// Тип «найм/разовое» подтверждён ИИ по контексту сообщения.
/// </summary>
public bool IsVacancyKnown { get; init; }
/// <summary>
/// Заголовок карточки
/// </summary>
public string Title { get; init; } = string.Empty;
/// <summary>
/// Блок «О заявке»
/// </summary>
public string Summary { get; init; } = string.Empty;
/// <summary>
/// Источник записи.
/// </summary>
public SourceRef Source { get; init; } = SourceRefs.Empty;
/// <summary>
/// Содержимое записи источника.
/// </summary>
public SourceContent Content { get; init; } = new();
/// <summary>
/// Стек/направления
/// </summary>
public IReadOnlyList<string> Stack { get; init; } = Array.Empty<string>();
/// <summary>
/// Бюджет по исходному сообщению; null — сумма не указана
/// </summary>
public CardBudgetDto? Budget { get; init; }
/// <summary>
/// Бюджет, пересчитанный в целевую валюту тенанта; null — конверсия не сделана.
/// </summary>
public CardBudgetDto? Converted { get; init; }
/// <summary>
/// «Быстрый» контакт
/// </summary>
public string Contact { get; init; } = string.Empty;
/// <summary>
/// Квалифицированные контакты.
/// </summary>
public IReadOnlyList<CardContactDto> Contacts { get; init; } = Array.Empty<CardContactDto>();
/// <summary>
/// Человеческая метка возраста
/// </summary>
public string Time { get; init; } = string.Empty;
/// <summary>
/// Время получения исходного сообщения, epoch-ms
/// </summary>
[property: JsonPropertyName("receivedAt")]
public long ReceivedAtMs { get; init; }
/// <summary>
/// Предыдущая колонка
/// </summary>
public string PrevCol { get; init; } = string.Empty;
/// <summary>
/// Совпавшие критерии правил — почему карточка в этой колонке.
/// </summary>
public IReadOnlyList<MatchHitDto> MatchHits { get; init; } = Array.Empty<MatchHitDto>();
/// <summary>
/// Комментарии карточки
/// </summary>
public IReadOnlyList<CardCommentDto> Comments { get; init; } = Array.Empty<CardCommentDto>();
/// <summary>
/// Ссылки карточки.
/// </summary>
public IReadOnlyList<CardLinkDto> Links { get; init; } = Array.Empty<CardLinkDto>();
/// <summary>
/// Файлы карточки.
/// </summary>
public IReadOnlyList<CardFileDto> Files { get; init; } = Array.Empty<CardFileDto>();
/// <summary>
/// История движения карточки.
/// </summary>
public IReadOnlyList<CardHistoryDto> History { get; init; } = Array.Empty<CardHistoryDto>();
/// <summary>
/// Текст технического задания.
/// </summary>
public string TzText { get; init; } = string.Empty;
/// <summary>
/// Напоминание ({at}) либо null — напоминания нет.
/// </summary>
public CardReminderDto? Reminder { get; init; }
/// <summary>
/// Время создания, epoch-ms
/// </summary>
[property: JsonPropertyName("createdAt")]
public long CreatedAtMs { get; init; }
/// <summary>
/// Время последнего изменения, epoch-ms
/// </summary>
[property: JsonPropertyName("updatedAt")]
public long UpdatedAtMs { get; init; }
}
@@ -0,0 +1,18 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Файл карточки — элемент массива <c>files</c>.
/// </summary>
/// <param name="Id">Короткий id записи файла (<c>pf_...</c>).</param>
/// <param name="Name">Имя файла.</param>
/// <param name="Size">Размер в байтах.</param>
/// <param name="Kind">Тип: image/video/audio/archive/document/other.</param>
/// <param name="Label">Человекочитаемая метка типа.</param>
/// <param name="ObjectKey">Ключ объекта в файловом хранилище.</param>
public sealed record CardFileDto(
string Id,
string Name,
long Size,
string Kind,
string Label,
string ObjectKey);
@@ -0,0 +1,12 @@
using Deal.Modules.Kanban.Application.Services;
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Результат <see cref="FileKindDetector.Detect"/> — kind/label вложения.
/// </summary>
/// <param name="Kind">Категория файла: <c>image|video|audio|archive|document|other</c>.</param>
/// <param name="Label">Человекочитаемая метка («Изображение», «Документ», «Файл» …).</param>
public sealed record CardFileKind(
string Kind,
string Label);
@@ -0,0 +1,16 @@
using System.Text.Json.Serialization;
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Запись истории движения карточки — элемент массива <c>history</c>.
/// </summary>
/// <param name="Id">Короткий id записи (<c>h_...</c>).</param>
/// <param name="At">Время события, epoch-ms.</param>
/// <param name="Type">Тип создания; null у move-записей.</param>
/// <param name="Stage">Новая стадия; null у записей создания.</param>
public sealed record CardHistoryDto(
string Id,
long At,
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Type,
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Stage);
@@ -0,0 +1,12 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Ссылка карточки — элемент массива <c>links</c>.
/// </summary>
/// <param name="Id">Короткий id ссылки (<c>pl_...</c>).</param>
/// <param name="Name">Название ссылки.</param>
/// <param name="Url">URL ссылки.</param>
public sealed record CardLinkDto(
string Id,
string Name,
string Url);
@@ -0,0 +1,20 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Начальные поля ручного
/// </summary>
/// <param name="Title">Заголовок карточки (при создании Trim; пустой допустим).</param>
/// <param name="Summary">Краткое содержание карточки.</param>
/// <param name="Stack">Стек/направления (null — пусто).</param>
/// <param name="Budget">Бюджет (from/to/cur); null — бюджета нет.</param>
/// <param name="Contact">Контактная строка карточки.</param>
/// <param name="TzText">Текст технического задания.</param>
/// <param name="ContainerId">Желаемый контейнер-стадия (id каталога <see cref="Deal.Modules.Cards.Application.Models.CardsDefaultContainers"/>); null или неизвестная — карточка создаётся в <c>planned</c>.</param>
public sealed record CardLocalCreateDto(
string Title = "",
string Summary = "",
IReadOnlyList<string>? Stack = null,
CardBudgetDto? Budget = null,
string Contact = "",
string TzText = "",
string? ContainerId = null);
@@ -0,0 +1,11 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Запись журнала действий над карточкой — параметр ICardStore.AddMoveAsync.
/// </summary>
public sealed record CardMoveDto(
string Id,
string LeadId,
string Action,
string? FromCol,
string? ToCol);
@@ -0,0 +1,24 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Частичная правка карточки.
/// </summary>
/// <param name="Title">Новый заголовок.</param>
/// <param name="Summary">Новое краткое содержание.</param>
/// <param name="Contact">Новая контактная строка.</param>
/// <param name="TzText">Новый текст технического задания.</param>
/// <param name="Stack">Новый стек (полная замена массива).</param>
/// <param name="Budget">Новый бюджет (полная замена from/to/cur).</param>
/// <param name="Comments">Новый массив комментариев (полная замена).</param>
/// <param name="Links">Новый массив ссылок (полная замена).</param>
/// <param name="Files">Новый массив файлов (полная замена).</param>
public sealed record CardPatch(
string? Title,
string? Summary,
string? Contact,
string? TzText,
IReadOnlyList<string>? Stack,
CardBudgetDto? Budget,
IReadOnlyList<CardCommentDto>? Comments,
IReadOnlyList<CardLinkDto>? Links,
IReadOnlyList<CardFileDto>? Files);
@@ -0,0 +1,32 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Результат ручной переклассификации карточки — параметр ICardStore.ApplyReclassificationAsync.
/// </summary>
/// <param name="CardId">Id карточки (<c>c_...</c>).</param>
/// <param name="Col">Новая колонка (доска либо inbox — страховку ContainerAccepts уже применил вызывающий).</param>
/// <param name="IsNew">Флаг «новое».</param>
/// <param name="IsVacancy">Признак найма/заказа после переклассификации.</param>
/// <param name="IsVacancyKnown">Тип подтверждён классификатором (иначе — маркерная гипотеза).</param>
/// <param name="Title">Новый заголовок (очищенный, ≤140).</param>
/// <param name="Summary">Новый блок «О заявке» (≤2000).</param>
/// <param name="Stack">Новый стек (полная замена массива).</param>
/// <param name="Budget">Бюджет по исходнику; null — суммы нет (поля очищаются).</param>
/// <param name="Converted">Бюджет в целевой валюте; null — конверсии нет (поля очищаются).</param>
/// <param name="Contact">«Быстрый» контакт (значение основного контакта).</param>
/// <param name="Contacts">Квалифицированные контакты (полная замена).</param>
/// <param name="MatchHits">Совпавшие критерии правил доски (для inbox — пусто).</param>
public sealed record CardReclassificationDto(
string CardId,
string Col,
bool IsNew,
bool IsVacancy,
bool IsVacancyKnown,
string Title,
string Summary,
IReadOnlyList<string> Stack,
CardBudgetDto? Budget,
CardBudgetDto? Converted,
string Contact,
IReadOnlyList<CardContactDto> Contacts,
IReadOnlyList<MatchHitDto> MatchHits);
@@ -0,0 +1,7 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Напоминание карточки — объект <c>reminder</c>.
/// </summary>
/// <param name="At">Время напоминания, epoch-ms.</param>
public sealed record CardReminderDto(long At);
@@ -0,0 +1,12 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Строка «выстрелившего» напоминания — мини-DTO выборки ICardStore.ListDueRemindersAsync.
/// </summary>
/// <param name="Id">Id карточки (<c>c_...</c>).</param>
/// <param name="Title">Заголовок карточки (для уведомления).</param>
/// <param name="ContainerId">Контейнер карточки (всегда <c>hold</c> на момент срабатывания).</param>
public sealed record CardReminderDueDto(
string Id,
string Title,
string ContainerId);
@@ -0,0 +1,8 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Результат мутации карточки, отвечающей карточкой
/// </summary>
/// <param name="Error">Текст 400 (фиксированная строка) либо null.</param>
/// <param name="Card">Карточка после мутации либо null (400/карточки нет).</param>
public sealed record CardResultDto(string? Error, CardDto? Card);
@@ -0,0 +1,139 @@
using Deal.Modules.Cards.Application.Sources;
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// «Сырая» запись для создания карточки
/// </summary>
public sealed record CardSnapshot
{
/// <summary>
/// Готовый id карточки
/// </summary>
public string Id { get; init; } = string.Empty;
/// <summary>
/// Контейнер размещения
/// </summary>
public string Col { get; init; } = string.Empty;
/// <summary>
/// Новая карточка
/// </summary>
public bool IsNew { get; init; } = true;
/// <summary>
/// Признак «карточка создана локально»
/// </summary>
public bool Local { get; init; }
/// <summary>
/// Найм (вакансия) vs разовая сделка (маркерная гипотеза).
/// </summary>
public bool IsVacancy { get; init; }
/// <summary>
/// Тип подтверждён ИИ.
/// </summary>
public bool IsVacancyKnown { get; init; }
/// <summary>
/// Заголовок карточки
/// </summary>
public string Title { get; init; } = string.Empty;
/// <summary>
/// Блок «О заявке»
/// </summary>
public string Summary { get; init; } = string.Empty;
/// <summary>
/// Стек/направления.
/// </summary>
public IReadOnlyList<string> Stack { get; init; } = Array.Empty<string>();
/// <summary>
/// Нижняя граница бюджета
/// </summary>
public double? BudgetFrom { get; init; }
/// <summary>
/// Верхняя граница бюджета
/// </summary>
public double? BudgetTo { get; init; }
/// <summary>
/// Валюта бюджета; пусто — бюджет не задан.
/// </summary>
public string BudgetCur { get; init; } = string.Empty;
/// <summary>
/// Сконвертированная нижняя граница
/// </summary>
public double? ConvFrom { get; init; }
/// <summary>
/// Сконвертированная верхняя граница
/// </summary>
public double? ConvTo { get; init; }
/// <summary>
/// Валюта сконвертированного бюджета; пусто — конверсия не сделана.
/// </summary>
public string ConvCur { get; init; } = string.Empty;
/// <summary>
/// «Быстрый» контакт
/// </summary>
public string Contact { get; init; } = string.Empty;
/// <summary>
/// Квалифицированные контакты.
/// </summary>
public IReadOnlyList<CardContactDto> Contacts { get; init; } = Array.Empty<CardContactDto>();
/// <summary>
/// Источник записи.
/// </summary>
public SourceRef Source { get; init; } = SourceRefs.Empty;
/// <summary>
/// Содержимое записи источника.
/// </summary>
public SourceContent Content { get; init; } = new();
/// <summary>
/// Время получения исходного сообщения
/// </summary>
public DateTimeOffset ReceivedAt { get; init; }
/// <summary>
/// Предыдущая колонка
/// </summary>
public string PrevCol { get; init; } = string.Empty;
/// <summary>
/// Время помещения в архив
/// </summary>
public DateTimeOffset? ArchivedAt { get; init; }
/// <summary>
/// Совпавшие критерии правил
/// </summary>
public IReadOnlyList<MatchHitDto> MatchHits { get; init; } = Array.Empty<MatchHitDto>();
/// <summary>
/// Текст технического задания
/// </summary>
public string TzText { get; init; } = string.Empty;
/// <summary>
/// Стартовые комментарии карточки
/// </summary>
public IReadOnlyList<CardCommentDto> Comments { get; init; } = Array.Empty<CardCommentDto>();
/// <summary>
/// История движения
/// </summary>
public IReadOnlyList<CardHistoryDto> History { get; init; } = Array.Empty<CardHistoryDto>();
}
@@ -0,0 +1,6 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Запрос списка карточек — параметр ICardStore.ListCardsAsync.
/// </summary>
public sealed record CardsQuery(string? Col);
@@ -0,0 +1,8 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Результат очистки служебной колонки — CardsService.ClearColAsync.
/// </summary>
/// <param name="Error">Текст 400 (колонка не служебная trash/archive) либо null.</param>
/// <param name="Cleared">Число удалённых карточек (валидная колонка); 0 при ошибке.</param>
public sealed record ClearColResultDto(string? Error, int Cleared);
@@ -0,0 +1,8 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Состояние одной колонки в KV-настройке <c>colState</c> — значение словаря «col → состояние».
/// </summary>
/// <param name="Collapsed">Свёрнута ли колонка в виджет (null — не задано).</param>
/// <param name="Width">Ширина колонки sm|md|lg (null — не задано).</param>
public sealed record ColumnStateDto(bool? Collapsed, string? Width);
@@ -0,0 +1,8 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Счётчики карточек контейнера — поле <c>counts</c> ответа /api/containers.
/// </summary>
/// <param name="Total">Всего карточек в контейнере.</param>
/// <param name="New">Из них «новых» (is_new = true).</param>
public sealed record ContainerCountsDto(int Total, int New);
@@ -0,0 +1,22 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Вход создания контейнера — параметр ContainersService.CreateAsync
/// </summary>
/// <param name="Name">Имя колонки (пустое → «Новая колонка»).</param>
/// <param name="Description">Описание колонки.</param>
/// <param name="Color">Цвет (hex) либо null — из палитры.</param>
/// <param name="Space">Пространство (по умолчанию dashboard).</param>
/// <param name="Kind">Вид контейнера (по умолчанию board).</param>
/// <param name="Suggested">Признак ИИ-предложения.</param>
/// <param name="Rules">Правила попадания.</param>
/// <param name="Note">Заметка/обоснование.</param>
public sealed record ContainerCreateDto(
string Name,
string Description = "",
string? Color = null,
string Space = "dashboard",
string Kind = "board",
bool Suggested = false,
ContainerRulesDto? Rules = null,
string Note = "");
@@ -0,0 +1,72 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Единый контейнер карточек
/// </summary>
public sealed record ContainerDto
{
/// <summary>
/// Короткий id контейнера
/// </summary>
public string Id { get; init; } = string.Empty;
/// <summary>
/// Имя для отображения
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// Описание контейнера
/// </summary>
public string Description { get; init; } = string.Empty;
/// <summary>
/// Цвет контейнера
/// </summary>
public string Color { get; init; } = string.Empty;
/// <summary>
/// Позиция в пространстве
/// </summary>
public int Order { get; init; }
/// <summary>
/// Id пространства
/// </summary>
public string Space { get; init; } = ContainerSpaces.Dashboard;
/// <summary>
/// Вид контейнера: <c>board</c> | <c>stage</c> | <c>service</c> | <c>terminal</c>.
/// </summary>
public string Kind { get; init; } = ContainerKinds.Board;
/// <summary>
/// Свёрнутость колонки на дашборде
/// </summary>
public bool Collapsed { get; init; }
/// <summary>
/// Признак ИИ-предложения
/// </summary>
public bool Suggested { get; init; }
/// <summary>
/// Заметка контейнера
/// </summary>
public string Note { get; init; } = string.Empty;
/// <summary>
/// Правила попадания карточки; null — фильтра нет
/// </summary>
public ContainerRulesDto? Rules { get; init; }
/// <summary>
/// Политика контейнера
/// </summary>
public ContainerPolicyDto Policy { get; init; } = new();
/// <summary>
/// Счётчики карточек контейнера
/// </summary>
public ContainerCountsDto Counts { get; init; } = new(0, 0);
}
@@ -0,0 +1,27 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Реестр видов контейнеров
/// </summary>
public static class ContainerKinds
{
/// <summary>
/// Пользовательская колонка-фильтр дашборда.
/// </summary>
public const string Board = "board";
/// <summary>
/// Стадия пространства «Выбранные».
/// </summary>
public const string Stage = "stage";
/// <summary>
/// Служебная зона
/// </summary>
public const string Service = "service";
/// <summary>
/// Терминальная стадия
/// </summary>
public const string Terminal = "terminal";
}
@@ -0,0 +1,22 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Патч контейнера — допустимые изменения PATCH /api/containers/{id}.
/// </summary>
/// <param name="Name">Новое имя (null — не менять).</param>
/// <param name="Description">Новое описание (null — не менять).</param>
/// <param name="Color">Новый цвет (null — не менять).</param>
/// <param name="Collapsed">Новая свёрнутость (null — не менять).</param>
/// <param name="Suggested">Признак ИИ-предложения (null — не менять).</param>
/// <param name="Note">Новая заметка (null — не менять).</param>
/// <param name="Rules">Новые правила (null — не менять).</param>
/// <param name="Policy">Новая политика (null — не менять).</param>
public sealed record ContainerPatchDto(
string? Name,
string? Description,
string? Color,
bool? Collapsed,
bool? Suggested,
string? Note,
ContainerRulesDto? Rules,
ContainerPolicyDto? Policy);
@@ -0,0 +1,22 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Политика контейнера
/// </summary>
public sealed record ContainerPolicyDto
{
/// <summary>
/// Можно ли вернуть карточку из контейнера на доску пространства
/// </summary>
public bool CanRestore { get; init; } = true;
/// <summary>
/// Терминальная зона
/// </summary>
public bool IsTerminal { get; init; }
/// <summary>
/// Срок автоочистки карточек контейнера в днях; null — автоочистки нет.
/// </summary>
public int? RetentionDays { get; init; }
}
@@ -0,0 +1,17 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Правила фильтра колонки — объект <c>rules</c> доски.
/// </summary>
public sealed record ContainerRulesDto(
string Mode,
IReadOnlyList<string> Direction,
IReadOnlyList<string> Keywords,
IReadOnlyList<string> Stack,
IReadOnlyList<string> Grade,
IReadOnlyList<string> Exclude,
BudgetRangeDto? Budget,
IReadOnlyList<string>? Levels = null,
IReadOnlyList<string>? Locations = null,
IReadOnlyList<string>? Types = null,
BudgetRangeDto? Prices = null);
@@ -0,0 +1,17 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Реестр пространств контейнеров
/// </summary>
public static class ContainerSpaces
{
/// <summary>
/// Пространство дашборда
/// </summary>
public const string Dashboard = "dashboard";
/// <summary>
/// Пространство «Выбранные»
/// </summary>
public const string Selected = "selected";
}
@@ -0,0 +1,22 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Реестр служебных
/// </summary>
public static class KanbanColumns
{
/// <summary>
/// «Неразобранное» — приёмная колонка
/// </summary>
public const string Inbox = "inbox";
/// <summary>
/// Архив: карточки, ушедшие по правилам хранения
/// </summary>
public const string Archive = "archive";
/// <summary>
/// Корзина: удалённые пользователем карточки
/// </summary>
public const string Trash = "trash";
}
@@ -0,0 +1,49 @@
using Deal.Modules.Cards.Application.Models;
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Реестр префиксов коротких id модуля Kanban.
/// </summary>
public static class KanbanIdPrefixes
{
/// <summary>
/// Префикс id контейнера-доски
/// </summary>
public const string Board = "b_";
/// <summary>
/// Префикс id карточки
/// </summary>
public const string Card = CardIds.CardPrefix;
/// <summary>
/// Префикс id комментария карточки
/// </summary>
public const string Comment = "cm_";
/// <summary>
/// Префикс id записи журнала действий
/// </summary>
public const string CardMove = "lm_";
/// <summary>
/// Префикс id ссылки карточки
/// </summary>
public const string Link = "pl_";
/// <summary>
/// Префикс id файла карточки
/// </summary>
public const string File = "pf_";
/// <summary>
/// Префикс id записи истории движения
/// </summary>
public const string History = "h_";
/// <summary>
/// Префикс id строки очереди обучения
/// </summary>
public const string MlOutbox = "mle_";
}
@@ -0,0 +1,9 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Совпадение критерия правил — элемент массива <c>matchHits</c>.
/// </summary>
public sealed record MatchHitDto(
string Label,
string Term,
string? Word);
@@ -0,0 +1,14 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Строка очереди обучения ML
/// </summary>
/// <param name="Id">Id строки очереди (<c>mle_...</c>).</param>
/// <param name="Text">Текст обучающего примера (trim-нут, ≤6000 символов).</param>
/// <param name="Label">Метка обучения: id доски, <c>spam</c> либо <c>t:hire|t:order</c>.</param>
/// <param name="Delta">Вес сигнала (1.0 — учить, −1.0 — снять метку).</param>
public sealed record MlOutboxEntryDto(
string Id,
string Text,
string Label,
double Delta);
@@ -0,0 +1,22 @@
using System.Security.Cryptography;
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Генератор коротких префиксных id модуля Kanban
/// </summary>
public static class PrefixId
{
// Размер случайной части в байтах: 6 байт → 12 hex-символов (uuid4().hex[:12]).
private const int RandomHexBytes = 6;
/// <summary>
/// Новый id: префикс + 12 случайных hex-символов в нижнем регистре.
/// </summary>
/// <param name="prefix">Префикс типа записи (см. <see cref="KanbanIdPrefixes"/>).</param>
/// <returns>Короткий id записи (например, <c>b_1a2b3c4d5e6f</c>).</returns>
public static string New(string prefix)
{
return prefix + Convert.ToHexString(RandomNumberGenerator.GetBytes(RandomHexBytes)).ToLowerInvariant();
}
}
@@ -0,0 +1,10 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Статистика тика правил хранения — поле <c>storage</c> ответа POST /api/admin/tick.
/// </summary>
public sealed record StorageTickStatsDto(
int Archived,
int PurgedArchive,
int PurgedTrash,
int PurgedRejected);
@@ -0,0 +1,14 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// План одной колонки-предложения — элемент выхода <c>SuggestHeuristics</c>.
/// </summary>
/// <param name="Word">Тема-слово группы (нижний регистр; станет keywords-правилом колонки).</param>
/// <param name="Name">Имя колонки-предложения (Word с заглавной буквы, ≤40 символов).</param>
/// <param name="CardIds">Id карточек группы (в порядке выдачи окна, ≥2, без пересечений между планами).</param>
/// <param name="Note">Обоснование колонки (сколько карточек и по какому слову собраны).</param>
public sealed record SuggestedColumnPlan(
string Word,
string Name,
IReadOnlyList<string> CardIds,
string Note);
@@ -0,0 +1,26 @@
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Settings.Application.Abstractions;
using Microsoft.Extensions.DependencyInjection;
namespace Deal.Modules.Kanban.Application.Registrars;
/// <summary>
/// DI-регистрация модуля Kanban.
/// </summary>
public static class KanbanModuleRegistrar
{
/// <summary>
/// Регистрирует сервисы модуля Kanban в контейнере.
/// </summary>
public static IServiceCollection AddKanbanModule(this IServiceCollection services)
{
services.AddScoped<ContainersService>();
services.AddScoped<CardsService>();
services.AddScoped<StorageTickService>();
services.AddScoped<SourceContentResolver>();
services.AddScoped<IRatesChangedListener, ConversionRecomputer>();
return services;
}
}
@@ -0,0 +1,154 @@
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application.Services;
namespace Deal.Modules.Kanban.Application.Services;
/// <summary>
/// Нормализация бюджета «при поступлении»
/// </summary>
public static class BudgetNormalizer
{
private const string DefaultTargetCurrency = "RUB";
private static readonly IReadOnlyDictionary<string, string> CurrencyAliases = new Dictionary<string, string>
{
["USD"] = "USD", ["$"] = "USD", ["US$"] = "USD", ["ДОЛЛАР"] = "USD", ["ДОЛЛАРОВ"] = "USD",
["ДОЛЛ"] = "USD", ["БАКС"] = "USD", ["БАКСОВ"] = "USD",
["EUR"] = "EUR", ["€"] = "EUR", ["ЕВРО"] = "EUR",
["RUB"] = "RUB", ["RUR"] = "RUB", ["₽"] = "RUB", ["РУБ"] = "RUB", ["РУБЛЕЙ"] = "RUB",
["РУБЛИ"] = "RUB", ["РУБЛЬ"] = "RUB", ["РУБЛЯ"] = "RUB",
["GBP"] = "GBP", ["£"] = "GBP", ["CNY"] = "CNY", ["¥"] = "CNY", ["USDT"] = "USDT", ["₮"] = "USDT",
};
/// <summary>
/// Нормализует бюджет к форме хранения.
/// </summary>
/// <param name="budget">Бюджет из ИИ/локального разбора (null — бюджета нет).</param>
/// <returns>Нормализованный бюджет CardBudgetDto(from/to/cur) либо null: бюджет отсутствует, валюта не распознана или обе границы отсутствуют/равны нулю. from=0 трактуется как отсутствие нижней границы; to без from у «одной суммы» приравнивается к from.</returns>
public static CardBudgetDto? Normalize(BudgetRangeDto? budget)
{
if (budget is null)
{
return null;
}
string? cur = NormalizeCurrency(budget.Cur);
if (cur is null)
{
return null;
}
double? from = NormalizeBound(budget.From);
double? to = NormalizeBound(budget.To);
if (from is null && to is null)
{
return null;
}
if (to is null)
{
to = from; // одна сумма или «от X» без верхней границы (ai.py L337338)
}
return new CardBudgetDto(from, to, cur);
}
/// <summary>
/// Пересчёт бюджета в целевую валюту «один раз при поступлении».
/// </summary>
/// <param name="budget">Нормализованный бюджет (валюта — код, см. <see cref="Normalize"/>).</param>
/// <param name="conversionOn">Настройка conversionOn; false → конверсия снята.</param>
/// <param name="targetCurrency">Настройка targetCurrency.</param>
/// <param name="rates">Курсы к рублю «код → курс» (кэш ratesCache либо мок-курсы).</param>
/// <returns>Сконвертированный бюджет CardBudgetDto(convFrom, convTo, targetCurrency), либо null — конверсия выключена/бюджета нет/валюта не задана. При валюте, отсутствующей в курсах, границы null, но целевая валюта сохраняется.</returns>
public static CardBudgetDto? ToTarget(
CardBudgetDto? budget,
bool conversionOn,
string? targetCurrency,
IReadOnlyDictionary<string, double>? rates)
{
if (budget is null || string.IsNullOrWhiteSpace(budget.Cur))
{
return null;
}
string target = string.IsNullOrWhiteSpace(targetCurrency)
? DefaultTargetCurrency
: targetCurrency.ToUpperInvariant();
if (!conversionOn)
{
return null;
}
double? convFrom = null;
double? convTo = null;
if (budget.From is not null)
{
convFrom = Convert(budget.From.Value, budget.Cur, target, rates);
}
if (budget.To is not null)
{
convTo = Convert(budget.To.Value, budget.Cur, target, rates);
}
else if (budget.From is not null)
{
convTo = convFrom;
}
return new CardBudgetDto(convFrom, convTo, target);
}
private static double? NormalizeBound(double? value)
{
if (value is null || value.Value == 0)
{
return null;
}
return value.Value;
}
private static string? NormalizeCurrency(string? raw)
{
string s = (raw ?? string.Empty).Trim().ToUpperInvariant();
if (s.Length == 0)
{
return null;
}
if (CurrencyAliases.TryGetValue(s, out string? direct))
{
return direct;
}
string letters = new string(s.Where(c => c.IsCurrencyLetter()).ToArray());
if (CurrencyAliases.TryGetValue(letters, out string? fromLetters))
{
return fromLetters;
}
if (s.Length == 3 && s.All(c => c.IsCurrencyLetter()))
{
return s;
}
return null;
}
// Конвертация суммы через курсы к рублю; null rates → null (курсов нет — граница не конвертируется).
// amount: Сумма.
// fromCurrency: Исходная валюта (код).
// toCurrency: Целевая валюта (код).
// rates: Курсы к рублю либо null.
// Возвращает: Сумма в целевой валюте или null.
private static double? Convert(
double amount,
string fromCurrency,
string toCurrency,
IReadOnlyDictionary<string, double>? rates)
{
return rates is null ? null : RatesService.ConvertAmount(amount, fromCurrency, toCurrency, rates);
}
}
@@ -0,0 +1,143 @@
using Deal.Contracts.Integrations.Abstractions;
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Kanban.Application.Services;
/// <summary>
/// Файлы карточки — partial-часть <see cref="CardsService"/>
/// </summary>
public sealed partial class CardsService
{
private const string ObjectRootSegment = "projects";
// Замена недопустимых символов имени в objectKey (path-разделители/кавычки заменяются).
private const char KeyNameReplacement = '_';
// Символы имени, заменяемые в objectKey: path-разделители («/», «\») и кавычки «"».
private const string KeyNameUnsafeCharacters = "/\\\"";
private const string DefaultAttachmentName = "file";
/// <summary>
/// Добавляет файл карточке
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="fileName">Имя файла как прислано (в objectKey санитизируется); пустое → «file».</param>
/// <param name="contentType">MIME-тип загрузки (может быть null/пустым — детект по расширению).</param>
/// <param name="content">Поток содержимого файла (читается хранилищем с позиции 0).</param>
/// <param name="size">Длина содержимого в байтах (пишется в метаданные записи).</param>
/// <returns>Метаданные добавленного файла или null — карточки нет (404).</returns>
public async Task<CardFileDto?> AddFileAsync(
string cardId,
string fileName,
string? contentType,
Stream content,
long size,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(content);
CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null)
{
return null;
}
string name = string.IsNullOrWhiteSpace(fileName) ? DefaultAttachmentName : fileName;
CardFileKind kind = FileKindDetector.Detect(name, contentType);
string fileId = PrefixId.New(KanbanIdPrefixes.File);
string objectKey = BuildObjectKey(cardId, fileId, name);
await _storage.PutAsync(objectKey, content, contentType ?? string.Empty, ct);
var entry = new CardFileDto(
fileId,
name,
size,
kind.Kind,
kind.Label,
objectKey);
bool updated = await _store.AddFileAsync(cardId, entry, ct);
if (!updated)
{
// Карточка исчезла между чтением и записью (гонка): объект-сирота в хранилище не нужен —
// удаляем и отвечаем 404-семантикой (DeleteAsync сбои не бросает).
await _storage.DeleteAsync(objectKey, ct);
return null;
}
return entry;
}
/// <summary>
/// Запись файла по id для download-эндпоинта.
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="fileId">Id записи файла (<c>pf_...</c>).</param>
/// <returns>Метаданные записи файла либо null (карточка/запись не найдены).</returns>
public async Task<CardFileDto?> GetFileEntryAsync(
string cardId,
string fileId,
CancellationToken ct)
{
CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null)
{
return null;
}
return card.Files.FirstOrDefault(file => file.Id == fileId);
}
/// <summary>
/// Удаляет файл карточки
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="fileId">Id удаляемой записи файла (<c>pf_...</c>).</param>
/// <returns>Карточка после удаления (без записи) либо null — карточки нет (404-семантика).</returns>
public async Task<CardDto?> RemoveFileAsync(
string cardId,
string fileId,
CancellationToken ct)
{
CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null)
{
return null;
}
CardFileDto? entry = card.Files.FirstOrDefault(file => file.Id == fileId);
if (entry is not null && !string.IsNullOrWhiteSpace(entry.ObjectKey))
{
await _storage.DeleteAsync(entry.ObjectKey, ct);
}
if (!await _store.RemoveFileAsync(cardId, fileId, ct))
{
return null;
}
return await _store.GetCardAsync(cardId, ct)
?? throw new InvalidOperationException("Карточка не прочиталась после удаления файла: " + cardId);
}
private static string BuildObjectKey(
string cardId,
string fileId,
string name)
{
return $"{ObjectRootSegment}/{cardId}/{fileId}_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}_{SanitizeKeyName(name)}";
}
// Санитизирует имя файла для objectKey: path-разделители и кавычки заменяются «_».
// name: Имя файла как прислано.
// Возвращает: Безопасная часть имени для ключа.
private static string SanitizeKeyName(string name)
{
foreach (char character in KeyNameUnsafeCharacters)
{
name = name.Replace(character, KeyNameReplacement);
}
return name;
}
}
@@ -0,0 +1,88 @@
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Services;
// Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён — внутри пространства имён
// Deal.Modules.Kanban.Application имя ColumnRules резолвится в пространство (CS0234), нужен явный алиас.
using KanbanColumnRules = Deal.Modules.Kanban.Application.ColumnRules.ColumnRules;
namespace Deal.Modules.Kanban.Application.Services;
/// <summary>
/// Приватные помощники <see cref="CardsService"/> — partial-часть
/// </summary>
public sealed partial class CardsService
{
// ── Внутреннее ─────────────────────────────────────────────────────────
private static string LearningText(CardDto card)
{
string source = (card.Content.Text ?? string.Empty).Trim();
return source.Length > 0 ? source : card.Title;
}
private async Task MoveToColumnAsync(
CardDto card,
string toCol,
IReadOnlyList<MatchHitDto> hits,
string action,
CancellationToken ct)
{
await _store.UpdateColumnAsync(new CardColumnUpdateDto(
CardId: card.Id,
Col: toCol,
IsNew: false,
PrevCol: card.Col,
ArchivedAt: null,
MatchHits: hits), ct);
await LogMoveAsync(card.Id, action, card.Col, toCol, ct);
}
private async Task LogMoveAsync(
string cardId,
string action,
string? fromCol,
string? toCol,
CancellationToken ct)
{
await _store.AddMoveAsync(new CardMoveDto(
PrefixId.New(KanbanIdPrefixes.CardMove),
cardId,
action,
fromCol,
toCol), ct);
}
private async Task<IReadOnlyList<MatchHitDto>> ComputeHitsForBoardAsync(
string boardId,
string text,
CancellationToken ct)
{
ContainerDto? board = await _store.GetContainerAsync(boardId, ct);
return board is null
? Array.Empty<MatchHitDto>()
: await ComputeHitsAsync(board.Rules, text, ct);
}
private async Task<IReadOnlyList<MatchHitDto>> ComputeHitsAsync(
ContainerRulesDto? rules,
string text,
CancellationToken ct)
{
// Кэш курсов из типизированного снимка настроек (C30): null → мок-курсы (дефолт RatesService).
IReadOnlyDictionary<string, double> rates =
(await TenantSettingsSnapshot.LoadAsync(_settings, ct)).TryGetRatesCache()?.Rates ?? MockRates.Values;
return KanbanColumnRules.ComputeHits(rules, text, rates);
}
private async Task<string> ResolveReturnColAsync(string prevCol, CancellationToken ct)
{
if (prevCol == CardIds.Inbox)
{
return prevCol;
}
ContainerDto? board = await _store.GetContainerAsync(prevCol, ct);
return board is null ? CardIds.Inbox : prevCol;
}
}
@@ -0,0 +1,278 @@
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Kanban.Application.Services;
/// <summary>
/// Публичные операции карточек — partial-часть <see cref="CardsService"/>
/// </summary>
public sealed partial class CardsService
{
/// <summary>
/// Карточки колонки или всех колонок дашборда, received_at DESC.
/// </summary>
/// <param name="col">Колонка-фильтр (inbox/archive/trash/доска); null — все колонки дашборда.</param>
/// <returns>Полные карточки (маппинг/комментарии/time — адаптер); пусто — карточек нет.</returns>
public Task<IReadOnlyList<CardDto>> ListCardsAsync(string? col, CancellationToken ct)
{
return _store.ListCardsAsync(new CardsQuery(col), ct);
}
/// <summary>
/// Одна карточка по id.
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <returns>Карточка или null — строки нет (эндпоинт отвечает 404 «Карточка не найдена»).</returns>
public Task<CardDto?> GetCardAsync(string cardId, CancellationToken ct)
{
return _store.GetCardAsync(cardId, ct);
}
/// <summary>
/// Перенос карточки на доску или в «Неразобранное».
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="toCol">Цель: <c>inbox</c> либо id доски (<c>b_...</c>).</param>
/// <returns>Результат: Error (400) | Card=null (карточки нет, 404) | Card — карточка после переноса.</returns>
public async Task<CardResultDto> MoveDashboardCardAsync(
string cardId,
string toCol,
CancellationToken ct)
{
ContainerDto? board = null;
if (toCol != CardIds.Inbox)
{
board = await _store.GetContainerAsync(toCol, ct);
if (board is null)
{
return new CardResultDto(MoveTargetInvalidDetail, null);
}
}
CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null)
{
return new CardResultDto(null, null);
}
if (card.Col == CardIds.Archive || card.Col == CardIds.Trash)
{
return new CardResultDto(MoveSourceRestrictedDetail, null);
}
if (card.Col == toCol)
{
return new CardResultDto(null, card);
}
string text = LearningText(card);
IReadOnlyList<MatchHitDto> hits = toCol == CardIds.Inbox
? Array.Empty<MatchHitDto>()
: await ComputeHitsAsync(board!.Rules, text, ct);
await MoveToColumnAsync(card, toCol, hits, ActionMove, ct);
if (toCol != CardIds.Inbox && text.Length > 0)
{
await _mlClient.PushAsync(text, toCol, PushWeightUser, ct);
}
return new CardResultDto(null, await _store.GetCardAsync(cardId, ct));
}
/// <summary>
/// Перенос карточки в корзину
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <returns>Карточка после переноса (при no-op — как была) либо null — карточки нет (404).</returns>
public Task<CardDto?> TrashCardAsync(string cardId, CancellationToken ct)
{
return TrashCardAsync(cardId, teach: true, ct);
}
/// <summary>
/// Перенос карточки в корзину с управлением обучением ML.
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="teach">True — писать сигнал «спам» (действие пользователя); false — не писать.</param>
/// <returns>Карточка после переноса (при no-op — как была) либо null — карточки нет (404).</returns>
public async Task<CardDto?> TrashCardAsync(
string cardId,
bool teach,
CancellationToken ct)
{
CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null)
{
return null;
}
if (card.Col == CardIds.Trash)
{
return card;
}
string text = LearningText(card);
await MoveToColumnAsync(card, CardIds.Trash, Array.Empty<MatchHitDto>(), ActionTrash, ct);
if (teach && card.Col != CardIds.Archive && text.Length > 0)
{
await _mlClient.PushAsync(text, MlLearningLabels.Spam, PushWeightUser, ct);
}
return await _store.GetCardAsync(cardId, ct);
}
/// <summary>
/// Возврат карточки из архива/корзины на канбан.
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <returns>Колонка возврата (inbox/доска) либо null — карточки нет (404).</returns>
public async Task<string?> RestoreCardAsync(string cardId, CancellationToken ct)
{
CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null)
{
return null;
}
string back = await ResolveReturnColAsync(card.PrevCol, ct);
string text = LearningText(card);
IReadOnlyList<MatchHitDto> hits = back == CardIds.Inbox
? Array.Empty<MatchHitDto>()
: await ComputeHitsForBoardAsync(back, text, ct);
await _store.UpdateColumnAsync(new CardColumnUpdateDto(
CardId: cardId,
Col: back,
IsNew: true,
PrevCol: CardIds.Inbox,
ArchivedAt: null,
MatchHits: hits), ct);
await LogMoveAsync(cardId, ActionRestore, card.Col, back, ct);
if (card.Col == CardIds.Trash && text.Length > 0)
{
await _mlClient.PushAsync(text, MlLearningLabels.Spam, PushWeightUnlearn, ct);
}
return back;
}
/// <summary>
/// Полное удаление карточки
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <returns>False — карточки нет (404 «Карточка не найдена»); True — удалена.</returns>
public async Task<bool> DeleteForeverAsync(string cardId, CancellationToken ct)
{
if (await _store.GetCardAsync(cardId, ct) is null)
{
return false;
}
await _store.DeleteForeverAsync(cardId, ct);
return true;
}
/// <summary>
/// Полная ручная очистка служебной колонки trash/archive.
/// </summary>
/// <param name="col">Очищаемая колонка: <c>trash</c> | <c>archive</c>.</param>
/// <returns>Результат: Error (400) либо Cleared — сколько карточек удалено навсегда.</returns>
public async Task<ClearColResultDto> ClearColAsync(string col, CancellationToken ct)
{
if (col != CardIds.Trash && col != CardIds.Archive)
{
return new ClearColResultDto(ClearColInvalidDetail, 0);
}
int cleared = await _store.ClearColAsync(col, ct);
return new ClearColResultDto(null, cleared);
}
/// <summary>
/// Добавляет комментарий к карточке
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="text">Текст комментария (непустой после Trim).</param>
/// <returns>Результат: Error (400) | Comments=null (404) | Comments — список после добавления.</returns>
public async Task<AddCommentResultDto> AddCommentAsync(
string cardId,
string text,
CancellationToken ct)
{
string trimmed = (text ?? string.Empty).Trim();
if (trimmed.Length == 0)
{
return new AddCommentResultDto(EmptyCommentDetail, null);
}
if (await _store.GetCardAsync(cardId, ct) is null)
{
return new AddCommentResultDto(null, null);
}
await _store.AddCommentAsync(PrefixId.New(KanbanIdPrefixes.Comment), cardId, CommentAuthor, trimmed, ct);
await LogMoveAsync(cardId, ActionComment, null, null, ct);
IReadOnlyList<CardCommentDto> comments = await _store.ListCommentsAsync(cardId, ct);
return new AddCommentResultDto(null, comments);
}
/// <summary>
/// Снимает флаг «новое»
/// </summary>
/// <param name="cardId">Id карточки либо null/пусто.</param>
/// <param name="col">Колонка либо null/пусто (используется, когда cardId не задан).</param>
public Task MarkSeenAsync(
string? cardId,
string? col,
CancellationToken ct)
{
return _store.UpdateSeenAsync(
string.IsNullOrEmpty(cardId) ? null : cardId,
string.IsNullOrEmpty(col) ? null : col,
ct);
}
/// <summary>
/// Счётчики колонок
/// </summary>
/// <returns>Счётчики: колонки + learning/ml/ai (поля New/Learning/Ml/Ai и словарь Columns).</returns>
public async Task<CardCountsDto> CountsAsync(CancellationToken ct)
{
IReadOnlyDictionary<string, CardColumnCountDto> columns = await _store.CountCardsByColAsync(ct);
MlStatusResponseDto mlStatus = await _mlClient.StatusAsync(ct);
return new CardCountsDto
{
New = columns.Values.Sum(column => column.New),
Columns = columns,
Learning = mlStatus.Stats.Learning,
Ml = mlStatus.Stats.Ml,
Ai = mlStatus.Stats.Ai,
};
}
/// <summary>
/// Поиск карточек: FTS по Cards.SearchTsv + LIKE-дополнение.
/// </summary>
/// <param name="query">Поисковый запрос (trim + lowercase внутри).</param>
/// <returns>Найденные карточки (≤12); пусто — запрос короче 2 символов или нет совпадений.</returns>
public async Task<IReadOnlyList<CardDto>> SearchCardsAsync(string? query, CancellationToken ct)
{
string lowered = (query ?? string.Empty).Trim().ToLowerInvariant();
if (lowered.Length < MinSearchQueryLength)
{
return Array.Empty<CardDto>();
}
return await _store.SearchCardsAsync(lowered, SearchLimit, ct);
}
}
@@ -0,0 +1,110 @@
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application.Models;
namespace Deal.Modules.Kanban.Application.Services;
/// <summary>
/// Напоминания «Отложено» — partial-часть <see cref="CardsService"/>
/// </summary>
public sealed partial class CardsService
{
/// <summary>
/// 400 set: напоминания выключены в настройках.
/// </summary>
public const string RemindersDisabledDetail = "Напоминания об отложенных выключены в настройках";
private const string RemindersEnabledKey = SettingsKeys.RemindersEnabled;
private const long ReminderSnoozeMs = 86_400_000;
/// <summary>
/// Устанавливает напоминание карточке.
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="atMs">Время напоминания, epoch-ms.</param>
/// <returns>Результат: Error <see cref="RemindersDisabledDetail"/> (400) | Card=null без Error (404) | Card — карточка с напоминанием.</returns>
public async Task<CardResultDto> SetReminderAsync(
string cardId,
long atMs,
CancellationToken ct)
{
CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null)
{
return new CardResultDto(null, null);
}
if (!await ReadRemindersEnabledAsync(ct))
{
return new CardResultDto(RemindersDisabledDetail, null);
}
await _store.SetReminderAsync(cardId, atMs, ct);
CardDto saved = await _store.GetCardAsync(cardId, ct)
?? throw new InvalidOperationException("Карточка не прочиталась после установки напоминания: " + cardId);
return new CardResultDto(null, saved);
}
/// <summary>
/// Снимает напоминание карточки.
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <returns>True — карточка есть и напоминание снято; false — карточки нет (404).</returns>
public async Task<bool> ClearReminderAsync(string cardId, CancellationToken ct)
{
CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null)
{
return false;
}
await _store.ClearReminderAsync(cardId, ct);
return true;
}
/// <summary>
/// «Напомнить позже»
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <returns>True — карточка есть и напоминание отложено; false — карточки нет (404).</returns>
public async Task<bool> SnoozeReminderAsync(string cardId, CancellationToken ct)
{
CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null)
{
return false;
}
long snoozedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + ReminderSnoozeMs;
await _store.SetReminderAsync(cardId, snoozedAtMs, ct);
return true;
}
/// <summary>
/// Проверка наступивших напоминаний «Отложено».
/// </summary>
/// <returns>«Выстрелившие» напоминания (после пометки fired); пусто — сработавших нет/напоминания выключены.</returns>
public async Task<IReadOnlyList<CardReminderDueDto>> CheckDueRemindersAsync(CancellationToken ct)
{
if (!await ReadRemindersEnabledAsync(ct))
{
await _store.ClearExpiredRemindersAsync(DateTimeOffset.UtcNow, ct);
return Array.Empty<CardReminderDueDto>();
}
DateTimeOffset now = DateTimeOffset.UtcNow;
IReadOnlyList<CardReminderDueDto> due = await _store.ListDueRemindersAsync(now, ct);
if (due.Count > 0)
{
await _store.MarkRemindersFiredAsync(due.Select(item => item.Id).ToList(), ct);
}
return due;
}
private async Task<bool> ReadRemindersEnabledAsync(CancellationToken ct)
{
return (await TenantSettingsSnapshot.LoadAsync(_settings, ct))
.GetBool(RemindersEnabledKey, SettingsDefaults.RemindersEnabled);
}
}
@@ -0,0 +1,382 @@
using System.Text.Json;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Kanban.Application.Services;
/// <summary>
/// Операции пространства «Выбранные» — partial-часть <see cref="CardsService"/>
/// </summary>
public sealed partial class CardsService
{
/// <summary>
/// 400 перенос по стадии
/// </summary>
public const string UnknownStageDetail = "Неизвестная стадия";
private const string PlannedStage = CardsDefaultContainers.Planned;
private const string RejectedStage = CardsDefaultContainers.Rejected;
private const string TakenCommentText = "Взял в работу.";
/// <summary>
/// 400 ссылка: пустой url после Trim.
/// </summary>
public const string EmptyLinkDetail = "Пустая ссылка";
private const string HttpsUrlScheme = "https://";
private const string HttpUrlScheme = "http://";
// Ключ тела PATCH: заголовок (JSON-строка).
private const string PatchKeyTitle = "title";
// Ключ тела PATCH: краткое содержание (JSON-строка).
private const string PatchKeySummary = "summary";
// Ключ тела PATCH: контактная строка (JSON-строка).
private const string PatchKeyContact = "contact";
// Ключ тела PATCH: текст технического задания (JSON-строка).
private const string PatchKeyTzText = "tzText";
// Ключ тела PATCH: стек (JSON-массив строк либо null — очистка).
private const string PatchKeyStack = "stack";
// Ключ тела PATCH: бюджет (JSON-объект {from,to,cur} либо null/не-объект — очистка).
private const string PatchKeyBudget = "budget";
private static readonly CardBudgetDto ClearedBudget = new(From: null, To: null, Cur: string.Empty);
/// <summary>
/// Карточки пространства «Выбранные»
/// </summary>
/// <param name="containerId">Фильтр по контейнеру-стадии; null — все стадии «Выбранных».</param>
/// <returns>Полные карточки в порядке UpdatedAt DESC; пусто — карточек нет.</returns>
public Task<IReadOnlyList<CardDto>> ListSelectedCardsAsync(string? containerId, CancellationToken ct)
{
return _store.ListSelectedCardsAsync(containerId, ct);
}
/// <summary>
/// Ручное создание «локальной» карточки без внешнего источника.
/// </summary>
/// <param name="draft">Начальные поля карточки (тело POST /api/cards, см. <see cref="CardLocalCreateDto"/>).</param>
/// <returns>Созданная карточка (полное чтение после записи).</returns>
public async Task<CardDto> CreateLocalCardAsync(CardLocalCreateDto draft, CancellationToken ct)
{
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
DateTimeOffset receivedAt = DateTimeOffset.FromUnixTimeMilliseconds(nowMs);
string containerId = draft.ContainerId is not null && CardsDefaultContainers.Contains(draft.ContainerId)
? draft.ContainerId
: PlannedStage;
var snapshot = new CardSnapshot
{
Id = PrefixId.New(KanbanIdPrefixes.Card),
Col = containerId,
IsNew = false,
Local = true,
Title = draft.Title.Trim(),
Summary = draft.Summary,
Source = new SourceRef { Kind = SourceKinds.Local, ReceivedAt = receivedAt },
Content = new SourceContent { Text = draft.Summary },
ReceivedAt = receivedAt,
Stack = draft.Stack ?? Array.Empty<string>(),
BudgetFrom = draft.Budget?.From,
BudgetTo = draft.Budget?.To,
BudgetCur = draft.Budget?.Cur ?? string.Empty,
Contact = draft.Contact,
TzText = draft.TzText,
History = new[] { new CardHistoryDto(PrefixId.New(KanbanIdPrefixes.History), nowMs, "createdLocal", null) },
};
await _store.AddCardAsync(snapshot, ct);
return await _store.GetCardAsync(snapshot.Id, ct)
?? throw new InvalidOperationException("Карточка не прочиталась после создания: " + snapshot.Id);
}
/// <summary>
/// «Взять в работу»
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <returns>Карточка в стадии planned; null — карточки нет (404).</returns>
public async Task<CardDto?> TakeCardAsync(string cardId, CancellationToken ct)
{
CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null)
{
return null;
}
if (CardsDefaultContainers.Contains(card.Col))
{
return card;
}
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var entry = new CardHistoryDto(PrefixId.New(KanbanIdPrefixes.History), nowMs, null, PlannedStage);
if (!await _store.MoveCardStageAsync(cardId, PlannedStage, entry, nowMs, ct))
{
// Карточка исчезла между чтением и переносом (гонка с удалением).
return null;
}
await _store.AddCommentAsync(
PrefixId.New(KanbanIdPrefixes.Comment), cardId, CommentAuthor, TakenCommentText, ct);
return await _store.GetCardAsync(cardId, ct)
?? throw new InvalidOperationException("Карточка не прочиталась после take: " + cardId);
}
/// <summary>
/// Точечная правка полей карточки по телу PATCH — presence-aware.
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="body">Тело PATCH: ключ → JSON-значение (наличие ключа = поле меняется).</param>
/// <returns>Обновлённая карточка или null — карточки нет (404).</returns>
public async Task<CardDto?> PatchCardAsync(
string cardId,
IReadOnlyDictionary<string, JsonElement> body,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(body);
bool updated = await _store.PatchCardAsync(cardId, ResolvePatch(body), ct);
return updated ? await _store.GetCardAsync(cardId, ct) : null;
}
/// <summary>
/// Добавляет ссылку карточке
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="name">Название ссылки; пустое после Trim → name = url.</param>
/// <param name="url">URL ссылки (без схемы — добавится https://).</param>
/// <returns>Результат: Error (400 «Пустая ссылка») | Card=null без Error (404) | Card — карточка со ссылкой.</returns>
public async Task<CardResultDto> AddLinkAsync(
string cardId,
string name,
string url,
CancellationToken ct)
{
CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null)
{
return new CardResultDto(null, null);
}
string normalizedUrl = (url ?? string.Empty).Trim();
if (normalizedUrl.Length == 0)
{
return new CardResultDto(EmptyLinkDetail, null);
}
if (!normalizedUrl.StartsWith(HttpsUrlScheme, StringComparison.Ordinal)
&& !normalizedUrl.StartsWith(HttpUrlScheme, StringComparison.Ordinal))
{
normalizedUrl = HttpsUrlScheme + normalizedUrl;
}
string trimmedName = (name ?? string.Empty).Trim();
var link = new CardLinkDto(
PrefixId.New(KanbanIdPrefixes.Link),
trimmedName.Length == 0 ? normalizedUrl : trimmedName,
normalizedUrl);
if (!await _store.AddLinkAsync(cardId, link, ct))
{
return new CardResultDto(null, null);
}
CardDto saved = await _store.GetCardAsync(cardId, ct)
?? throw new InvalidOperationException("Карточка не прочиталась после добавления ссылки: " + cardId);
return new CardResultDto(null, saved);
}
/// <summary>
/// Удаляет ссылку карточки по id
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="linkId">Id удаляемой ссылки (<c>pl_...</c>).</param>
/// <returns>Результат: Card=null без Error (404) | Card — карточка без ссылки.</returns>
public async Task<CardResultDto> RemoveLinkAsync(
string cardId,
string linkId,
CancellationToken ct)
{
if (!await _store.RemoveLinkAsync(cardId, linkId, ct))
{
return new CardResultDto(null, null);
}
CardDto saved = await _store.GetCardAsync(cardId, ct)
?? throw new InvalidOperationException("Карточка не прочиталась после удаления ссылки: " + cardId);
return new CardResultDto(null, saved);
}
/// <summary>
/// Перенос карточки по контейнерам-стадиям «Выбранных»
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="containerId">Новый контейнер-стадия — id каталога <see cref="CardsDefaultContainers"/>.</param>
/// <returns>Результат: Error «Неизвестная стадия» (400) | Card=null без Error (карточки нет, 404) | Card — карточка после переноса.</returns>
public async Task<CardResultDto> MoveStageCardAsync(
string cardId,
string containerId,
CancellationToken ct)
{
if (!CardsDefaultContainers.Contains(containerId))
{
return new CardResultDto(UnknownStageDetail, null);
}
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var entry = new CardHistoryDto(PrefixId.New(KanbanIdPrefixes.History), nowMs, null, containerId);
bool moved = await _store.MoveCardStageAsync(cardId, containerId, entry, nowMs, ct);
if (!moved)
{
return new CardResultDto(null, null);
}
CardDto card = await _store.GetCardAsync(cardId, ct)
?? throw new InvalidOperationException("Карточка не прочиталась после move: " + cardId);
return new CardResultDto(null, card);
}
/// <summary>
/// Полная ручная очистка терминальной стадии «Отклонено» — hard-delete строк
/// </summary>
/// <returns>Сколько карточек удалено (0 — стадия пуста).</returns>
public Task<int> ClearRejectedAsync(CancellationToken ct)
{
return _store.ClearStageAsync(RejectedStage, ct);
}
private static CardPatch ResolvePatch(IReadOnlyDictionary<string, JsonElement> body)
{
string? title = null;
string? summary = null;
string? contact = null;
string? tzText = null;
IReadOnlyList<string>? stack = null;
CardBudgetDto? budget = null;
foreach ((string key, JsonElement value) in body)
{
switch (key)
{
case PatchKeyTitle:
if (ReadTextValue(value, out string parsedTitle))
{
title = parsedTitle;
}
break;
case PatchKeySummary:
if (ReadTextValue(value, out string parsedSummary))
{
summary = parsedSummary;
}
break;
case PatchKeyContact:
if (ReadTextValue(value, out string parsedContact))
{
contact = parsedContact;
}
break;
case PatchKeyTzText:
if (ReadTextValue(value, out string parsedTzText))
{
tzText = parsedTzText;
}
break;
case PatchKeyStack:
stack = ReadStack(value);
break;
case PatchKeyBudget:
budget = ReadBudget(value);
break;
}
}
return new CardPatch(title, summary, contact, tzText, stack, budget, null, null, null);
}
// Читает JSON-строку текстового поля; null/не-строка → ключ не применяется.
// element: Значение ключа тела.
// text: Прочитанная строка (при успехе).
// Возвращает: True — значение строковое (в т.ч. пустое — очистка текста).
private static bool ReadTextValue(JsonElement element, out string text)
{
if (element.ValueKind == JsonValueKind.String)
{
text = element.GetString() ?? string.Empty;
return true;
}
text = string.Empty;
return false;
}
// Читает стек: массив строк (не-строки отбрасываются); null/не-массив → пустой стек.
// element: Значение ключа stack.
// Возвращает: Новый стек (полная замена массива).
private static IReadOnlyList<string> ReadStack(JsonElement element)
{
if (element.ValueKind != JsonValueKind.Array)
{
return Array.Empty<string>();
}
var items = new List<string>();
foreach (JsonElement item in element.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.String)
{
items.Add(item.GetString() ?? string.Empty);
}
}
return items;
}
// Читает бюджет: объект {from,to,cur} → пара границ + валюта; null/не-объект → очистка.
// element: Значение ключа budget.
// Возвращает: Бюджет патча: валюта пуста — «бюджета нет» (очистка).
private static CardBudgetDto ReadBudget(JsonElement element)
{
if (element.ValueKind != JsonValueKind.Object)
{
return ClearedBudget;
}
double? from = ReadNumber(element, "from");
double? to = ReadNumber(element, "to");
string cur = ReadText(element, "cur");
return new CardBudgetDto(from, to, cur);
}
// Читает число поля объекта бюджета: число → значение; иное — пусто.
// obj: Объект бюджета.
// fieldName: Имя поля (from/to).
// Возвращает: Граница либо null.
private static double? ReadNumber(JsonElement obj, string fieldName)
{
return obj.TryGetProperty(fieldName, out JsonElement element) && element.ValueKind == JsonValueKind.Number
? element.GetDouble()
: null;
}
// Читает строку поля объекта бюджета: строка → значение; отсутствие/иное → пусто.
// obj: Объект бюджета.
// fieldName: Имя поля (cur).
// Возвращает: Код валюты либо пустая строка.
private static string ReadText(JsonElement obj, string fieldName)
{
return obj.TryGetProperty(fieldName, out JsonElement element) && element.ValueKind == JsonValueKind.String
? element.GetString() ?? string.Empty
: string.Empty;
}
}
@@ -0,0 +1,29 @@
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Kanban.Application.Services;
/// <summary>
/// Содержимое источника карточки — partial-часть <see cref="CardsService"/>
/// </summary>
public sealed partial class CardsService
{
/// <summary>
/// Загружает содержимое источника карточки.
/// </summary>
/// <param name="card">Карточка, для которой нужно содержимое.</param>
/// <returns>Содержимое от провайдера источника либо сохранённое в карточке.</returns>
public async Task<SourceContent> ResolveSourceAsync(CardDto card, CancellationToken ct)
{
if (_sourceResolver is not null && _sourceResolver.CanResolve(card.Source))
{
SourceContent? loaded = await _sourceResolver.ResolveAsync(card.Source, ct);
if (loaded is not null)
{
return loaded;
}
}
return card.Content;
}
}
@@ -0,0 +1,94 @@
using Deal.Contracts.Integrations.Abstractions;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Settings.Application.Abstractions;
// Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён — внутри пространства имён
// Deal.Modules.Kanban.Application имя ColumnRules резолвится в пространство (CS0234), нужен явный алиас.
namespace Deal.Modules.Kanban.Application.Services;
/// <summary>
/// Сервис карточек — единый домен карточки
/// </summary>
/// <param name="store">Единый порт хранилища карточек/контейнеров/журнала тенанта.</param>
/// <param name="settings">KV-хранилище настроек тенанта (курсы, напоминания).</param>
/// <param name="mlClient">Клиент ML: PushAsync — обучающий сигнал действия, StatusAsync — счётчики counts.</param>
/// <param name="storage">Файловое хранилище вложений карточки (объекты файлов).</param>
public sealed partial class CardsService
{
private readonly ICardStore _store;
private readonly ISettingsStore _settings;
private readonly IMlClient _mlClient;
private readonly IFileStorage _storage;
private readonly SourceContentResolver? _sourceResolver;
/// <summary>
/// Создаёт сервис карточек над портами модуля
/// </summary>
/// <param name="store">Единый порт хранилища карточек/контейнеров/журнала тенанта.</param>
/// <param name="settings">KV-хранилище настроек тенанта (курсы, напоминания).</param>
/// <param name="mlClient">Клиент ML: PushAsync — обучающий сигнал действия, StatusAsync — счётчики counts.</param>
/// <param name="storage">Файловое хранилище вложений карточки (объекты файлов).</param>
/// <param name="sourceResolver">Реестр провайдеров содержимого источника; null — только содержимое карточки.</param>
public CardsService(
ICardStore store,
ISettingsStore settings,
IMlClient mlClient,
IFileStorage storage,
SourceContentResolver? sourceResolver = null)
{
_store = store;
_settings = settings;
_mlClient = mlClient;
_storage = storage;
_sourceResolver = sourceResolver;
}
/// <summary>
/// 400 move: целевой контейнер не существует
/// </summary>
public const string MoveTargetInvalidDetail = "Переносить можно только в существующий контейнер или в «Неразобранное»";
/// <summary>
/// 400 move: исходная колонка archive/trash — из них карточку выводит только restore_lead
/// </summary>
public const string MoveSourceRestrictedDetail = "Переносить из корзины, архива или «взятых в работу» нельзя — верните карточку на канбан";
/// <summary>
/// 400 clear-col: колонка не trash/archive.
/// </summary>
public const string ClearColInvalidDetail = "Очищать можно только корзину или архив";
/// <summary>
/// 400 комментарий
/// </summary>
public const string EmptyCommentDetail = "Пустой комментарий";
private const string ActionMove = "move";
private const string ActionTrash = "trash";
private const string ActionRestore = "restore";
private const string ActionComment = "comment";
/// <summary>
/// Автор комментария — «Вы».
/// </summary>
public const string CommentAuthor = "Вы";
/// <summary>
/// Human-метка времени свежего комментария.
/// </summary>
public const string JustNowLabel = "только что";
private const int MinSearchQueryLength = 2;
private const int SearchLimit = 12;
private const double PushWeightUser = 1.0;
private const double PushWeightUnlearn = -1.0;
}
@@ -0,0 +1,279 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
namespace Deal.Modules.Kanban.Application.Services;
/// <summary>
/// Сервис контейнеров
/// </summary>
/// <param name="store">Порт хранилища (контейнеры/карточки тенанта).</param>
/// <param name="settings">KV-хранилище настроек тенанта (ключ colState).</param>
public sealed class ContainersService(ICardStore store, ISettingsStore settings)
{
/// <summary>
/// Имя по умолчанию
/// </summary>
public const string DefaultContainerName = "Новая колонка";
// Палитра колонок по умолчанию: цвет = Palette[order % 8], если цвет не задан.
private static readonly string[] Palette =
["#818cf8", "#fbbf24", "#22d3ee", "#e879f9", "#34d399", "#fb7185", "#a78bfa", "#f97316"];
// Опции JSON colState: camelCase при записи, терпимость регистра при чтении; null-поля не пишутся.
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
// ── Контейнеры ───────────────────────────────────────────────────────
/// <summary>
/// Контейнеры пространства в порядке показа, со счётчиками карточек.
/// </summary>
/// <param name="space">Пространство (dashboard/selected) либо null — все контейнеры.</param>
/// <returns>Контейнеры с заполненными counts; пусто — контейнеров нет.</returns>
public async Task<IReadOnlyList<ContainerDto>> ListAsync(string? space, CancellationToken ct)
{
IReadOnlyList<ContainerDto> containers = await store.ListContainersAsync(space, ct);
IReadOnlyDictionary<string, CardColumnCountDto> counts = await store.CountCardsByColAsync(ct);
return containers
.Select(container => container with
{
Counts = counts.TryGetValue(container.Id, out CardColumnCountDto? count)
? new ContainerCountsDto(count.Count, count.New)
: new ContainerCountsDto(0, 0),
})
.ToList();
}
/// <summary>
/// Один контейнер со счётчиками; null — контейнера нет.
/// </summary>
/// <param name="containerId">Id контейнера.</param>
/// <returns>Контейнер со счётчиками либо null.</returns>
public async Task<ContainerDto?> GetAsync(string containerId, CancellationToken ct)
{
ContainerDto? container = await store.GetContainerAsync(containerId, ct);
if (container is null)
{
return null;
}
IReadOnlyDictionary<string, CardColumnCountDto> counts = await store.CountCardsByColAsync(ct);
ContainerCountsDto containerCounts = counts.TryGetValue(container.Id, out CardColumnCountDto? count)
? new ContainerCountsDto(count.Count, count.New)
: new ContainerCountsDto(0, 0);
return container with { Counts = containerCounts };
}
/// <summary>
/// Создаёт контейнер с дефолтами
/// </summary>
/// <param name="create">Вход создания (имя обязательно; цвет/правила/пространство/вид — опциональны).</param>
/// <returns>Созданный контейнер (id <c>b_</c> + 12 hex).</returns>
public async Task<ContainerDto> CreateAsync(ContainerCreateDto create, CancellationToken ct)
{
string space = string.IsNullOrWhiteSpace(create.Space) ? ContainerSpaces.Dashboard : create.Space;
IReadOnlyList<ContainerDto> existing = await store.ListContainersAsync(space, ct);
int order = existing.Count == 0 ? 0 : existing.Max(container => container.Order) + 1;
var container = new ContainerDto
{
Id = PrefixId.New(KanbanIdPrefixes.Board),
Name = BuildName(create.Name),
Description = create.Description.Trim(),
Color = string.IsNullOrEmpty(create.Color) ? Palette[order % Palette.Length] : create.Color,
Order = order,
Space = space,
Kind = string.IsNullOrWhiteSpace(create.Kind) ? ContainerKinds.Board : create.Kind,
Suggested = create.Suggested,
Note = create.Note ?? string.Empty,
Rules = NormalizeRules(create.Rules),
Policy = new ContainerPolicyDto(),
};
await store.CreateContainerAsync(container, ct);
return container;
}
/// <summary>
/// Частичное обновление контейнера
/// </summary>
/// <param name="containerId">Id контейнера.</param>
/// <param name="patch">Изменения; null-поле означает «не менять».</param>
/// <returns>Контейнер после патча; null — контейнера нет (404 «Контейнер не найден»).</returns>
public async Task<ContainerDto?> PatchAsync(
string containerId,
ContainerPatchDto patch,
CancellationToken ct)
{
ContainerDto? current = await store.GetContainerAsync(containerId, ct);
if (current is null)
{
return null;
}
ContainerDto updated = ApplyPatch(current, patch);
await store.UpdateContainerAsync(updated, ct);
return updated;
}
/// <summary>
/// Принимает ИИ-предложение
/// </summary>
/// <param name="containerId">Id контейнера-предложения.</param>
/// <returns>Контейнер после принятия; null — контейнера нет (404).</returns>
public Task<ContainerDto?> AcceptSuggestedAsync(string containerId, CancellationToken ct)
{
return PatchAsync(containerId, new ContainerPatchDto(
Name: null,
Description: null,
Color: null,
Collapsed: null,
Suggested: false,
Note: null,
Rules: null,
Policy: null), ct);
}
/// <summary>
/// Удаляет контейнер, перенося его карточки в «Неразобранное» новыми.
/// </summary>
/// <param name="containerId">Id удаляемого контейнера.</param>
/// <returns>Сколько карточек перенесено в inbox (ответ {ok, movedToInbox}).</returns>
public Task<int> DeleteAsync(string containerId, CancellationToken ct)
{
return store.DeleteContainerAsync(containerId, ct);
}
/// <summary>
/// Переставляет контейнеры пространства
/// </summary>
/// <param name="space">Пространство переставляемых контейнеров.</param>
/// <param name="containerIds">Id контейнеров в новом порядке.</param>
public Task ReorderAsync(
string space,
IReadOnlyList<string> containerIds,
CancellationToken ct)
{
return store.ReorderContainersAsync(space, containerIds, ct);
}
// ── Состояние колонок (colState, KV через ISettingsStore) ────────────
/// <summary>
/// Весь объект colState
/// </summary>
/// <returns>Состояния всех колонок; пусто — настройка не сохранена/повреждена.</returns>
public async Task<IReadOnlyDictionary<string, ColumnStateDto>> GetColStateAsync(CancellationToken ct)
{
return await ReadColStateAsync(ct);
}
/// <summary>
/// PATCH состояния одной колонки
/// </summary>
/// <param name="colId">Id контейнера.</param>
/// <param name="patch">Изменяемые поля состояния.</param>
/// <returns>Состояние колонки после merge.</returns>
public async Task<ColumnStateDto> PatchColStateAsync(
string colId,
ColumnStateDto patch,
CancellationToken ct)
{
Dictionary<string, ColumnStateDto> state = await ReadColStateAsync(ct);
state.TryGetValue(colId, out ColumnStateDto? current);
var merged = new ColumnStateDto(
patch.Collapsed ?? current?.Collapsed,
patch.Width ?? current?.Width);
state[colId] = merged;
await settings.SetAsync(SettingsKeys.ColState, JsonSerializer.Serialize(state, JsonOptions), ct);
return merged;
}
// ── Внутреннее ───────────────────────────────────────────────────────
// Имя контейнера: trim; пустое → «Новая колонка».
// name: Имя из запроса.
// Возвращает: Непустое имя контейнера.
private static string BuildName(string name)
{
string trimmed = name.Trim();
return trimmed.Length == 0 ? DefaultContainerName : trimmed;
}
// Правила: пустые правила (все группы пусты) нормализуются в null — хранилище пишет каноничное {}.
// rules: Правила из запроса.
// Возвращает: Нормализованные правила либо null.
private static ContainerRulesDto? NormalizeRules(ContainerRulesDto? rules)
{
if (rules is null)
{
return null;
}
bool isEmpty = rules.Mode.Length == 0
&& rules.Direction.Count == 0
&& rules.Keywords.Count == 0
&& rules.Stack.Count == 0
&& rules.Grade.Count == 0
&& rules.Exclude.Count == 0
&& (rules.Levels?.Count ?? 0) == 0
&& (rules.Locations?.Count ?? 0) == 0
&& (rules.Types?.Count ?? 0) == 0
&& rules.Prices is null
&& rules.Budget is null;
return isEmpty ? null : rules;
}
// Применяет не-null поля патча к текущему контейнеру.
// container: Текущий контейнер.
// patch: Патч.
// Возвращает: Контейнер с применёнными изменениями.
private static ContainerDto ApplyPatch(ContainerDto container, ContainerPatchDto patch)
{
return container with
{
Name = patch.Name ?? container.Name,
Description = patch.Description ?? container.Description,
Color = patch.Color ?? container.Color,
Collapsed = patch.Collapsed ?? container.Collapsed,
Suggested = patch.Suggested ?? container.Suggested,
Note = patch.Note ?? container.Note,
Rules = patch.Rules is null ? container.Rules : NormalizeRules(patch.Rules),
Policy = patch.Policy ?? container.Policy,
};
}
// Читает весь объект colState из KV; повреждённый JSON трактуется как пустое состояние.
// ct: Токен отмены.
// Возвращает: Словарь контейнер → состояние (Ordinal-ключи).
private async Task<Dictionary<string, ColumnStateDto>> ReadColStateAsync(CancellationToken ct)
{
SettingValue? row = await settings.GetAsync(SettingsKeys.ColState, ct);
if (row is null)
{
return new Dictionary<string, ColumnStateDto>(StringComparer.Ordinal);
}
try
{
Dictionary<string, ColumnStateDto>? state =
JsonSerializer.Deserialize<Dictionary<string, ColumnStateDto>>(row.ValueJson, JsonOptions);
return state ?? new Dictionary<string, ColumnStateDto>(StringComparer.Ordinal);
}
catch (JsonException)
{
// Битое значение не должно ронять GET /state и последующие PATCH (перезапишут при записи).
return new Dictionary<string, ColumnStateDto>(StringComparer.Ordinal);
}
}
}
@@ -0,0 +1,80 @@
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Services;
namespace Deal.Modules.Kanban.Application.Services;
/// <summary>
/// Пересчёт конверсий бюджетов карточек при смене курсов/целевой валюты.
/// </summary>
/// <param name="settings">KV-хранилище настроек тенанта (conversionOn/targetCurrency/ratesCache).</param>
/// <param name="store">Порт хранилища канбана: кандидаты пересчёта и запись conv-полей.</param>
public sealed class ConversionRecomputer(ISettingsStore settings, ICardStore store) : IRatesChangedListener
{
/// <inheritdoc />
public Task OnRatesChangedAsync(bool fullRecompute, CancellationToken ct)
{
// Оба текущих триггера (обновление кэша курсов, PATCH targetCurrency/conversionOn) требуют полного
// пересчёта всех кандидатов; параметр fullRecompute зарезервирован под будущие частичные события
// (пересчёт одной карточки) — см. IRatesChangedListener.
return RecomputeAsync(ct);
}
/// <summary>
/// Полный пересчёт конверсий кандидатов по текущим настройкам и кэшу курсов.
/// </summary>
/// <returns>Сколько карточек обновлено (0 — конверсия выключена / нет кэша курсов / нет кандидатов).</returns>
public async Task<int> RecomputeAsync(CancellationToken ct)
{
// Все настройки/кэш — из одного типизированного снимка (C30): один GetAllAsync на пересчёт.
TenantSettingsSnapshot snapshot = await TenantSettingsSnapshot.LoadAsync(settings, ct);
bool conversionOn = snapshot.GetBool(SettingsKeys.ConversionOn, SettingsDefaults.ConversionOn);
if (!conversionOn)
{
return 0; // конверсия снята — ничего не пересчитываем (rates.py L112113)
}
string targetCurrency = NormalizeTargetCurrency(
snapshot.GetString(SettingsKeys.TargetCurrency, SettingsDefaults.TargetCurrency));
RatesCacheValue? cache = snapshot.TryGetRatesCache();
if (cache is not { Rates.Count: > 0 })
{
return 0; // кэша курсов нет — конвертировать нечем; карточки не трогаем
}
int updated = 0;
IReadOnlyList<CardDto> candidates = await store.ListCardsForConversionAsync(ct);
foreach (CardDto card in candidates)
{
CardBudgetDto? budget = card.Budget;
if (budget is null)
{
continue; // хранилище уже фильтрует budget_cur != '' (null Budget), страховка DTO
}
double? convFrom = RatesService.ConvertAmount(budget.From, budget.Cur, targetCurrency, cache.Rates);
if (convFrom is null)
{
continue; // нет курса валюты / нет нижней границы — строку не трогаем (rates.py L123124)
}
double? convTo =
RatesService.ConvertAmount(budget.To ?? budget.From, budget.Cur, targetCurrency, cache.Rates);
await store.UpdateConversionAsync(card.Id, convFrom, convTo, targetCurrency, ct);
updated++;
}
return updated;
}
// Код целевой валюты для пересчёта: trim + верхний регистр; пусто → дефолт RUB (как писал PATCH).
// value: Значение настройки targetCurrency (JSON-строка).
// Возвращает: Код валюты (RUB/USD/…) либо дефолт.
private static string NormalizeTargetCurrency(string value)
{
string currency = value.Trim().ToUpperInvariant();
return currency.Length > 0 ? currency : SettingsDefaults.TargetCurrency;
}
}
@@ -0,0 +1,113 @@
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Kanban.Application.Services;
/// <summary>
/// Чистый детектор типа вложения карточки.
/// </summary>
public static class FileKindDetector
{
/// <summary>
/// Категория «изображение».
/// </summary>
public const string ImageKind = "image";
/// <summary>
/// Категория «видео»
/// </summary>
public const string VideoKind = "video";
/// <summary>
/// Категория «аудио»
/// </summary>
public const string AudioKind = "audio";
/// <summary>
/// Категория «архив»
/// </summary>
public const string ArchiveKind = "archive";
/// <summary>
/// Категория «документ»
/// </summary>
public const string DocumentKind = "document";
/// <summary>
/// Категория «другое» — неопознанный MIME и расширение
/// </summary>
public const string OtherKind = "other";
private const string ImageLabel = "Изображение";
private const string VideoLabel = "Видео";
private const string AudioLabel = "Аудио";
private const string ArchiveLabel = "Архив";
private const string DocumentLabel = "Документ";
private const string OtherLabel = "Файл";
private static readonly IReadOnlyDictionary<string, IReadOnlySet<string>> ExtensionsByKind = new Dictionary<string, IReadOnlySet<string>>(StringComparer.Ordinal)
{
[ImageKind] = new HashSet<string>(StringComparer.Ordinal) { "png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "avif", "heic" },
[VideoKind] = new HashSet<string>(StringComparer.Ordinal) { "mp4", "mov", "avi", "mkv", "webm", "m4v" },
[AudioKind] = new HashSet<string>(StringComparer.Ordinal) { "mp3", "wav", "ogg", "m4a", "flac", "aac" },
[ArchiveKind] = new HashSet<string>(StringComparer.Ordinal) { "zip", "rar", "7z", "tar", "gz", "bz2" },
[DocumentKind] = new HashSet<string>(StringComparer.Ordinal) { "pdf", "doc", "docx", "xls", "xlsx", "csv", "txt", "md", "rtf", "ppt", "pptx", "odt", "ods" },
};
private static readonly IReadOnlyDictionary<string, string> LabelsByKind = new Dictionary<string, string>(StringComparer.Ordinal)
{
[ImageKind] = ImageLabel,
[VideoKind] = VideoLabel,
[AudioKind] = AudioLabel,
[ArchiveKind] = ArchiveLabel,
[DocumentKind] = DocumentLabel,
[OtherKind] = OtherLabel,
};
/// <summary>
/// Определяет категорию вложения по MIME и расширению имени.
/// </summary>
/// <param name="name">Имя файла как прислано (расширение — часть после последней точки, в нижнем регистре).</param>
/// <param name="mime">MIME-тип из загрузки (может быть null/пустым — тогда только расширение).</param>
/// <returns>Категория {kind, label} (никогда не null; неопознанное → other/«Файл»).</returns>
public static CardFileKind Detect(string name, string? mime)
{
ArgumentNullException.ThrowIfNull(name);
string kind = OtherKind;
string normalizedMime = mime?.Trim() ?? string.Empty;
if (normalizedMime.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
{
kind = ImageKind;
}
else if (normalizedMime.StartsWith("video/", StringComparison.OrdinalIgnoreCase))
{
kind = VideoKind;
}
else if (normalizedMime.StartsWith("audio/", StringComparison.OrdinalIgnoreCase))
{
kind = AudioKind;
}
else
{
string extension = GetExtension(name);
foreach ((string candidateKind, IReadOnlySet<string> extensions) in ExtensionsByKind)
{
if (extensions.Contains(extension))
{
kind = candidateKind;
break;
}
}
}
return new CardFileKind(kind, LabelsByKind[kind]);
}
private static string GetExtension(string name)
{
int lastDot = name.LastIndexOf('.');
return lastDot < 0
? string.Empty
: name.Substring(lastDot + 1).ToLowerInvariant();
}
}
@@ -0,0 +1,51 @@
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
namespace Deal.Modules.Kanban.Application.Services;
/// <summary>
/// Сервис правил хранения
/// </summary>
/// <param name="store">Порт хранилища канбана: кандидаты тика, batch-перенос в архив, жёсткое удаление пачки.</param>
/// <param name="settings">KV-хранилище настроек тенанта.</param>
public sealed class StorageTickService(ICardStore store, ISettingsStore settings)
{
/// <summary>
/// Один тик правил хранения тенанта.
/// </summary>
/// <returns>Статистика тика: сколько карточек архивировано/очищено из архива и корзины (purgedRejected=0).</returns>
public async Task<StorageTickStatsDto> TickAsync(CancellationToken ct)
{
// Все правила хранения — из одного типизированного снимка настроек (C30): один GetAllAsync на тик.
TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(settings, ct);
bool autoArchive = settingsSnapshot.GetBool(SettingsKeys.AutoArchive, SettingsDefaults.AutoArchive);
int archiveAfterDays = settingsSnapshot.GetInt(SettingsKeys.ArchiveAfterDays, SettingsDefaults.ArchiveAfterDays);
int archiveClearDays = settingsSnapshot.GetInt(SettingsKeys.ArchiveClearDays, SettingsDefaults.ArchiveClearDays);
int trashClearDays = settingsSnapshot.GetInt(SettingsKeys.TrashClearDays, SettingsDefaults.TrashClearDays);
DateTimeOffset now = DateTimeOffset.UtcNow;
int archived = 0;
if (autoArchive)
{
IReadOnlyList<string> candidates = await store.ListArchiveCandidatesAsync(
now.AddDays(-archiveAfterDays), ct);
archived = candidates.Count == 0 ? 0 : await store.ArchiveAsync(candidates, now, ct);
}
int purgedArchive = await PurgeAsync(
await store.ListExpiredArchiveCandidatesAsync(now.AddDays(-archiveClearDays), ct), ct);
int purgedTrash = await PurgeAsync(
await store.ListTrashCandidatesAsync(now.AddDays(-trashClearDays), ct), ct);
return new StorageTickStatsDto(archived, purgedArchive, purgedTrash, PurgedRejected: 0);
}
private async Task<int> PurgeAsync(IReadOnlyList<string> cardIds, CancellationToken ct)
{
return cardIds.Count == 0 ? 0 : await store.PurgeAsync(cardIds, ct);
}
}
@@ -0,0 +1,249 @@
using System.Text;
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Kanban.Application.Services;
/// <summary>
/// Чистое ядро ИИ-предложений колонок/ключей — план.
/// </summary>
public static class SuggestHeuristics
{
/// <summary>
/// Минимум карточек в «Неразобранном» для анализа.
/// </summary>
public const int MinInbox = 6;
/// <summary>
/// Минимум карточек в одной колонке-предложении.
/// </summary>
public const int MinInboxGroup = 2;
/// <summary>
/// Сколько сообщений берём в анализ — окно по received_at DESC.
/// </summary>
public const int MaxText = 12;
/// <summary>
/// Колонок-предложений за один прогон максимум.
/// </summary>
public const int MaxColumns = 4;
/// <summary>
/// Минимальная длина слова-темы
/// </summary>
public const int MinWordLength = 3;
/// <summary>
/// Максимальная длина слова-темы/ключа
/// </summary>
public const int MaxKeywordLength = 40;
/// <summary>
/// Максимум ключей-маркеров suggest-keywords.
/// </summary>
public const int MaxKeywordsTotal = 60;
/// <summary>
/// Окно карточек для suggest-keywords
/// </summary>
public const int KeywordsSampleLimit = 40;
/// <summary>
/// Минимум карточек для suggest-keywords
/// </summary>
public const int MinKeywordsSample = 3;
private const string ColumnNoteFormat =
"Эвристика (этап 3): слово-тема «{0}» встречается у {1} карточек; реальные предложения ИИ — этап 6";
// Служебные слова, не несущие темы: предлоги/союзы/частицы и типовые обращения заявок («нужен», «ищу»…).
private static readonly HashSet<string> StopWords = new(StringComparer.Ordinal)
{
// Русские служебные слова (предлоги/союзы/частицы/местоимения).
"а", "без", "более", "будет", "бы", "в", "вам", "вас", "ведь", "весь", "вместе", "вот", "впрочем",
"всё", "все", "всего", "всех", "вы", "где", "да", "даже", "для", "до", "его", "её", "ей", "ему",
"если", "есть", "ещё", "еще", "же", "за", "затем", "здесь", "и", "из", "или", "им", "иногда", "их",
"к", "каждый", "как", "какая", "какие", "какой", "когда", "кого", "кому", "конечно", "которая",
"которого", "которые", "который", "которых", "кто", "куда", "ли", "лишь", "лучше", "между", "меня",
"мне", "много", "может", "можно", "мой", "моя", "мы", "на", "над", "надо", "нас", "не", "него",
"нее", "неё", "ней", "нельзя", "нет", "ни", "но", "ну", "о", "об", "один", "однако", "он", "она",
"они", "опять", "от", "очень", "перед", "по", "под", "после", "потом", "потому", "почти", "при",
"про", "раз", "с", "сам", "сама", "самое", "самый", "свою", "себе", "себя", "сейчас", "сколько",
"со", "совсем", "так", "такая", "такие", "такой", "там", "те", "тебе", "тебя", "тем", "теперь",
"то", "тогда", "того", "тоже", "только", "том", "тот", "ту", "тут", "ты", "у", "уже", "хотя",
"чего", "чем", "через", "что", "чтобы", "чуть", "эта", "эти", "этим", "этих", "это", "этого",
"этой", "этом", "этот", "эту", "я",
// Типовые обращения/глаголы-просьбы, общие для заявок любой сферы (не слова-темы).
"добрый", "здравствуйте", "привет", "пожалуйста", "напишите", "написать", "пишите", "писать",
"звоните", "свяжитесь", "связаться", "подробнее", "интересно", "интересует", "нужен", "нужна",
"нужно", "нужны", "ищу", "ищем", "ищет", "ищут", "искал", "искали", "требуется", "требуются",
"найти", "нанять", "рассмотрите", "рассмотрим",
// Английские служебные слова.
"the", "and", "for", "with", "from", "that", "this", "are", "was", "were", "have", "has", "had",
"not", "but", "you", "your", "our", "can", "will", "would", "should", "about", "into", "over",
"after", "before", "more", "most", "than", "then", "them", "they", "their", "there", "here", "when",
"where", "which", "what", "who", "whom", "its", "been", "being", "all", "any", "some", "such",
"only", "also", "very", "just", "please",
};
/// <summary>
/// Планы колонок-предложений по карточкам «Неразобранного».
/// </summary>
/// <param name="inbox">Карточки «Неразобранного» с непустым текстом источника (ICardStore.ListInboxWithSourceAsync).</param>
/// <param name="existingBoardNames">Имена существующих (suggested=false) досок — похожие темы не предлагаются.</param>
/// <returns>Планы колонок (≤4, каждая ≥2 карточки); пусто — мало карточек/нечего сгруппировать.</returns>
public static IReadOnlyList<SuggestedColumnPlan> PlanColumns(IReadOnlyList<CardDto> inbox, IReadOnlyList<string> existingBoardNames)
{
List<CardDto> window = inbox
.Where(card => (card.Content.Text ?? string.Empty).Trim().Length > 0)
.OrderByDescending(card => card.ReceivedAtMs)
.ThenBy(card => card.Id, StringComparer.Ordinal)
.Take(MaxText)
.ToList();
if (window.Count < MinInbox)
{
return Array.Empty<SuggestedColumnPlan>();
}
// Слово → карточки окна, где оно встречается (у карточки слово учитывается один раз).
Dictionary<string, List<string>> termCards = new(StringComparer.Ordinal);
foreach (CardDto card in window)
{
// Distinct: у карточки слово учитывается один раз, сколько бы раз ни встречалось в тексте.
foreach (string term in Tokenize(card.Content.Text ?? string.Empty).Distinct(StringComparer.Ordinal))
{
if (!termCards.TryGetValue(term, out List<string>? cardIds))
{
cardIds = [];
termCards[term] = cardIds;
}
cardIds.Add(card.Id);
}
}
List<string> existingLowered = existingBoardNames
.Select(name => name.ToLowerInvariant())
.ToList();
// Кандидаты: слово в ≥2 карточках и не похожее на существующую доску; порядок — частота,
// при равенстве более длинное (специфичнее), затем лексикографически (детерминизм).
List<string> candidates = termCards
.Where(pair => pair.Value.Count >= MinInboxGroup && !SimilarToExisting(pair.Key, existingLowered))
.Select(pair => pair.Key)
.OrderByDescending(term => termCards[term].Count)
.ThenByDescending(term => term.Length)
.ThenBy(term => term, StringComparer.Ordinal)
.ToList();
var usedCardIds = new HashSet<string>(StringComparer.Ordinal);
var plans = new List<SuggestedColumnPlan>();
foreach (string term in candidates)
{
if (plans.Count == MaxColumns)
{
break;
}
List<string> freeCardIds = termCards[term].Where(cardId => !usedCardIds.Contains(cardId)).ToList();
if (freeCardIds.Count < MinInboxGroup)
{
continue;
}
usedCardIds.UnionWith(freeCardIds);
plans.Add(new SuggestedColumnPlan(
Word: term,
Name: Capitalize(term),
CardIds: freeCardIds,
Note: string.Format(ColumnNoteFormat, term, freeCardIds.Count)));
}
return plans;
}
/// <summary>
/// Частотные слова-маркеры по текстам карточек.
/// </summary>
/// <param name="texts">Тексты источника карточек выборки (непустые, свежие ≤40).</param>
/// <returns>Слова-маркеры (≤60); пусто — нет слов, встречающихся в ≥2 текстах.</returns>
public static IReadOnlyList<string> SuggestDomainKeywords(IReadOnlyList<string> texts)
{
Dictionary<string, int> termCounts = new(StringComparer.Ordinal);
foreach (string text in texts)
{
// Маркер — слово, встречающееся в ≥2 ТЕКСТАХ выборки: повтор в одном тексте не увеличивает счёт.
foreach (string term in Tokenize(text).Distinct(StringComparer.Ordinal))
{
termCounts[term] = termCounts.GetValueOrDefault(term) + 1;
}
}
return termCounts
.Where(pair => pair.Value >= MinInboxGroup)
.OrderByDescending(pair => pair.Value)
.ThenByDescending(pair => pair.Key.Length)
.ThenBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => pair.Key)
.Take(MaxKeywordsTotal)
.ToList();
}
// Слова текста: подряд букв (латиница/кириллица), нижний регистр, длина 3..40, минус стоп-слова.
// text: Исходный текст (текст источника карточки).
// Возвращает: Уникальные слова текста в порядке появления (Ordinal).
private static List<string> Tokenize(string text)
{
var terms = new List<string>();
var buffer = new StringBuilder();
foreach (char c in text)
{
if (char.IsLetter(c))
{
buffer.Append(c);
continue;
}
AddTerm(buffer, terms);
}
AddTerm(buffer, terms);
return terms;
}
// Сбрасывает буфер слова в список, если слово проходит пороги (длина, стоп-слова).
// buffer: Накопленные буквы слова.
// terms: Список слов текста.
private static void AddTerm(StringBuilder buffer, List<string> terms)
{
if (buffer.Length == 0)
{
return;
}
string term = buffer.ToString().ToLowerInvariant();
buffer.Clear();
if (term.Length is < MinWordLength or > MaxKeywordLength || StopWords.Contains(term))
{
return;
}
terms.Add(term);
}
private static bool SimilarToExisting(string term, List<string> existingLowered)
{
foreach (string name in existingLowered)
{
if (term == name || name.Contains(term, StringComparison.Ordinal) || term.Contains(name, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
private static string Capitalize(string term) => char.ToUpperInvariant(term[0]) + term[1..];
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Deal.SharedKernel\Deal.SharedKernel.csproj" />
<ProjectReference Include="..\Deal.Contracts\Deal.Contracts.csproj" />
<ProjectReference Include="..\Deal.Modules.Settings\Deal.Modules.Settings.csproj" />
<ProjectReference Include="..\Deal.Modules.Cards\Deal.Modules.Cards.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.11" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -0,0 +1,8 @@
namespace Deal.Modules.Kanban;
/// <summary>
/// Маркер модуля Kanban
/// </summary>
public interface IKanbanModule
{
}