Инициализировать репозиторий «Дейл»

Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
Rustam Khalimov
2026-09-11 02:50:17 +03:00
commit 9e07568ddd
1402 changed files with 177470 additions and 0 deletions
@@ -0,0 +1,183 @@
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application;
namespace Deal.Modules.Kanban.Application;
/// <summary>
/// Нормализация бюджета «при поступлении»: приведение к форме хранения и пересчёт в целевую валюту
/// (ai.py clean_budget L316326, budget_to_target L342352; Ruling 7 — чистый BudgetNormalizer).
/// </summary>
/// <remarks>
/// Чистый класс без хранилища: <see cref="Normalize"/> приводит произвольный бюджет (из ИИ/локального
/// разбора) к соглашению хранения — одна сумма X → from=to=X, «до X» → from=null, from=0 → null
/// («от 0 до X» == «до X»); валюта нормализуется алиасами к коду (ai.py _norm_currency L279291).
/// <see cref="ToTarget"/> пересчитывает нормализованный бюджет в целевую валюту тенанта по курсам —
/// «интерфейс курсов» это словарь «код → курс к рублю» + чистая <see cref="RatesService.ConvertAmount"/>
/// (USDT=USD, rates.py L86103); чтение настроек conversionOn/targetCurrency и кэша ratesCache остаётся
/// за вызывающим (демо Task 13, пайплайн этапа 4, ConversionRecomputer Task 12).
/// </remarks>
public static class BudgetNormalizer
{
// Дефолтная целевая валюта (ai.py budget_to_target L347: targetCurrency or "RUB").
private const string DefaultTargetCurrency = "RUB";
// Синонимы валют из ответов ИИ → коды хранения (ai.py _CUR_ALIASES L271276, согласовано с rules._CUR_*).
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>
/// Нормализует бюджет к форме хранения (ai.py clean_budget L316326).
/// </summary>
/// <param name="budget">Бюджет из ИИ/локального разбора (null — бюджета нет).</param>
/// <returns>
/// Нормализованный бюджет CardBudgetDto(from/to/cur) либо null: бюджет отсутствует, валюта не
/// распознана или обе границы отсутствуют/равны нулю (чистый dict → None в прототипе).
/// 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>
/// Пересчёт бюджета в целевую валюту «один раз при поступлении» (ai.py budget_to_target L342352).
/// </summary>
/// <param name="budget">Нормализованный бюджет (валюта — код, см. <see cref="Normalize"/>).</param>
/// <param name="conversionOn">Настройка conversionOn (Ruling 7); false → конверсия снята.</param>
/// <param name="targetCurrency">Настройка targetCurrency (пустая → RUB, как в прототипе).</param>
/// <param name="rates">Курсы к рублю «код → курс» (кэш ratesCache либо мок-курсы).</param>
/// <returns>
/// Сконвертированный бюджет CardBudgetDto(convFrom, convTo, targetCurrency), либо null — конверсия
/// выключена/бюджета нет/валюта не задана (в прототипе это convCur=""). При валюте, отсутствующей в
/// курсах, границы null, но целевая валюта сохраняется (как budget_to_target L351357).
/// </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);
}
// Число границы: 0 → null (отсутствие границы), иначе значение (ai.py _budget_num L294313: x==0 → None).
// value: Значение границы из бюджета.
// Возвращает: Значение или null при 0.
private static double? NormalizeBound(double? value)
{
if (value is null || value.Value == 0)
{
return null;
}
return value.Value;
}
// Приводит название/символ валюты из ИИ к коду (ai.py _norm_currency L279291).
// raw: Валюта как пришла («рублей», «$», «usd», …).
// Возвращает: Код валюты (USD/RUB/…) или null, если не распознана.
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(IsCurrencyLetter).ToArray());
if (CurrencyAliases.TryGetValue(letters, out string? fromLetters))
{
return fromLetters;
}
if (s.Length == 3 && s.All(IsCurrencyLetter))
{
return s;
}
return null;
}
// Буква кода валюты: латиница A–Z или кириллица А–Я (regex прототипа [^A-ZА-Я], ai.py L286).
// c: Символ (строка уже в верхнем регистре).
// Возвращает: True — буква, участвующая в распознавании валюты.
private static bool IsCurrencyLetter(char c)
{
return c is >= 'A' and <= 'Z' or >= 'А' and <= 'Я';
}
// Конвертация суммы через курсы к рублю; 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,15 @@
namespace Deal.Modules.Kanban.Application;
/// <summary>
/// Результат <see cref="FileKindDetector.Detect"/> — kind/label вложения (1:1 files.py detect L3145).
/// </summary>
/// <remarks>
/// kind — категория файла для wire-поля CardFileDto.kind и иконок фронта; label — человекочитаемая
/// метка (wire-поле CardFileDto.label). Значения 1:1 с прототипом (files.py KIND_BY_EXT/KIND_LABELS):
/// image/video/audio/archive/document/other и «Изображение»/«Видео»/«Аудио»/«Архив»/«Документ»/«Файл».
/// </remarks>
/// <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,169 @@
using Deal.Contracts.Integrations;
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Kanban.Application;
/// <summary>
/// Файлы карточки — partial-часть <see cref="CardsService"/> (этап 9: тот же домен карточки):
/// оркестрация файлового хранилища и метаданных FilesJson (files.py L5794).
/// </summary>
/// <remarks>
/// Вложения живут в двух местах — объект файла в файловом хранилище (порт <see cref="IFileStorage"/>) и
/// метаданные JSON-массивом <c>FilesJson</c> карточки (запись {id,name,size,kind,label,objectKey}).
/// add_file — объект сохраняется первым, затем метаданные дописываются атомарным jsonb-append; get_file_entry —
/// запись по id для download; remove_file — объект удаляется из хранилища (при непустом objectKey), запись
/// убирается из массива атомарной jsonb-фильтрацией. 404-семантика — null-результатом методов. Id записей —
/// PrefixId с префиксом <c>pf_</c>; objectKey строит сервис.
/// </remarks>
public sealed partial class CardsService
{
// Первый сегмент objectKey — каталог вложений карточек (object_store.py L65: «projects/{card}/{ms}_{name}»).
private const string ObjectRootSegment = "projects";
// Замена недопустимых символов имени в objectKey (path-разделители/кавычки заменяются).
private const char KeyNameReplacement = '_';
// Символы имени, заменяемые в objectKey: path-разделители («/», «\») и кавычки «"».
private const string KeyNameUnsafeCharacters = "/\\\"";
// Имя файла по умолчанию при пустом имени из multipart (прототип: «f.filename or "file"»).
private const string DefaultAttachmentName = "file";
/// <summary>
/// Добавляет файл карточке: объект в хранилище + метаданные в конец массива files (files.py add_file L5775).
/// </summary>
/// <remarks>
/// Порядок 1:1 с прототипом: карточки нет → null (404) ДО записи объекта. Kind — через
/// <see cref="FileKindDetector.Detect"/> по contentType и расширению; id записи (pf_) генерируется ДО
/// ключа и входит в него: objectKey = projects/{cardId}/{fileId}_{unixMs}_{safeName} — две загрузки в одну
/// миллисекунду не перезапишут объект. Имя в ключе санитизируется, в метаданных остаётся как прислано.
/// Карточка исчезла между чтением и записью — объект удаляется (сирота не нужен) и возвращается null (404).
/// </remarks>
/// <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>
/// <param name="ct">Токен отмены.</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-эндпоинта (files.py get_file_entry L7883).
/// </summary>
/// <remarks>
/// Возвращает ТОЛЬКО метаданные (включая objectKey/name): поток объекта для ответа резолвит эндпоинт через
/// <see cref="IFileStorage.GetAsync"/>. Карточки нет или записи с таким id нет → null (оба случая — 404).
/// </remarks>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="fileId">Id записи файла (<c>pf_...</c>).</param>
/// <param name="ct">Токен отмены.</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>
/// Удаляет файл карточки: объект из хранилища + запись из массива files (files.py remove_file L8694).
/// </summary>
/// <remarks>
/// Карточки нет → null (404). Объект удаляется только когда запись найдена и у неё непустой objectKey.
/// Запись убирается из FilesJson атомарной jsonb-фильтрацией; записи с указанным id нет — список остаётся
/// прежним, ошибки НЕТ.
/// </remarks>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="fileId">Id удаляемой записи файла (<c>pf_...</c>).</param>
/// <param name="ct">Токен отмены.</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);
}
// Строит objectKey файла: projects/{cardId}/{fileId}_{unixMs}_{safeName} (object_store.py put L65).
// cardId: Id карточки (c_...).
// fileId: Id записи файла (pf_...; уникальный суффикс ключа).
// name: Имя файла как прислано (в ключ идёт санитизированная часть).
// Возвращает: Ключ объекта (opaque для хранилища).
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,106 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application;
using Deal.Modules.Kanban.Application.ColumnRules;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application;
// Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён — внутри пространства имён
// Deal.Modules.Kanban.Application имя ColumnRules резолвится в пространство (CS0234), нужен явный алиас.
using KanbanColumnRules = Deal.Modules.Kanban.Application.ColumnRules.ColumnRules;
namespace Deal.Modules.Kanban.Application;
/// <summary>
/// Приватные помощники <see cref="CardsService"/> — partial-часть (C32: выделено из общего файла,
/// поведение не менялось): текст обучающего примера, перенос колонки с журналом, hits правил доски и
/// колонка возврата (leads.py L163174, L209, L311319; Ruling 2/4).
/// </summary>
public sealed partial class CardsService
{
// ── Внутреннее ─────────────────────────────────────────────────────────
// Текст обучающего примера: source_msg (после Trim) или title (leads.py L167, L189190, L199200).
// card: Карточка.
// Возвращает: source_msg без краевых пробелов; пустой — title как сохранён (1:1 с (x or "").strip() or (y or "")).
private static string LearningText(CardDto card)
{
string source = card.SourceMsg.Trim();
return source.Length > 0 ? source : card.Title;
}
// Меняет колонку карточки и пишет строку журнала CardMoves (leads.py _move L170174).
// card: Карточка ДО переноса (для prev_col/from_col журнала).
// toCol: Новая колонка.
// hits: matchHits для новой колонки (пересчитаны вызывающим, Ruling 2).
// action: Действие журнала: move/trash.
// ct: Токен отмены.
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);
}
// Пишет строку журнала действия (leads.py _log_learning L4044): id lm_ генерирует модуль.
// cardId: Id карточки.
// action: Действие: move/trash/restore/comment.
// fromCol: Прежняя колонка (для comment — null).
// toCol: Новая колонка (для comment — null).
// 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);
}
// Совпавшие критерии правил доски (hits_for_board L311319 через ColumnRules, Ruling 2).
// boardId: Id доски (b_...).
// text: Текст карточки для правил (source_msg или title).
// 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);
}
// Совпавшие критерии правил колонки-доски (ColumnRules.ComputeHits с курсами для бюджета).
// Резолв имени — через алиас KanbanColumnRules: одноимённые класс и namespace ColumnRules в одном модуле.
// rules: Правила доски; null («правил нет») → пусто (Ruling 2).
// text: Текст карточки для правил.
// ct: Токен отмены.
// Возвращает: Совпавшие критерии (label/term[/word]); нет активных правил → пусто.
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);
}
// Колонка возврата карточки: prev_col, если inbox или существующая доска, иначе inbox (restore_lead L209).
// prevCol: Сохранённая prev_col карточки.
// ct: Токен отмены.
// Возвращает: Колонка возврата (inbox/доска).
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,341 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application;
using Deal.Modules.Kanban.Application.ColumnRules;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application;
namespace Deal.Modules.Kanban.Application;
/// <summary>
/// Публичные операции карточек — partial-часть <see cref="CardsService"/> (C32: выделено из общего
/// файла по темам, поведение не менялось): чтение списка/карточки, переносы/корзина/возврат/удаление/
/// очистка колонки, комментарии, mark-seen, счётчики и поиск (leads.py L151279, L509551).
/// </summary>
public sealed partial class CardsService
{
// ── Чтение (list_leads/get_lead L151160) ───────────────────────────────
/// <summary>
/// Карточки колонки или всех колонок дашборда, received_at DESC (list_leads L151156).
/// </summary>
/// <param name="col">Колонка-фильтр (inbox/archive/trash/доска); null — все колонки дашборда.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Полные карточки (маппинг/комментарии/time — адаптер); пусто — карточек нет.</returns>
public Task<IReadOnlyList<CardDto>> ListCardsAsync(string? col, CancellationToken ct)
{
return _store.ListCardsAsync(new CardsQuery(col), ct);
}
/// <summary>
/// Одна карточка по id (get_lead L159160; GET /api/cards/{id}).
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Карточка или null — строки нет (эндпоинт отвечает 404 «Карточка не найдена»).</returns>
public Task<CardDto?> GetCardAsync(string cardId, CancellationToken ct)
{
return _store.GetCardAsync(cardId, ct);
}
// ── Переносы / архив / корзина (L163–247) ───────────────────────────────
/// <summary>
/// Перенос карточки на доску или в «Неразобранное» (move_lead L177191 + _move L163174).
/// </summary>
/// <remarks>
/// Цель валидируется до чтения карточки: не inbox и не существующая доска → 400
/// <see cref="MoveTargetInvalidDetail"/>. Исходная колонка archive/trash для MoveLeadAsync недоступна
/// → 400 <see cref="MoveSourceRestrictedDetail"/>: вывод из них — только restore_lead (иначе перенос минует
/// снятие метки «спам» возврата из корзины, Ruling 4). Перенос «в ту же колонку» — no-op (карточка
/// возвращается без изменений; журнал и обучение не пишутся). При реальном переносе: колонка меняется
/// (is_new=FALSE, prev_col = прежняя колонка), matchHits пересчитываются для доски через
/// <see cref="ColumnRules.ComputeHits"/> (Ruling 2; для inbox — пусто), пишется строка журнала action=move,
/// а при to≠inbox — обучающий сигнал PushAsync(text, id доски, 1.0) (Ruling 4; текст = source_msg или title;
/// пустой текст не учим — L188–191).
/// </remarks>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="toCol">Цель: <c>inbox</c> либо id доски (<c>b_...</c>).</param>
/// <param name="ct">Токен отмены.</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);
}
// Из archive/trash карточку выводит только restore (L204222): прямой move в доску
// прошёл бы мимо снятия у ML веса «спама» возврата из корзины (Ruling 4, L218221).
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>
/// Перенос карточки в корзину (trash_lead L194201): col=trash, is_new=FALSE, matchHits пусто.
/// </summary>
/// <remarks>
/// Карточка уже в корзине — no-op (как в _move L165166). Журнал action=trash пишется при реальном
/// переносе; обучающий сигнал «спам» 1.0 — только если карточка была НЕ в trash/archive (L198201,
/// Ruling 4). Исключение archive: перенос архива в корзину не «переучивает» на спам.
/// </remarks>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Карточка после переноса (при no-op — как была) либо null — карточки нет (404).</returns>
public Task<CardDto?> TrashCardAsync(string cardId, CancellationToken ct)
{
return TrashCardAsync(cardId, teach: true, ct);
}
/// <summary>
/// Перенос карточки в корзину с управлением обучением ML (trash_lead L194201).
/// </summary>
/// <remarks>
/// <paramref name="teach"/> = false — «тихое» перемещение без сигнала «спам»: используется ручной
/// переклассификацией (leads.py reclassify_lead L311/L316), где обучение кладётся ЯВНО одним сигналом
/// с весом гипотезы ИИ (0.4), а не весом действия пользователя (1.0). Журнал action=trash пишется всегда.
/// </remarks>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="teach">True — писать сигнал «спам» (действие пользователя); false — не писать.</param>
/// <param name="ct">Токен отмены.</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>
/// Возврат карточки из архива/корзины на канбан (restore_lead L204222).
/// </summary>
/// <remarks>
/// Куда возвращаем: prev_col, если это «Неразобранное» или существующая доска, иначе inbox (L209).
/// При возврате is_new=TRUE, prev_col='inbox', archived_at=NULL (Ruling 10), matchHits пересчитаны для
/// доски (Ruling 2), журнал action=restore. Возврат ИЗ корзины снимает метку спам:
/// PushAsync(text, "spam", 1.0) (L218221, Ruling 4); из архива сигнал не шлётся.
/// </remarks>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="ct">Токен отмены.</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>
/// Полное удаление карточки (delete_forever L225234): Cards + комментарии (FK cascade),
/// журнал CardMoves/MlOutbox не трогаются (Ruling 10).
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="ct">Токен отмены.</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 (clear_col L237247).
/// </summary>
/// <remarks>Другая колонка (inbox/доска/…) → 400 <see cref="ClearColInvalidDetail"/> (как ValueError L239240).</remarks>
/// <param name="col">Очищаемая колонка: <c>trash</c> | <c>archive</c>.</param>
/// <param name="ct">Токен отмены.</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);
}
// ── Комментарии (add_comment L259265) ──────────────────────────────────
/// <summary>
/// Добавляет комментарий к карточке: строка LeadComments (id <c>cm_</c>) + журнал action=comment.
/// </summary>
/// <remarks>
/// Текст Trim'ится (пустой после Trim → 400 <see cref="EmptyCommentDetail"/>, как dashboard_routes L240241);
/// автор — «Вы»; ответ — полный список комментариев (свежий — «только что», маппинг адаптера). Карточки
/// нет → Comments=null, Error=null (404 «Карточка не найдена» — сервис читает карточку до записи, порт
/// LeadComments ссылается FK, Task 4).
/// </remarks>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="text">Текст комментария (непустой после Trim).</param>
/// <param name="ct">Токен отмены.</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);
}
// ── Пометить прочитанным (mark_seen L250–256) ───────────────────────────
/// <summary>
/// Снимает флаг «новое»: с одной карточки (cardId), колонки (col) или всех (оба null/пустые).
/// </summary>
/// <remarks>Семантика 1:1 с mark_seen L250256 (проверка на truthiness: пустая строка = параметр не задан).
/// Эндпоинты этапа: mark-col-seen {col}, mark-all-seen (Ruling 11); /leads/{id}/seen фронтом не вызывается.</remarks>
/// <param name="cardId">Id карточки либо null/пусто.</param>
/// <param name="col">Колонка либо null/пусто (используется, когда cardId не задан).</param>
/// <param name="ct">Токен отмены.</param>
public Task MarkSeenAsync(string? cardId, string? col, CancellationToken ct)
{
return _store.UpdateSeenAsync(
string.IsNullOrEmpty(cardId) ? null : cardId,
string.IsNullOrEmpty(col) ? null : col,
ct);
}
// ── Счётчики (counts L268–279) ──────────────────────────────────────────
/// <summary>
/// Счётчики колонок (count+new по Cards) + статистика обучения/решений ML (learning/ml/ai).
/// </summary>
/// <remarks>
/// Форма CardCountsDto: Columns — только колонки с карточками; New — сумма «новых» по колонкам
/// (counts L270274). learning/ml/ai — из IMlClient.StatusAsync (L275278, план Task 7 L321): learning =
/// count(CardMoves), ml/ai — KV-счётчики решений пайплайна (на этапе 3 — 0, Ruling 4). Плоскую wire-форму
/// «{new, &lt;col&gt;:{…}, learning, ml, ai}» собирает эндпоинт Task 8.
/// </remarks>
/// <param name="ct">Токен отмены.</param>
/// <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,
};
}
// ── Поиск (search L509551, LIKE-вариант Ruling 6) ──────────────────────
/// <summary>
/// Поиск карточек: FTS по Cards.SearchTsv + LIKE-дополнение (search L509551, Ruling 6/Task 12).
/// </summary>
/// <remarks>
/// q после Trim короче 2 символов → пусто, порт не вызывается (поведение этапа 3, L511–512). Сам поиск
/// выполняет адаптер — <see cref="ICardStore.SearchCardsAsync"/>: SearchTsv @@ plainto_tsquery('russian')
/// (морфология) LIKE по lower(title/summary/contact/source_msg), контейнеры-стадии «Выбранных»
/// исключены, порядок
/// ts_rank DESC, ReceivedAt DESC, результат ограничен <see cref="SearchLimit"/> = 12. messages: [] — на
/// совесть эндпоинта (Task 8). Запрос нормализуется trim+lowercase (как отсев-поиск Ruling 6).
/// </remarks>
/// <param name="query">Поисковый запрос (trim + lowercase внутри).</param>
/// <param name="ct">Токен отмены.</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,145 @@
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application;
namespace Deal.Modules.Kanban.Application;
/// <summary>
/// Напоминания «Отложено» — partial-часть <see cref="CardsService"/> (этап 9: тот же домен карточки):
/// set/clear/snooze/check (projects.py L236282), Ruling 3.
/// </summary>
/// <remarks>
/// Семантика 1:1 с прототипом (Ruling 3): set проверяет выключатель (400-результат «Напоминания об отложенных
/// выключены в настройках») и НЕ проверяет ни стадию карточки (фронт шлёт напоминание только для hold), ни
/// время at (прошлое допустимо — «выстреливает» таким at на ближайшей проверке); clear и snooze выключатель НЕ
/// проверяют; snooze = now + 24 ч. CheckDueRemindersAsync (check_reminders L264282): выключено → только
/// очистка протухших и пустой список; включено → due-строки hold-карточек помечаются fired и возвращаются
/// списком {id,title,containerId} — SSE-события по ним публикует Api-слой, не сервис. Порядок проверок set —
/// карточка раньше выключателя (404 раньше 400).
/// </remarks>
public sealed partial class CardsService
{
/// <summary>
/// 400 set: напоминания выключены в настройках (set_reminder projects.py L237238; Ruling 3).
/// </summary>
public const string RemindersDisabledDetail = "Напоминания об отложенных выключены в настройках";
// Ключ публичной настройки-выключателя напоминаний (Ruling 3).
private const string RemindersEnabledKey = SettingsKeys.RemindersEnabled;
// Шаг «напомнить позже» (snooze): +24 часа в epoch-мс (snooze projects.py L257261).
private const long ReminderSnoozeMs = 86_400_000;
/// <summary>
/// Устанавливает напоминание карточке (POST /api/cards/{cardId}/reminder; set_reminder L236243).
/// </summary>
/// <remarks>
/// Порядок 1:1 с прототипом: карточки нет → 404-результат ДО проверки выключателя; напоминания выключены →
/// 400 <see cref="RemindersDisabledDetail"/>. Стадия карточки НЕ проверяется, at НЕ валидируется. Хранилище
/// пишет reminder_at + reminder_fired=false + bump UpdatedAt; ответ — полная карточка после записи.
/// </remarks>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="atMs">Время напоминания, epoch-ms.</param>
/// <param name="ct">Токен отмены.</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>
/// Снимает напоминание карточки (DELETE /api/cards/{cardId}/reminder; clear_reminder L246247).
/// </summary>
/// <remarks>
/// Выключатель НЕ проверяется. Карточки нет → false (404); напоминания у карточки нет — успех без изменений.
/// Хранилище пишет reminder_at=NULL + reminder_fired=false, UpdatedAt НЕ бампит.
/// </remarks>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="ct">Токен отмены.</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>
/// «Напомнить позже»: перенос напоминания на now + 24 ч (POST /api/cards/{cardId}/reminder/snooze).
/// </summary>
/// <remarks>
/// Выключатель НЕ проверяется (snooze зовётся из баннера reminder_due независимо от настройки). Карточки
/// нет → false (404). Хранилище пишет reminder_at=now+24ч, reminder_fired=false, UpdatedAt бампит.
/// </remarks>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="ct">Токен отмены.</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>
/// Проверка наступивших напоминаний «Отложено» (check_reminders projects.py L264282).
/// </summary>
/// <remarks>
/// Выключено → ТОЛЬКО очистка протухших напоминаний (reminder_at ≤ now, без учёта stage/fired) и пустой
/// результат; включено → строки stage='hold' AND reminder_at ≤ now AND reminder_fired=false помечаются
/// fired и возвращаются списком {id,title,containerId}. Публикацию SSE reminder_due выполняет Api-слой.
/// </remarks>
/// <param name="ct">Токен отмены.</param>
/// <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;
}
// Читает выключатель напоминаний «remindersEnabled» (типизированный снимок настроек, Ruling 3).
// ct: Токен отмены.
// Возвращает: Значение настройки; отсутствие/повреждённый JSON → дефолт SettingsDefaults.RemindersEnabled.
private async Task<bool> ReadRemindersEnabledAsync(CancellationToken ct)
{
return (await TenantSettingsSnapshot.LoadAsync(_settings, ct))
.GetBool(RemindersEnabledKey, SettingsDefaults.RemindersEnabled);
}
}
@@ -0,0 +1,421 @@
using System.Text.Json;
using Deal.Modules.Cards.Application;
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Kanban.Application;
/// <summary>
/// Операции пространства «Выбранные» — partial-часть <see cref="CardsService"/> (этап 9: тот же домен
/// карточки): ручное создание, патч полей тела, ссылки, перенос по контейнерам-стадиям с историей и сбросом
/// напоминания, «взять в работу», очистка «Отклонено» (projects.py L103231).
/// </summary>
public sealed partial class CardsService
{
/// <summary>
/// 400 перенос по стадии: стадии нет в каталоге (move_stage projects.py L203204).
/// </summary>
public const string UnknownStageDetail = "Неизвестная стадия";
// Стадия размещения новой карточки и fallback ручного создания (Rulings 5/6).
private const string PlannedStage = CardsDefaultContainers.Planned;
// Очищаемая терминальная стадия (clear_stage projects.py L225).
private const string RejectedStage = CardsDefaultContainers.Rejected;
// Текст комментария-«взял в работу» на карточке (take L149).
private const string TakenCommentText = "Взял в работу.";
/// <summary>
/// 400 ссылка: пустой url после Trim (projects_routes.py L137138).
/// </summary>
public const string EmptyLinkDetail = "Пустая ссылка";
// Схема по умолчанию ссылки, присланной без схемы (add_link L139140: «https://» + url).
private const string HttpsUrlScheme = "https://";
// Схема http: ссылки с ней оставляются как есть (add_link L139 — проверка префикса http/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";
// Представление «бюджета нет» для патча: пустая Cur = очистка (patch_card L174179).
private static readonly CardBudgetDto ClearedBudget = new(From: null, To: null, Cur: string.Empty);
/// <summary>
/// Карточки пространства «Выбранные»: список ORDER BY updated_at DESC с фильтром контейнера-стадии.
/// </summary>
/// <param name="containerId">Фильтр по контейнеру-стадии; null — все стадии «Выбранных».</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Полные карточки в порядке UpdatedAt DESC; пусто — карточек нет.</returns>
public Task<IReadOnlyList<CardDto>> ListSelectedCardsAsync(string? containerId, CancellationToken ct)
{
return _store.ListSelectedCardsAsync(containerId, ct);
}
/// <summary>
/// Ручное создание «локальной» карточки без внешнего источника (POST /api/cards; create_local_card L103124).
/// </summary>
/// <remarks>
/// local=true («создано локально»); title — Trim(); контейнер — переданный, если есть в каталоге
/// <see cref="CardsDefaultContainers"/>, иначе <c>planned</c>; история — одна запись type="createdLocal".
/// Пустой заголовок допустим (фронт шлёт {title:''}, 1:1 прототип).
/// </remarks>
/// <param name="draft">Начальные поля карточки (тело POST /api/cards, см. <see cref="CardLocalCreateDto"/>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Созданная карточка (полное чтение после записи).</returns>
public async Task<CardDto> CreateLocalCardAsync(CardLocalCreateDto draft, CancellationToken ct)
{
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
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,
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>
/// «Взять в работу»: карточка переходит в контейнер-стадию planned, сохраняя свой id (POST
/// /api/cards/take; Rulings 4/5 этапа 9 — никакого клонирования во вторую сущность).
/// </summary>
/// <remarks>
/// Поток: (1) карточка читается (<see cref="ICardStore.GetCardAsync"/>) — null → null-результат (404);
/// (2) если карточка уже в контейнере-стадии — возвращается без изменений (идемпотентность Ruling 5);
/// (3) иначе карточка переносится в стадию planned той же строкой (<see cref="ICardStore.MoveCardStageAsync"/>:
/// контейнер, запись истории, сброс напоминания), дописывается комментарий «Взял в работу.».
/// </remarks>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="ct">Токен отмены.</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))
{
// Карточка уже в пространстве «Выбранные» — повторный take идемпотентен (Ruling 5).
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 (PATCH /api/cards/{cardId};
/// 1:1 с patch_card projects.py L159187).
/// </summary>
/// <remarks>
/// Тело — произвольный JSON-объект: учитывается ПРИСУТСТВИЕ ключа, а не только значение, поэтому явный
/// null очищаемых полей не теряется типизированным биндингом. Патчатся ключи title/summary/contact/
/// tzText (JSON-строка, пустая строка — очистка текста), stack (JSON-массив строк; null/не-массив →
/// пустой стек) и budget (JSON-объект {from,to,cur}; null/не-объект → очистка бюджета). Неизвестные
/// ключи игнорируются. Правки в историю НЕ пишутся (Ruling 7); хранилище бампает UpdatedAt.
/// </remarks>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="body">Тело PATCH: ключ → JSON-значение (наличие ключа = поле меняется).</param>
/// <param name="ct">Токен отмены.</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>
/// Добавляет ссылку карточке: append в JSON-массив links (POST /api/cards/{cardId}/links; add_link L133143).
/// </summary>
/// <remarks>
/// Порядок 1:1 с прототипом: карточки нет → 404-результат ДО валидации url. url Trim'ится; пустой → 400
/// <see cref="EmptyLinkDetail"/>. Без схемы http:// или https:// → префикс https://. Новая запись:
/// {id <c>pl_</c>, name: name.Trim() или url — пустое имя → ссылка называется url, url}. Запись — атомарным
/// jsonb-append хранилища.
/// </remarks>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="name">Название ссылки; пустое после Trim → name = url.</param>
/// <param name="url">URL ссылки (без схемы — добавится https://).</param>
/// <param name="ct">Токен отмены.</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: фильтрация JSON-массива links (DELETE /api/cards/{cardId}/links/{linkId}).
/// </summary>
/// <remarks>
/// Карточки нет → 404-результат. Ссылка с указанным id не найдена — список остаётся прежним, ошибки НЕТ.
/// Запись — атомарной jsonb-фильтрацией хранилища.
/// </remarks>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="linkId">Id удаляемой ссылки (<c>pl_...</c>).</param>
/// <param name="ct">Токен отмены.</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>
/// Перенос карточки по контейнерам-стадиям «Выбранных»: запись истории + сброс напоминания
/// (1:1 move_stage projects.py L202216, Rulings 3/7).
/// </summary>
/// <remarks>
/// Контейнер валидируется каталогом <see cref="CardsDefaultContainers"/> (400 «Неизвестная стадия»).
/// При успехе хранилище пишет одним UPDATE: col, reminder_at=NULL/reminder_fired=false, updated_at=время
/// переноса, history + запись {id h_, at, stage:&lt;новый&gt;}.
/// </remarks>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="containerId">Новый контейнер-стадия — id каталога <see cref="CardsDefaultContainers"/>.</param>
/// <param name="ct">Токен отмены.</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 строк (POST /api/cards/clear-rejected).
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <returns>Сколько карточек удалено (0 — стадия пуста).</returns>
public Task<int> ClearRejectedAsync(CancellationToken ct)
{
return _store.ClearStageAsync(RejectedStage, ct);
}
// Переводит тело PATCH в точечный патч хранилища: ключ присутствует → поле меняется;
// неизвестные ключи отбрасываются (1:1 pydantic PatchBody + patch_card L159187).
// body: Тело PATCH (ключ → значение).
// Возвращает: Патч со значениями присутствующих ключей; остальные поля null («не менять»).
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,118 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application.ColumnRules;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application;
// Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён — внутри пространства имён
// Deal.Modules.Kanban.Application имя ColumnRules резолвится в пространство (CS0234), нужен явный алиас.
using KanbanColumnRules = Deal.Modules.Kanban.Application.ColumnRules.ColumnRules;
namespace Deal.Modules.Kanban.Application;
/// <summary>
/// Сервис карточек — единый домен карточки (дашборд и «Выбранные»): leads.py L151279 + L509551
/// и projects.py L103282 (этап 9).
/// </summary>
/// <remarks>
/// Чистый сервис модуля (без EF/HTTP): оркестрирует <see cref="ICardStore"/> (карточки/контейнеры/
/// комментарии/журнал), <see cref="ISettingsStore"/> (кэш курсов <c>ratesCache</c>, настройка напоминаний,
/// Ruling 3/7), <see cref="IMlClient"/> (обучающие сигналы move/trash/restore и счётчики counts) и
/// <see cref="IFileStorage"/> (объекты вложений карточки).
/// Полные карточки (JSON-поля, comments, human-метка time, matchHits) собирает адаптер (маппинг
/// строки → CardDto в хранилище); сервис добавляет поведение: валидации с фиксированными текстами прототипа,
/// пересчёт matchHits при размещении в доску (Ruling 2, ColumnRules), журнал CardMoves и push-сигналы ML,
/// операции пространства «Выбранные» (создание, патч, ссылки, файлы, перенос по стадии с историей,
/// напоминания) на той же сущности карточки.
/// Обучение ML идёт ВСЕГДА и синхронно — выключатель <c>mlEnabled</c> управляет только использованием ML в
/// пайплайне, а не записью действий пользователя (ml_client.py L6–7: «обучение идёт всегда»; Ruling 4).
/// 404-семантика «Карточка не найдена» выражается null-результатом методов; тексты 400 — константы класса.
/// C32: класс разделён на partial-файлы по темам (Operations/Helpers/Selected/Files/Reminders).
/// </remarks>
/// <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;
/// <summary>
/// Создаёт сервис карточек над портами модуля (поле-захват DI-зависимостей).
/// </summary>
/// <param name="store">Единый порт хранилища карточек/контейнеров/журнала тенанта.</param>
/// <param name="settings">KV-хранилище настроек тенанта (курсы, напоминания).</param>
/// <param name="mlClient">Клиент ML: PushAsync — обучающий сигнал действия, StatusAsync — счётчики counts.</param>
/// <param name="storage">Файловое хранилище вложений карточки (объекты файлов).</param>
public CardsService(ICardStore store, ISettingsStore settings, IMlClient mlClient, IFileStorage storage)
{
_store = store;
_settings = settings;
_mlClient = mlClient;
_storage = storage;
}
// ── Фиксированные строки прототипа (400-детали; leads.py L183184, L239240, dashboard_routes L241) ──
/// <summary>
/// 400 move: целевой контейнер не существует (или это «Неразобранное» — оно допустимо).
/// </summary>
public const string MoveTargetInvalidDetail = "Переносить можно только в существующий контейнер или в «Неразобранное»";
/// <summary>
/// 400 move: исходная колонка archive/trash — из них карточку выводит только restore_lead
/// (кнопка «Вернуть»): прямой перенос в доску миновал бы снятие метки «спам» при возврате из корзины
/// (Ruling 4, PushAsync(spam, 1.0) только в RestoreLeadAsync) и журнал restore.
/// </summary>
public const string MoveSourceRestrictedDetail = "Переносить из корзины, архива или «взятых в работу» нельзя — верните карточку на канбан";
/// <summary>
/// 400 clear-col: колонка не trash/archive (clear_col L239240).
/// </summary>
public const string ClearColInvalidDetail = "Очищать можно только корзину или архив";
/// <summary>
/// 400 комментарий: пустой текст после Trim (dashboard_routes L240241).
/// </summary>
public const string EmptyCommentDetail = "Пустой комментарий";
// ── Журнал CardMoves: действия (leads.py _log_learning L4044) ──────────
// Действие журнала: перенос на доску/в «Неразобранное» (_move L174).
private const string ActionMove = "move";
// Действие журнала: в корзину (_move action='trash' L196).
private const string ActionTrash = "trash";
// Действие журнала: возврат из архива/корзины (restore_lead L216).
private const string ActionRestore = "restore";
// Действие журнала: добавлен комментарий (add_comment L264).
private const string ActionComment = "comment";
/// <summary>
/// Автор комментария — «Вы» (свои комментарии, add_comment L262). Единственный источник строки
/// для комментариев карточки.
/// </summary>
public const string CommentAuthor = "Вы";
/// <summary>
/// Human-метка времени свежего комментария (add_comment L259265; «только что» = возраст < 1 мин).
/// Единственный источник строки: адаптер KanbanStore считает её в HumanAge.
/// </summary>
public const string JustNowLabel = "только что";
// Минимальная длина поискового запроса после Trim: q короче → пустой ответ (search L511512, Ruling 6).
private const int MinSearchQueryLength = 2;
// Ограничение результатов поиска: не больше 12 карточек (search L509, Ruling 6).
private const int SearchLimit = 12;
// Вес сигнала пользователя: действие = истина (ml_client.py USER_WEIGHT L26, Ruling 4).
private const double PushWeightUser = 1.0;
// Вес снятия метки: возврат из корзины (restore_lead L221, delta=-1.0).
private const double PushWeightUnlearn = -1.0;
}
@@ -0,0 +1,217 @@
using System.Globalization;
using System.Text.RegularExpressions;
namespace Deal.Modules.Kanban.Application.ColumnRules;
/// <summary>
/// Парсер сумм из текста сообщения (rules.py extract_amounts L93144, _norm_amount L6273, _cur_from_tail L7690).
/// </summary>
/// <remarks>
/// Сохраняет смысл суммы: «от A до B»/«A–B» → from/to, «до B» → только верхняя граница, одна сумма/«от A»
/// → from=to. Валюту ищем сразу после суммы (символ «$ € ₽ ₮ £ ¥» или слово «usd eur rub … руб долл бакс»)
/// либо перед ней для «$1 200»; суффикс «к/К» в числе — тысячи («2к» → 2000, «1.5к$» → 1500 USD).
/// Суммы БЕЗ валюты игнорируются (прототип L98); повторное срабатывание конструкций на одном тексте
/// отсекается занятыми диапазонами (_free/_add L104112) — порядок проходов 1:1 с прототипом.
/// Семантика 1:1, включая особенности: «usdt» словом после числа распознаётся как USD (первым срабатывает
/// startswith «usd», rules.py L82), «евро»/«рубли» словами не распознаются (в _CUR_WORDS их нет).
/// </remarks>
public static class AmountParser
{
// Цифры суммы: число с разделителями тысяч и опциональным суффиксом «к/К» (rules.py _AMT L58).
private const string Amount = @"\d[\d\s\u00a0]*(?:[.,]\d+)?[кkКK]?";
// Символы валют сразу после суммы (rules.py _CUR_SYMBOLS L15, порядок 1:1).
private static readonly (string Symbol, string Code)[] CurrencySymbols =
{
("$", "USD"), ("€", "EUR"), ("₽", "RUB"), ("₮", "USDT"), ("£", "GBP"), ("¥", "CNY"),
};
// Слова валют сразу после суммы (rules.py _CUR_WORDS L16, порядок 1:1 — важен для «usdt»).
private static readonly (string Word, string Code)[] CurrencyWords =
{
("usd", "USD"), ("eur", "EUR"), ("rub", "RUB"), ("usdt", "USDT"), ("gbp", "GBP"), ("cny", "CNY"),
("руб", "RUB"), ("долл", "USD"), ("бакс", "USD"),
};
// Проход 1: словесный диапазон «от A до B» (rules.py L115).
private static readonly Regex WordRangeRe = new(@"\bот\s+(" + Amount + @")\s+до\s+(" + Amount + @")", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
// Проход 2: «до B» без пары «от…» — только верхняя граница (rules.py L121).
private static readonly Regex ToOnlyRe = new(@"\bдо\s+(" + Amount + @")", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
// Проход 3: «от A» без верхней границы — считаем одной суммой (rules.py L127).
private static readonly Regex FromOnlyRe = new(@"\bот\s+(" + Amount + @")", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
// Проход 4: числовой диапазон «A–B» / «A - B» / «A до B» (rules.py L133).
private static readonly Regex NumericRangeRe = new("(" + Amount + @")\s*(?:[-–—]|\s+до\s+)\s*(" + Amount + @")", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
// Проход 5: одиночные суммы с валютой (rules.py L139).
private static readonly Regex AnyAmountRe = new(Amount, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
/// <summary>
/// Разбирает суммы в тексте (rules.py extract_amounts L93144).
/// </summary>
/// <param name="text">Текст сообщения (сырой, как сохранил источник).</param>
/// <returns>
/// Список распознанных сумм/диапазонов с кодом валюты; пустой список — сумм с валютой нет.
/// Порядок — по проходам парсера (1:1 с прототипом), а не по позиции в тексте.
/// </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)>();
// Свободен ли диапазон (не пересекается с уже занятым конструкцией выше) — прототип _free L106107.
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;
}
// Число из строки суммы: пробелы и неразрывные пробелы убираем, «,» — десятичный разделитель,
// суффикс «к/К» — множитель 1000 (прототип _norm_amount L6273).
// raw: Сырая цифровая часть из regex (может содержать «к»/«К» на конце).
// Возвращает: Значение суммы или null при ошибке парсинга.
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;
}
// Код валюты рядом с суммой: символ/слово сразу после позиции либо символ за ≤3 символа до неё
// («$1 200», прототип _cur_from_tail L7690).
// text: Весь текст сообщения.
// position: Позиция сразу после конца суммы (m.end).
// Возвращает: Код валюты или null, если валюты рядом нет.
private static string? CurFromTail(string text, int position)
{
// Окно до 12 символов после суммы (прототип: text[m_start:m_start+12].lower().strip()).
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;
}
}
// Валюта перед числом («$1 200»): символ за ≤3 символа до конца суммы (прототип L86–89).
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,15 @@
namespace Deal.Modules.Kanban.Application.ColumnRules;
/// <summary>
/// Одна распознанная сумма/диапазон из текста сообщения (rules.py extract_amounts L93144).
/// </summary>
/// <remarks>
/// Элемент результата <see cref="AmountParser.Parse"/>: «от A до B»/«AB» → from=A, to=B; «до B»
/// (только верхняя граница) → from=null, to=B; одна сумма/«от A» → from=to=A. <see cref="Cur"/> —
/// код валюты (USD/RUB/…) из символа или слова сразу после суммы (для «$1 200» — символа перед ней);
/// суммы без валюты парсер игнорирует (прототип, rules.py L98).
/// </remarks>
/// <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,80 @@
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application;
namespace Deal.Modules.Kanban.Application.ColumnRules;
/// <summary>
/// Проверка попадания распознанных сумм в бюджетный диапазон правил колонки (rules.py _amount_in_range L147173).
/// </summary>
/// <remarks>
/// «Интерфейс курсов» — чистый: сравнение в одной валюте не требует курсов, при разных валютах суммы
/// конвертируются по словарю «код → курс к рублю» через чистую <see cref="RatesService.ConvertAmount"/>
/// (Settings, rates.py L86103): USDT приравнивается к USD, отсутствующая валюта → сумма пропускается.
/// Курсы — параметр функции: чтение кэша ratesCache (Ruling 7) остаётся за вызывающим (CardsService и
/// др.), модуль и функция не ходят в БД. Границы из null → диапазон без ограничений (True), как прототип.
/// </remarks>
public static class BudgetInRange
{
/// <summary>
/// Попадает ли хотя бы одна распознанная сумма в диапазон правил (rules.py L147173).
/// </summary>
/// <param name="amounts">Суммы из текста (<see cref="AmountParser.Parse"/>).</param>
/// <param name="budget">Бюджет правил колонки (валюта пуста → USD, как в прототипе L157).</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,54 @@
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Kanban.Application.ColumnRules;
/// <summary>
/// Слова-исключения колонки: veto, применяется ДО положительных правил (rules.py excluded_terms L230244, is_excluded L247248).
/// </summary>
/// <remarks>
/// Исключения — вето колонки: если в содержании сообщения (без ссылок и служебных хвостов,
/// <see cref="ContentNormalizer.ContentText"/>) встречается любое из них, карточка в колонку не попадает,
/// даже если все положительные условия совпали. Ветo учитывает ТОЛЬКО размещение (страховка
/// ContainerAccepts/route — rules.py board_accepts L251268); сам матчинг положительных критериев
/// (MatchText/Hits) exclude не видит, как в прототипе.
/// </remarks>
public static class ColumnExclusions
{
/// <summary>
/// Какие слова-исключения колонки есть в тексте (rules.py excluded_terms L230244).
/// </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>
/// Есть ли в тексте слово-исключение колонки (rules.py is_excluded L247248).
/// </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,265 @@
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Kanban.Application.ColumnRules;
/// <summary>
/// Матчинг текста сообщения по правилам колонки: направление/слова/стек/грейд/бюджет (rules.py match_text L176209,
/// score_text L212227, has_active_rules L322338).
/// </summary>
/// <remarks>
/// Колонка — набор опциональных фильтров; пустая группа не участвует. Режим <c>all</c> — совпасть должны
/// ВСЕ включённые группы, <c>any</c> — хотя бы одна (пустые группы не считаются совпавшими «сами по себе»,
/// иначе колонка с одним фильтром ловила бы всё). Все термины ищутся подстрокой в тексте, приведённом к
/// нижнему регистру и очищенном от ссылок (<see cref="ContentNormalizer.ContentText"/>); бюджет
/// проверяется по суммам исходного текста через <see cref="AmountParser"/> + <see cref="BudgetInRange"/>.
/// Исключения (exclude) здесь НЕ участвуют — это вето размещения (ColumnExclusions/ColumnRules.ContainerAccepts).
/// </remarks>
public static class ColumnMatcher
{
/// <summary>
/// Соответствует ли текст правилам колонки (rules.py match_text L176209).
/// </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 = HasBudget(rules.Budget);
bool pricesEnabled = HasBudget(rules.Prices);
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>
/// Число совпавших ТЕРМОВ правил (для выбора лучшей колонки; rules.py score_text L212227).
/// </summary>
/// <remarks>
/// В отличие от групп MatchText, здесь считается каждый совпавший терм: direction/keywords/stack — по
/// терму, grade — по каждому расширенному синониму (тег с двумя найденными алиасами даёт +2). Бюджет и
/// exclude в счёт не входят (как в прототипе — score нужен для сравнения колонок с совпавшими словами).
/// </remarks>
/// <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>
/// Есть ли в правилах хотя бы одна реально работающая группа фильтров (rules.py has_active_rules L322338).
/// </summary>
/// <remarks>
/// Нужно для ML и страховки: колонка с активными правилами раскладывается только самими правилами
/// (детерминированно); колонка без активных правил принимает любой текст. «Бюджет» активен только при
/// заданной границе (from/to), одна валюта без границ активной группой не считается.
/// </remarks>
/// <param name="rules">Правила колонки (null → False).</param>
/// <returns>True — есть непустой терм группы или бюджет с границей.</returns>
public static bool HasActiveRules(ContainerRulesDto? rules)
{
if (rules is null)
{
return false;
}
return HasAnyTerm(rules.Direction)
|| HasAnyTerm(rules.Keywords)
|| HasAnyTerm(rules.Stack)
|| HasAnyTerm(rules.Grade)
|| HasAnyTerm(rules.Levels)
|| HasAnyTerm(rules.Locations)
|| HasAnyTerm(rules.Types)
|| (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));
}
// Есть ли в списке непустой (после trim) терм.
// terms: Список термов группы.
// Возвращает: True — хотя бы один терм непустой.
private static bool HasAnyTerm(IReadOnlyList<string>? terms)
{
if (terms is null)
{
return false;
}
foreach (string? raw in terms)
{
if (!string.IsNullOrWhiteSpace(raw))
{
return true;
}
}
return false;
}
// Совпал ли хотя бы один терм (подстрока в тексте).
// 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;
}
// Приводит термы группы к нижнему регистру и убирает пустые (прототип match_text L185188).
// terms: Термы как сохранены.
// Возвращает: Непустые термы в нижнем регистре.
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;
}
// Активна ли бюджетная группа: объект есть и не «пустой» (прототип bool(budget) — {} выключен,
// {cur} без границ включён, но границ не задаёт — группа совпадает всегда).
// budget: Поле budget правил.
// Возвращает: True — группа бюджета участвует в матчинге.
private static bool HasBudget(BudgetRangeDto? budget)
{
return budget is not null
&& (budget.From is not null || budget.To is not null || !string.IsNullOrWhiteSpace(budget.Cur));
}
}
@@ -0,0 +1,97 @@
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Kanban.Application.ColumnRules;
/// <summary>
/// Чистые правила колонок — единая точка входа модуля (Ruling 2, Task 3; rules.py board_accepts L251268,
/// hits_for_board L311319, has_active_rules L322338, describe L341368).
/// </summary>
/// <remarks>
/// Все функции — чистые (без EF/HTTP/хранилища): правила передаются объектом <see cref="ContainerRulesDto"/>,
/// текст — строкой, курсы для бюджетной конвертации — словарём (загрузка кэша ratesCache за вызывающим,
/// Ruling 7). Соответствие прототипу: <see cref="ComputeHits"/> = hits_for_board (для колонок без активных
/// правил и null-правил — пустой список), <see cref="ContainerAccepts"/> = board_accepts (вето исключений →
/// колонка без активных правил принимает любой текст → иначе MatchText), <see cref="Describe"/> = describe.
/// Для служебных колонок (inbox/archive/trash, Ruling 2) правила не вычисляются: сервис карточек
/// вызывает ComputeHits только для колонок-досок (правила null/отсутствуют → []).
/// </remarks>
public static class ColumnRules
{
/// <summary>
/// Пропускает ли колонка этот текст (страховка «ИИ/ML не кладут в отфильтрованную колонку»,
/// rules.py board_accepts L251268).
/// </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>
/// Совпал ли текст с правилами колонки (обёртка над <see cref="ColumnMatcher.MatchText"/>).
/// </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>
/// Есть ли в правилах активная группа фильтров (rules.py has_active_rules L322338).
/// </summary>
/// <param name="rules">Правила колонки.</param>
/// <returns>True — правила реально фильтруют (см. <see cref="ColumnMatcher.HasActiveRules"/>).</returns>
public static bool HasActiveRules(ContainerRulesDto? rules)
{
return ColumnMatcher.HasActiveRules(rules);
}
/// <summary>
/// Совпавшие критерии правил для размещения карточки в колонку-доску (rules.py hits_for_board L311319).
/// </summary>
/// <param name="rules">Правила колонки-доски; null — доски нет/правил нет.</param>
/// <param name="text">Текст карточки для правил (source_msg или title — выбор за вызывающим, leads.py L167).</param>
/// <param name="rates">Курсы для конвертации бюджета.</param>
/// <returns>
/// Список совпавших критериев (MatchHitDto label/term/word); для пустых/null правил и колонок без
/// активных правил — [] (Ruling 2). Исключения (veto) в список не входят (прототип hits L271296).
/// </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 (обёртка над <see cref="RulesDescriber.Describe"/>).
/// </summary>
/// <param name="rules">Правила колонки.</param>
/// <returns>«все условия · стек: …» или «без правил (решает ИИ/ML)».</returns>
public static string Describe(ContainerRulesDto? rules)
{
return RulesDescriber.Describe(rules);
}
}
@@ -0,0 +1,34 @@
using System.Text.RegularExpressions;
namespace Deal.Modules.Kanban.Application.ColumnRules;
/// <summary>
/// Нормализация текста сообщения перед матчингом правил колонок (rules.py content_text L5154).
/// </summary>
/// <remarks>
/// Вырезает markdown-ссылки <c>[текст](url)</c> и голые URL: иначе фильтр ловит слова из трекерных
/// хвостов и служебных строк адресов (например, «desktop» в utm_medium=member_desktop) — в колонку
/// попадает мусор, не имеющий отношения к содержанию сообщения. Нормализатор вызывается внутри
/// matcher/hits/exclude-функций (как в прототипе) — вызывающий код передаёт «сырой» текст карточки.
/// Регистр не меняется: lower-преобразование выполняют функции сравнения (прототип content_text L5154).
/// </remarks>
public static class ContentNormalizer
{
// Markdown-ссылка [текст](url) (правила прототипа _MD_URL_RE L47).
private static readonly Regex MarkdownLink = new(@"\[[^\]]*\]\([^)\s]+\)");
// Голый URL http(s)://… или www.… (правила прототипа _RAW_URL_RE L48).
private static readonly Regex RawUrl = new(@"https?://[^\s<>""']+|www\.[^\s<>""']+");
/// <summary>
/// Возвращает текст с вырезанными ссылками (markdown- и голыми), прототип content_text L5154.
/// </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,61 @@
namespace Deal.Modules.Kanban.Application.ColumnRules;
/// <summary>
/// Синонимы грейдов/уровней правил колонки (rules.py _GRADE_ALIASES L2027, _grade_terms L3040).
/// </summary>
/// <remarks>
/// Тег из правил расширяется синонимами, чтобы «middle» находил и «mid», и «мидл», а «сеньор» —
/// senior и т.п. Неизвестный тег участвует как есть (в нижнем регистре) — это позволяет хранить в
/// группе grade произвольные слова (например, «middle+» ищется буквально). Списки — 1:1 с прототипом;
/// используются ColumnMatcher (группа grade) и MatchHitBuilder (word-совпадение грейда).
/// </remarks>
public static class GradeAliases
{
// Карта «тег → синонимы» в нижнем регистре (rules.py L2027).
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>
/// Расширяет теги правил синонимами (прототип _grade_terms L3040).
/// </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,195 @@
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Kanban.Application.ColumnRules;
/// <summary>
/// Список совпавших критериев правил — «почему карточка в колонке» (rules.py hits L271296, _budget_label L299308).
/// </summary>
/// <remarks>
/// Для каждой группы правил колонки находит совпавшие термины и возвращает их в формате MatchHitDto
/// (label/term/word?): direction → «Направление», keywords → «Слова», stack → «Стек», grade → «Грейд/уровень»
/// (term — тег как сохранён, word — фактически найденный синоним), budget → «Бюджет» (term — человекочитаемый
/// диапазон «от X до Y CUR»). Ключевые слова ищутся по всему тексту сообщения (включая стек, требования и
/// «будет плюсом»). Грейд — только первый найденный синоним тега; exclude в hits не входит (это вето
/// размещения), пустые правила → пустой список.
/// </remarks>
public static class MatchHitBuilder
{
// Метка группы «направление» (как в UI и прототипе).
private const string DirectionLabel = "Направление";
// Метка группы «ключевые слова».
private const string KeywordsLabel = "Слова";
// Метка группы «стек».
private const string StackLabel = "Стек";
// Метка группы «грейд/уровень».
private const string GradeLabel = "Грейд/уровень";
// Метка отдельной группы «уровень» (§6.3).
private const string LevelsLabel = "Уровень";
// Метка группы «локация» (§6.3).
private const string LocationsLabel = "Локация";
// Метка группы «тип» (§6.3).
private const string TypesLabel = "Тип";
// Метка группы «бюджет».
private const string BudgetLabel = "Бюджет";
// Метка отдельной группы «цена» (§6.3).
private const string PricesLabel = "Цена";
/// <summary>
/// Совпавшие критерии фильтра колонки (rules.py hits L271296).
/// </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));
// Грейд и уровень: по тегу — первый найденный синоним (rules.py L288292).
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);
// Бюджет/цена: один hit с человекочитаемым диапазоном, если сумма в границах (rules.py L293295).
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));
}
}
// Совпавшие термины текстовой группы (direction/keywords/stack) — прототип L283287.
// rawTerms: Термы как сохранены.
// label: Метка группы.
// lower: Текст в нижнем регистре.
// Возвращает: Hits по каждому совпавшему терму (term сохраняет регистр пользователя).
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);
}
}
}
// Человекочитаемый диапазон бюджета для term-а hit'а (rules.py _budget_label L299308).
// budget: Бюджет правил.
// Возвращает: «от X до Y CUR» / «до X CUR» / «от X CUR» / «бюджет».
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 "бюджет";
}
// Формат числа как python %g (до 6 значащих цифр, без «.0»): «1000», «1500.5».
// value: Число.
// Возвращает: Строка по инвариантной культуре.
private static string FormatAmount(double value)
{
return value.ToString("G6", System.Globalization.CultureInfo.InvariantCulture).Replace('E', 'e');
}
}
@@ -0,0 +1,124 @@
using System.Globalization;
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Kanban.Application.ColumnRules;
/// <summary>
/// Человекочитаемое описание правил колонки для note/подсказки (rules.py describe L341368).
/// </summary>
/// <remarks>
/// Формат 1:1 с прототипом: «все условия · направление: …; стек: …; слова: …; грейд: …; исключено: …;
/// бюджет: …» (режим any → «любое из условий · …»). Списки обрезаются (direction ≤6, остальные ≤8), бюджет
/// печатается границами «lo–hi CUR» с удалением пробелов у тире. Правил нет → «без правил (решает ИИ/ML)».
/// </remarks>
public static class RulesDescriber
{
/// <summary>
/// Описывает правила колонки (rules.py describe L341368).
/// </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);
}
// Текст колонки без правил (прототип L366).
private const string NoRulesText = "без правил (решает ИИ/ML)";
// Префикс режима «все условия» (прототип L367).
private const string AllModePrefix = "все условия";
// Префикс режима «любое из условий».
private const string AnyModePrefix = "любое из условий";
// Лимит термов направления в описании (прототип L350: direction[:6]).
private const int DirectionLimit = 6;
// Лимит термов остальных групп (прототип L352359: stack/kw/grade/exclude[:8]).
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(" ", ""));
}
// Соединяет первые N термов группы через «, » (прототип describe).
// terms: Термы группы.
// limit: Максимум термов.
// Возвращает: Строка «t1, t2, …».
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,58 @@
namespace Deal.Modules.Kanban.Application.ColumnRules;
/// <summary>
/// Синонимы типов заявки для группы <c>types</c> правил колонки и глобальных исключений (§6.3).
/// </summary>
/// <remarks>
/// Тег из правил расширяется синонимами, чтобы «vacancy» находил «вакансия»/«найм», «freelance» —
/// «заказ»/«проект», «announcement» — «объявление». Неизвестный тег участвует как есть (в нижнем
/// регистре) — можно хранить произвольные слова («срочно»). Используется <see cref="ColumnMatcher"/>
/// (группа types) и <c>MatchHitBuilder</c> (word-совпадение типа).
/// </remarks>
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,32 @@
namespace Deal.Modules.Kanban.Application;
/// <summary>
/// Реестр видов контейнеров (этап 9, T4): роль колонки в пространстве.
/// </summary>
/// <remarks>
/// <c>board</c> — пользовательская колонка-фильтр (создаёт пользователь/ИИ); <c>stage</c> — стадия
/// «Выбранных»; <c>service</c> — служебная зона (inbox/archive/trash); <c>terminal</c> — терминальная
/// стадия (finished/rejected). Вид контейнера влияет на показ и допустимые переходы.
/// </remarks>
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;
/// <summary>
/// Реестр пространств контейнеров (этап 9, T4): где показывается карточка.
/// </summary>
/// <remarks>
/// Пространство — свойство контейнера, не карточки. Дашборд (<c>dashboard</c>) — служебные зоны и
/// пользовательские колонки; «Выбранные» (<c>selected</c>) — каталог стадий. Карточка живёт в одном
/// пространстве: её контейнер однозначно определяет вид.
/// </remarks>
public static class ContainerSpaces
{
/// <summary>
/// Пространство дашборда (служебные зоны и пользовательские колонки-доски).
/// </summary>
public const string Dashboard = "dashboard";
/// <summary>
/// Пространство «Выбранные» (стадии работы над карточкой).
/// </summary>
public const string Selected = "selected";
}
@@ -0,0 +1,284 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application;
using Deal.Modules.Settings.Application.Models;
namespace Deal.Modules.Kanban.Application;
/// <summary>
/// Сервис контейнеров (колонок/стадий/зон) и состояния колонок (colState) — этап 9, T4.
/// </summary>
/// <remarks>
/// Приходит на смену BoardsService: Containers — единственный реестр колонок; сервис выполняет CRUD
/// (список по пространству, создание, патч, удаление с переносом карточек в inbox, reorder, приём
/// ИИ-предложений) и держит colState (KV-ключ <see cref="SettingsKeys.ColState"/>). Счётчики карточек
/// контейнера заполняются при чтении из реестра Cards. Чистый сервис модуля (без EF/HTTP).
/// </remarks>
/// <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>
/// Контейнеры пространства в порядке показа, со счётчиками карточек (этап 9, T4).
/// </summary>
/// <param name="space">Пространство (dashboard/selected) либо null — все контейнеры.</param>
/// <param name="ct">Токен отмены.</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>
/// <param name="ct">Токен отмены.</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>
/// Создаёт контейнер с дефолтами: order = MAX+1, цвет палитры, имя «Новая колонка» при пустом.
/// </summary>
/// <param name="create">Вход создания (имя обязательно; цвет/правила/пространство/вид — опциональны).</param>
/// <param name="ct">Токен отмены.</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>
/// Частичное обновление контейнера: меняются только не-null поля патча; rules/policy заменяются целиком.
/// </summary>
/// <param name="containerId">Id контейнера.</param>
/// <param name="patch">Изменения; null-поле означает «не менять».</param>
/// <param name="ct">Токен отмены.</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>
/// Принимает ИИ-предложение: снимает флаг suggested у контейнера.
/// </summary>
/// <param name="containerId">Id контейнера-предложения.</param>
/// <param name="ct">Токен отмены.</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>
/// <param name="ct">Токен отмены.</param>
/// <returns>Сколько карточек перенесено в inbox (ответ {ok, movedToInbox}).</returns>
public Task<int> DeleteAsync(string containerId, CancellationToken ct)
{
return store.DeleteContainerAsync(containerId, ct);
}
/// <summary>
/// Переставляет контейнеры пространства: позиции 0..N-1 в порядке списка.
/// </summary>
/// <param name="space">Пространство переставляемых контейнеров.</param>
/// <param name="containerIds">Id контейнеров в новом порядке.</param>
/// <param name="ct">Токен отмены.</param>
public Task ReorderAsync(string space, IReadOnlyList<string> containerIds, CancellationToken ct)
{
return store.ReorderContainersAsync(space, containerIds, ct);
}
// ── Состояние колонок (colState, KV через ISettingsStore) ────────────
/// <summary>
/// Весь объект colState: словарь «контейнер → состояние».
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <returns>Состояния всех колонок; пусто — настройка не сохранена/повреждена.</returns>
public async Task<IReadOnlyDictionary<string, ColumnStateDto>> GetColStateAsync(CancellationToken ct)
{
return await ReadColStateAsync(ct);
}
/// <summary>
/// PATCH состояния одной колонки: merge патча в текущее значение и запись всего объекта.
/// </summary>
/// <param name="colId">Id контейнера.</param>
/// <param name="patch">Изменяемые поля состояния.</param>
/// <param name="ct">Токен отмены.</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,93 @@
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application;
using Deal.Modules.Settings.Application.Models;
namespace Deal.Modules.Kanban.Application;
/// <summary>
/// Пересчёт конверсий бюджетов карточек при смене курсов/целевой валюты (Ruling 7, план Task 12).
/// Реализация порта модуля Settings <see cref="IRatesChangedListener"/> (регистрация — AddKanbanModule).
/// </summary>
/// <remarks>
/// Чистый сервис модуля (без EF/HTTP), повторяет <c>rates.py recompute_conversions</c> (L106130). Полный
/// пересчёт: кандидаты <see cref="ICardStore.ListCardsForConversionAsync"/> (budget_cur != '' и колонка не
/// archive/trash — фильтрует хранилище), курсы — из кэша ratesCache
/// (<see cref="SettingsKeys.RatesCache"/> через типизированный снимок настроек TenantSettingsSnapshot, C30;
/// конвертация — <see cref="RatesService.ConvertAmount"/>, USDT=USD), целевая валюта — настройка
/// targetCurrency. conversionOn=false → 0 без изменений (rates.py L112113). Карточка, нижняя граница которой
/// не конвертируется (нет курса валюты либо budget_from не задан), пропускается ЦЕЛИКОМ — conv-поля не
/// трогаются (rates.py L123124: cf is None → continue). Нет кэша курсов → пересчёт не выполняется (мягкая
/// семантика, дефолт-мок НЕ подставляется — по прототипу). Повторный вызов идемпотентен: пересчитывает по
/// текущим настройкам/курсам всё заново.
/// </remarks>
/// <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>
/// <param name="ct">Токен отмены.</param>
/// <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
}
// Нижняя граница; верхняя — как есть, при отсутствии — равна нижней (одна сумма/«от X», L121122).
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,126 @@
namespace Deal.Modules.Kanban.Application;
/// <summary>
/// Чистый детектор типа вложения карточки (Ruling 4; 1:1 files.py detect L3145).
/// </summary>
/// <remarks>
/// Правила 1:1 с прототипом (files.py L1328, L3145): MIME-префикс <c>image/|video/|audio/</c> →
/// соответствующий kind; иначе — расширение имени файла по наборам KIND_BY_EXT (archive/document);
/// неопознанное → <c>other</c> («Файл»). kind/label — wire-значения CardFileDto и иконок
/// фронта: image/«Изображение», video/«Видео», audio/«Аудио», archive/«Архив», document/«Документ»,
/// other/«Файл». MIME сравнивается без учёта регистра (HTTP content-type регистронезависим), расширение
/// приводится к нижнему регистру (как <c>.lower()</c> в python). Детектор чистый и детерминированный:
/// «магия» (содержимое файла) не читается — тип даёт браузерный content-type и имя файла.
/// Потребитель — CardsService при добавлении файла.
/// </remarks>
public static class FileKindDetector
{
/// <summary>
/// Категория «изображение» (files.py KIND_BY_EXT: png/jpg/jpeg/gif/webp/svg/bmp/avif/heic).
/// </summary>
public const string ImageKind = "image";
/// <summary>
/// Категория «видео» (mp4/mov/avi/mkv/webm/m4v).
/// </summary>
public const string VideoKind = "video";
/// <summary>
/// Категория «аудио» (mp3/wav/ogg/m4a/flac/aac).
/// </summary>
public const string AudioKind = "audio";
/// <summary>
/// Категория «архив» (zip/rar/7z/tar/gz/bz2).
/// </summary>
public const string ArchiveKind = "archive";
/// <summary>
/// Категория «документ» (pdf/doc/docx/xls/xlsx/csv/txt/md/rtf/ppt/pptx/odt/ods).
/// </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 = "Файл";
// Расширения по категориям (1:1 files.py KIND_BY_EXT L1319); наборы не пересекаются, порядок обхода не важен.
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" },
};
// Метки категорий (1:1 files.py KIND_LABELS L2128).
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 и расширению имени (files.py detect L3145).
/// </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]);
}
// Расширение имени файла после последней точки, в нижнем регистре (1:1 python: (name.split(".")[-1]).lower()).
// name: Имя файла.
// Возвращает: Расширение без точки; пустая строка, если точки в имени нет (или имя заканчивается точкой).
private static string GetExtension(string name)
{
int lastDot = name.LastIndexOf('.');
return lastDot < 0
? string.Empty
: name.Substring(lastDot + 1).ToLowerInvariant();
}
}
@@ -0,0 +1,402 @@
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Kanban.Application;
/// <summary>
/// Единый порт хранилища карточек и контейнеров (таблицы Cards/Containers/LeadComments/CardMoves тенанта;
/// жёсткое удаление карточки дополнительно чистит строки DedupEntries — Ruling 3), Ruling 1.
/// </summary>
/// <remarks>
/// Объявлен в модуле Kanban — владельце единой сущности карточки (этап 9); реализация — EF-адаптер
/// <c>KanbanStore</c> в Deal.Infrastructure (регистрация в AddDealPersistence). Порт покрывает оба
/// пространства одной таблицы Cards: дашборд (служебные зоны/доски) и «Выбранные» (контейнеры-стадии),
/// поэтому отдельный порт карточек «Выбранных» упразднён. Порт оперирует DTO модуля; маппинг
/// DTO ↔ строки (включая JSON-поля и human-метки времени, Ruling 10) выполняет адаптер вручную
/// (эталон SettingsStore.cs). Набор методов — ровно тот, что нужен сервису карточек (YAGNI):
/// контейнеры, карточки и переносы, комментарии, журнал CardMoves, кандидаты правил хранения (Ruling 8),
/// пересчёт конверсий (Ruling 7) и выборка «Неразобранного» для эвристики (Ruling 3).
/// Id записей генерирует модуль (Ruling 12: короткие префиксные id через KanbanIdPrefixes) и передаёт
/// готовыми — хранилище id не создаёт. Чтения — AsNoTracking; сортировки (received_at DESC и т.п.) —
/// обязанность адаптера. Удаление карточки навсегда (DeleteForeverAsync/ClearColAsync/PurgeAsync)
/// снимает и «мягкие» dedup-ссылки (Ruling 3): строки таблицы DedupEntries чужого модуля адаптер удаляет
/// напрямую тем же TenantDbContext, цикла Kanban → Pipeline не возникает (Kanban о Pipeline не знает).
/// </remarks>
public interface ICardStore
{
// ── Контейнеры (колонки/стадии/зоны) ─────────────────────────────────
/// <summary>
/// Контейнеры пространства в порядке показа: ORDER BY space, position (этап 9, T4).
/// </summary>
/// <param name="space">Пространство (dashboard/selected) либо null — все контейнеры.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Контейнеры (DTO, счётчики заполняет сервис); пусто — контейнеров нет.</returns>
public Task<IReadOnlyList<ContainerDto>> ListContainersAsync(string? space, CancellationToken ct);
/// <summary>
/// Один контейнер по id (PATCH 404-семантика, переносы и валидация колонок).
/// </summary>
/// <param name="containerId">Id контейнера (inbox/archive/trash/стадия/<c>b_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Контейнер или null, если строки нет.</returns>
public Task<ContainerDto?> GetContainerAsync(string containerId, CancellationToken ct);
/// <summary>
/// Создаёт контейнер (id/позицию/цвет/дефолты вычисляет ContainersService).
/// </summary>
/// <param name="container">Полный контейнер для вставки, включая <see cref="ContainerDto.Order"/>.</param>
/// <param name="ct">Токен отмены.</param>
public Task CreateContainerAsync(ContainerDto container, CancellationToken ct);
/// <summary>
/// Обновляет контейнер целиком (сервис читает Get + применяет ContainerPatchDto).
/// </summary>
/// <param name="container">Контейнер с изменёнными полями (идентифицируется по Id).</param>
/// <param name="ct">Токен отмены.</param>
public Task UpdateContainerAsync(ContainerDto container, CancellationToken ct);
/// <summary>
/// Удаляет контейнер, предварительно перенося его карточки в «Неразобранное».
/// </summary>
/// <param name="containerId">Id удаляемого контейнера.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Сколько карточек перенесено в inbox (0 — контейнера/карточек не было; ответ {ok, movedToInbox}).</returns>
public Task<int> DeleteContainerAsync(string containerId, CancellationToken ct);
/// <summary>
/// Переставляет контейнеры пространства: позиции 0..N-1 в порядке списка.
/// </summary>
/// <param name="space">Пространство переставляемых контейнеров.</param>
/// <param name="containerIds">Id контейнеров в новом порядке.</param>
/// <param name="ct">Токен отмены.</param>
public Task ReorderContainersAsync(string space, IReadOnlyList<string> containerIds, CancellationToken ct);
// ── Карточки ──────────────────────────────────────────────────────────
/// <summary>
/// Карточки колонки или всех колонок дашборда, ORDER BY received_at DESC (list_leads L151156).
/// </summary>
/// <param name="query">Фильтр: Col — конкретная колонка либо null (все колонки дашборда).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Полные карточки (DTO): JSON-поля разобраны, комментарии приложены, time посчитан.</returns>
public Task<IReadOnlyList<CardDto>> ListCardsAsync(CardsQuery query, CancellationToken ct);
/// <summary>
/// Карточки пространства «Выбранные», ORDER BY updated_at DESC (list_cards projects.py L5863).
/// </summary>
/// <param name="containerId">Фильтр по контейнеру-стадии (id каталога <see cref="Deal.Modules.Cards.Application.CardsDefaultContainers"/>); null — все стадии.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Полные карточки (DTO): JSON-поля разобраны, комментарии приложены, time посчитан.</returns>
public Task<IReadOnlyList<CardDto>> ListSelectedCardsAsync(string? containerId, CancellationToken ct);
/// <summary>
/// Полнотекстовый поиск карточек (FTS + LIKE-дополнение; leads.py search L509551, Ruling 6/Task 12).
/// </summary>
/// <param name="q">Поисковый запрос (уже Trim+lowercase, как в отсев-поиске; короче 2 символов сервис не пропускает).</param>
/// <param name="limit">Ограничение результата (эндпоинт шлёт 12, Ruling 6).</param>
/// <param name="ct">Токен отмены.</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 (GET /api/cards/{id}, а также перечитывание после переноса).
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Карточка или null, если строки нет.</returns>
public Task<CardDto?> GetCardAsync(string cardId, CancellationToken ct);
/// <summary>
/// Карточка по исходному сообщению (диалог + id сообщения) — ручная разметка ML (§8).
/// </summary>
/// <remarks>Если сообщение давало несколько карточек (повторная разметка/пересоздание), берётся самая
/// свежая по ReceivedAt. Пустой dialogId — null (нечем искать).</remarks>
/// <param name="dialogId">Id диалога-источника сообщения.</param>
/// <param name="msgId">Id исходного сообщения в Telegram.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Карточка или null, если сообщение не становилось карточкой.</returns>
public Task<CardDto?> GetCardBySourceAsync(string dialogId, long msgId, CancellationToken ct);
/// <summary>
/// Создаёт карточку из готового снимка (пайплайн этапа 4, ручное создание; CreatedAt — UTC-now).
/// </summary>
/// <param name="snapshot">Полное состояние новой карточки (id сгенерирован модулем, см. CardSnapshot).</param>
/// <param name="ct">Токен отмены.</param>
public Task AddCardAsync(CardSnapshot snapshot, CancellationToken ct);
/// <summary>
/// Точечная правка полей по присутствующим в патче + bump UpdatedAt (patch_card projects.py L159187).
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="patch">Изменяемые поля (null — поле не меняется; JSON-поля — полная замена).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>True — строка обновлена; false — карточки нет (404-семантика сервиса).</returns>
public Task<bool> PatchCardAsync(string cardId, CardPatch patch, CancellationToken ct);
/// <summary>
/// Атомарно дописывает ссылку в JSON-массив links ОДНИМ UPDATE (jsonb-append) + bump UpdatedAt.
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="link">Готовая ссылка {id,name,url} (id сгенерирован модулем).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>True — строка обновлена; false — карточки нет (404-семантика сервиса).</returns>
public Task<bool> AddLinkAsync(string cardId, CardLinkDto link, CancellationToken ct);
/// <summary>
/// Атомарно убирает из JSON-массива links элемент с указанным id ОДНИМ UPDATE (jsonb-фильтрация) + bump UpdatedAt.
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="linkId">Id удаляемой ссылки (<c>pl_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>True — строка обновлена; false — карточки нет (404-семантика сервиса).</returns>
public Task<bool> RemoveLinkAsync(string cardId, string linkId, CancellationToken ct);
/// <summary>
/// Атомарно дописывает метаданные файла в JSON-массив files ОДНИМ UPDATE (jsonb-append) + bump UpdatedAt.
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="file">Готовые метаданные {id,name,size,kind,label,objectKey} (id сгенерирован модулем).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>True — строка обновлена; false — карточки нет (404-семантика сервиса).</returns>
public Task<bool> AddFileAsync(string cardId, CardFileDto file, CancellationToken ct);
/// <summary>
/// Атомарно убирает из JSON-массива files элемент с указанным id ОДНИМ UPDATE (jsonb-фильтрация) + bump UpdatedAt.
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="fileId">Id удаляемой записи файла (<c>pf_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>True — строка обновлена; false — карточки нет (404-семантика сервиса).</returns>
public Task<bool> RemoveFileAsync(string cardId, string fileId, CancellationToken ct);
/// <summary>
/// Смена контейнера-стадии: один UPDATE (col, reminder_at=NULL, reminder_fired=false, updated_at=atMs)
/// + перезапись history-массива с добавленной записью (move_stage projects.py L202216; Ruling 7).
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="containerId">Новый контейнер-стадия (валидирует сервис каталогом <see cref="Deal.Modules.Cards.Application.CardsDefaultContainers"/>).</param>
/// <param name="historyEntry">Готовая запись истории {id,at,stage} (id сгенерирован модулем).</param>
/// <param name="atMs">Время переноса, epoch-ms (пишется в updated_at и в запись истории).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>True — строка обновлена; false — карточки нет (404-семантика сервиса).</returns>
public Task<bool> MoveCardStageAsync(string cardId, string containerId, CardHistoryDto historyEntry, long atMs, CancellationToken ct);
/// <summary>
/// Устанавливает напоминание: reminder_at, reminder_fired=false + bump UpdatedAt (set_reminder projects.py L236243).
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="atMs">Время напоминания, epoch-ms.</param>
/// <param name="ct">Токен отмены.</param>
public Task SetReminderAsync(string cardId, long atMs, CancellationToken ct);
/// <summary>
/// Снимает напоминание: reminder_at=NULL, reminder_fired=false (clear_reminder projects.py L246247).
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="ct">Токен отмены.</param>
public Task ClearReminderAsync(string cardId, CancellationToken ct);
/// <summary>
/// Полная ручная очистка контейнера-стадии: DELETE строк (clear_stage projects.py L223231).
/// </summary>
/// <param name="containerId">Очищаемая стадия (допустимость — только <c>rejected</c> — валидирует сервис).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Сколько карточек удалено (0 — стадия пуста).</returns>
public Task<int> ClearStageAsync(string containerId, CancellationToken ct);
/// <summary>
/// Наступившие напоминания стадии hold: ReminderAt ≤ now, ReminderFired=false, ORDER BY ReminderAt (check_reminders projects.py L270275).
/// </summary>
/// <param name="now">Текущий момент (UTC) для сравнения с ReminderAt.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Due-строки {id,title,containerId} в порядке наступления; пусто — сработавших нет.</returns>
public Task<IReadOnlyList<CardReminderDueDto>> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct);
/// <summary>
/// Помечает due-карточки сработавшими: ReminderFired=true по списку id (check_reminders projects.py L277278).
/// </summary>
/// <param name="cardIds">Id карточек, чьи напоминания «выстрелили».</param>
/// <param name="ct">Токен отмены.</param>
public Task MarkRemindersFiredAsync(IReadOnlyList<string> cardIds, CancellationToken ct);
/// <summary>
/// Очищает протухшие напоминания при выключенной настройке: ReminderAt=NULL WHERE ReminderAt ≤ now (check_reminders L266269).
/// </summary>
/// <param name="now">Текущий момент (UTC).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Сколько строк очищено.</returns>
public Task<int> ClearExpiredRemindersAsync(DateTimeOffset now, CancellationToken ct);
/// <summary>
/// Меняет колонку/состояние карточки (move/trash/restore/автоархив) — см. CardColumnUpdateDto.
/// </summary>
/// <param name="update">Новое состояние колонки карточки (matchHits пересчитан в модуле, Ruling 2).</param>
/// <param name="ct">Токен отмены.</param>
public Task UpdateColumnAsync(CardColumnUpdateDto update, CancellationToken ct);
/// <summary>
/// Применяет результат ручной переклассификации к существующей карточке ОДНИМ обновлением
/// (leads.py reclassify_lead L346367): тип/заголовок/суть/стек/бюджет/конверсия/контакты/matchHits + колонка.
/// </summary>
/// <param name="update">Поля классификации (полная замена; см. CardReclassificationDto).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>True — строка обновлена; false — карточки нет (404-семантика сервиса).</returns>
public Task<bool> ApplyReclassificationAsync(CardReclassificationDto update, CancellationToken ct);
/// <summary>
/// Снимает флаг «новое»: с одной карточки (cardId), с колонки (col) или со всех (оба null) — leads.py mark_seen L250256.
/// </summary>
/// <param name="cardId">Id карточки либо null.</param>
/// <param name="col">Колонка либо null.</param>
/// <param name="ct">Токен отмены.</param>
public Task UpdateSeenAsync(string? cardId, string? col, CancellationToken ct);
/// <summary>
/// Удаляет карточку навсегда: Cards + комментарии (FK cascade) + строки дедупа карточки
/// (DedupEntries WHERE LeadId=?, Ruling 3); журнал/outbox не трогает (leads.py _hard_delete L225234).
/// </summary>
/// <param name="cardId">Id карточки.</param>
/// <param name="ct">Токен отмены.</param>
public Task DeleteForeverAsync(string cardId, CancellationToken ct);
/// <summary>
/// Полная очистка служебной колонки (только trash|archive — валидирует сервис), leads.py clear_col L237247;
/// строки дедупа удаляемых карточек чистятся вместе с ними (Ruling 3).
/// </summary>
/// <param name="col">Очищаемая колонка (trash/archive).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Сколько карточек удалено (0 — колонка пуста).</returns>
public Task<int> ClearColAsync(string col, CancellationToken ct);
/// <summary>
/// Счётчики карточек по колонкам (count + new) для GET /api/cards/counts (leads.py counts L268279).
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <returns>Словарь col → {count, new}; ключи — только колонки с карточками.</returns>
public Task<IReadOnlyDictionary<string, CardColumnCountDto>> CountCardsByColAsync(CancellationToken ct);
// ── Комментарии (LeadComments) ────────────────────────────────────────
/// <summary>
/// Комментарии карточки (для ответа add-comment и карточки; сортировка по времени добавления).
/// </summary>
/// <param name="cardId">Id карточки.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Комментарии (time — human-метка от CreatedAt); пусто — комментариев нет.</returns>
public Task<IReadOnlyList<CardCommentDto>> ListCommentsAsync(string cardId, CancellationToken ct);
/// <summary>
/// Добавляет комментарий (leads.py add_comment L259265; CreatedAt — UTC-now).
/// </summary>
/// <param name="commentId">Готовый id (<c>cm_...</c>, генерирует модуль).</param>
/// <param name="cardId">Id карточки.</param>
/// <param name="by">Автор («Вы» — свои комментарии).</param>
/// <param name="text">Текст (непустой — валидирует сервис, 400 «Пустой комментарий»).</param>
/// <param name="ct">Токен отмены.</param>
public Task AddCommentAsync(string commentId, string cardId, string by, string text, CancellationToken ct);
// ── Журнал действий (CardMoves = learning_log) ────────────────────────
/// <summary>
/// Пишет строку журнала действия пользователя (move/trash/restore/comment) — leads.py _log_learning L4044.
/// </summary>
/// <param name="move">Запись журнала (id <c>lm_...</c> сгенерирован модулем).</param>
/// <param name="ct">Токен отмены.</param>
public Task AddMoveAsync(CardMoveDto move, CancellationToken ct);
/// <summary>
/// Число записей журнала = счётчик learning (Ruling 4, Task 5 StatusAsync).
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <returns>Количество строк CardMoves.</returns>
public Task<int> CountMovesAsync(CancellationToken ct);
/// <summary>
/// Свежие примеры разметки пользователя для few-shot ИИ-классификации (pipeline.py _learning_examples L201215).
/// </summary>
/// <remarks>
/// Join журнала CardMoves с карточками (Cards.SourceMsg): действия move/restore, цель не служебная
/// (trash/archive), исходный текст непустой; ORDER BY created_at DESC, ≤ limit записей. Используется
/// контекст-билдером классификации (план Task 15, Ruling 5) — текст примера дополнительно режется
/// вызывающим до 500 кодовых точек (python L214).
/// </remarks>
/// <param name="limit">Максимум примеров (python L201: 8).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Примеры «текст → колонка», свежие первыми; пусто — истории разметки нет.</returns>
public Task<IReadOnlyList<AiMarkupExampleDto>> GetAiMarkupExamplesAsync(int limit, CancellationToken ct);
// ── Правила хранения (тик, Ruling 8) ──────────────────────────────────
/// <summary>
/// Кандидаты на автоархив: карточки досок и «Неразобранного» со ReceivedAt старше срока (tick_storage L462467).
/// </summary>
/// <param name="receivedBeforeUtc">Граница: received_at &lt; now archiveAfterDays.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Id карточек-кандидатов (колонка при архивации пишется archive через UpdateColumnAsync).</returns>
public Task<IReadOnlyList<string>> ListArchiveCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct);
/// <summary>
/// Архивирует пачку карточек ОДНИМ UPDATE: col=archive, is_new=false, archived_at=archivedAt,
/// matchHits — пустой массив (тик tick_storage L462473; batch-замена по-карточных UpdateColumnAsync,
/// PrevCol при архивации не трогается — Ruling 8).
/// </summary>
/// <param name="cardIds">Id карточек-кандидатов (список из ListArchiveCandidatesAsync).</param>
/// <param name="archivedAt">Момент архивации (один «now» тика).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Сколько карточек реально архивировано (0 — кандидатов не было/уже не в рабочих колонках).</returns>
public Task<int> ArchiveAsync(IReadOnlyList<string> cardIds, DateTimeOffset archivedAt, CancellationToken ct);
/// <summary>
/// Кандидаты на очистку архива: col='archive' и ArchivedAt старше срока (tick_storage L475478).
/// </summary>
/// <param name="archivedBeforeUtc">Граница: archived_at &lt; now archiveClearDays.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Id карточек для жёсткого удаления.</returns>
public Task<IReadOnlyList<string>> ListExpiredArchiveCandidatesAsync(DateTimeOffset archivedBeforeUtc, CancellationToken ct);
/// <summary>
/// Кандидаты на очистку корзины: col='trash' и ReceivedAt старше срока (tick_storage L480483).
/// </summary>
/// <param name="receivedBeforeUtc">Граница: received_at &lt; now trashClearDays.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Id карточек для жёсткого удаления.</returns>
public Task<IReadOnlyList<string>> ListTrashCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct);
/// <summary>
/// Жёстко удаляет пачку карточек: Cards + комментарии (FK cascade) + строки дедупа карточек
/// (DedupEntries WHERE LeadId IN …, Ruling 3) — для очисток тика и clear-col (Ruling 8).
/// </summary>
/// <param name="cardIds">Id карточек на удаление.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Сколько карточек удалено.</returns>
public Task<int> PurgeAsync(IReadOnlyList<string> cardIds, CancellationToken ct);
// ── Пересчёт конверсий (Ruling 7, Task 12) ─────────────────────────────
/// <summary>
/// Карточки для пересчёта ConvFrom/ConvTo/ConvCur: budgetCur непуст и col NOT IN (archive, trash) — recompute_conversions L106130.
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <returns>Карточки с бюджетом (используются Budget/бюджетные поля; служебные колонки исключены).</returns>
public Task<IReadOnlyList<CardDto>> ListCardsForConversionAsync(CancellationToken ct);
/// <summary>
/// Записывает пересчитанную конверсию бюджета карточки (обновляет только conv-поля).
/// </summary>
/// <param name="cardId">Id карточки.</param>
/// <param name="convFrom">Сконвертированная нижняя граница, либо null.</param>
/// <param name="convTo">Сконвертированная верхняя граница, либо null.</param>
/// <param name="convCur">Валюта конверсии (целевая валюта тенанта); пусто — конверсия снята.</param>
/// <param name="ct">Токен отмены.</param>
public Task UpdateConversionAsync(string cardId, double? convFrom, double? convTo, string convCur, CancellationToken ct);
// ── Эвристика ИИ-предложений (Ruling 3, Task 14) ───────────────────────
/// <summary>
/// Карточки «Неразобранного» с исходным текстом — вход эвристики suggest (suggest.py, Ruling 3).
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <returns>Карточки col='inbox' с непустым SourceMsg (частотные темы считаются по source_msg).</returns>
public Task<IReadOnlyList<CardDto>> ListInboxWithSourceAsync(CancellationToken ct);
}
@@ -0,0 +1,66 @@
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Kanban.Application;
/// <summary>
/// Порт хранилища обучения ML: очередь MlOutbox + счётчик журнала CardMoves (Ruling 4, Task 5).
/// </summary>
/// <remarks>
/// Объявлен в модуле Kanban (чистый: без EF) и потребляется интеграционным адаптером
/// <c>LocalMlClient</c> (Deal.Infrastructure), чтобы тот оставался unit-чистым (план Task 5:
/// «подсчёты вынести за чистый порт IMlLearningCounters (модуль Kanban)»; состав порта — счётчики
/// learning/outbox плюс запись/очистка очереди — финальное решение исполнителя). Реализация —
/// EF-адаптер над таблицами CardMoves/MlOutbox схемы тенанта, регистрируется в AddDealPersistence.
/// Id строки очереди (<c>mle_...</c>) генерирует вызывающий (Ruling 12); CreatedAt проставляет
/// хранилище (UTC-now, как у CardMoves/комментариев).
/// </remarks>
public interface IMlLearningStore
{
/// <summary>
/// Число записей журнала обучения = счётчик learning: count(CardMoves) (ml_client.snapshot L144).
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <returns>Количество строк журнала CardMoves.</returns>
public Task<int> CountLearningAsync(CancellationToken ct);
/// <summary>
/// Число строк очереди обучения = счётчик outbox: count(MlOutbox) (ml_client.outbox_len L5253).
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <returns>Количество строк очереди MlOutbox.</returns>
public Task<int> CountOutboxAsync(CancellationToken ct);
/// <summary>
/// Пишет строку очереди обучения (ml_client.push L4049): текст уже trim-нут и обрезан до 6000 вызывающим.
/// </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>
/// <param name="ct">Токен отмены.</param>
public Task AddOutboxAsync(string id, string text, string label, double delta, CancellationToken ct);
/// <summary>
/// Полная очистка очереди обучения: DELETE FROM MlOutbox (ml_client.reset_model L122). Журнал CardMoves не трогается.
/// </summary>
/// <param name="ct">Токен отмены.</param>
public Task ClearOutboxAsync(CancellationToken ct);
/// <summary>
/// Забирает следующую порцию очереди обучения: ORDER BY created_at LIMIT N (ml_client.flush_outbox L6669).
/// </summary>
/// <remarks>Строки НЕ удаляются — выборка и удаление разделены: вызывающий (MlOutboxFlushScheduler, план
/// Task 16) отправляет батч в ml-service и удаляет строки только после успеха (Ruling 6).</remarks>
/// <param name="limit">Размер порции (флашер шлёт по 10 строк за батч).</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>До <paramref name="limit"/> строк очереди в порядке created_at.</returns>
public Task<IReadOnlyList<MlOutboxEntryDto>> TakeOutboxBatchAsync(int limit, CancellationToken ct);
/// <summary>
/// Удаляет отправленные строки очереди по id (ml_client.flush_outbox L79 — DELETE после успеха батча).
/// </summary>
/// <param name="ids">Id строк, успешно отправленных в ML-сервис.</param>
/// <param name="ct">Токен отмены.</param>
/// <returns>Задача завершается после удаления строк.</returns>
public Task DeleteOutboxAsync(IReadOnlyCollection<string> ids, CancellationToken ct);
}
@@ -0,0 +1,28 @@
namespace Deal.Modules.Kanban.Application;
/// <summary>
/// Реестр служебных (не-доски) колонок канбана (Ruling 1, constants.py SERVICE_COLS).
/// </summary>
/// <remarks>
/// Значения — фиксированные строки JSON/колонки Cards.Col: <c>inbox|archive|trash</c>.
/// Доски (<c>b_...</c>) в реестр не входят: их id хранятся в таблице Containers, а существование
/// колонки-доски приложение валидирует по хранилищу (KanbanStore.GetContainerAsync).
/// Прототип: <c>backend/app/constants.py</c> — SERVICE_COLS; api-map §4.4 L304.
/// </remarks>
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,57 @@
using Deal.Modules.Cards.Application;
namespace Deal.Modules.Kanban.Application;
/// <summary>
/// Реестр префиксов коротких id модуля Kanban (Ruling 12, прототип store.uid).
/// </summary>
/// <remarks>
/// Id-генерация — в модуле (не GUID: прототип и фронт требуют коротких ключей в JSON);
/// хранилище (Task 4) получает уже готовые id и только сохраняет их.
/// Префиксы соответствуют владельцам данных карточки: Contеinerы=<c>b_</c>, Cards=<c>c_</c>,
/// LeadComments=<c>cm_</c>, CardMoves=<c>lm_</c>, MlOutbox=<c>mle_</c>; элементы JSON-массивов
/// карточки — ссылки <c>pl_</c>, файлы <c>pf_</c>, записи истории <c>h_</c>.
/// Генератор (<c>PrefixId</c> + случайный hex) добавляется задачей 7.
/// </remarks>
public static class KanbanIdPrefixes
{
/// <summary>
/// Префикс id контейнера-доски (kind=board единого реестра Containers).
/// </summary>
public const string Board = "b_";
/// <summary>
/// Префикс id карточки (единый для всех дашбордов; таблица Cards).
/// </summary>
public const string Card = CardIds.CardPrefix;
/// <summary>
/// Префикс id комментария карточки (таблица LeadComments).
/// </summary>
public const string Comment = "cm_";
/// <summary>
/// Префикс id записи журнала действий (таблица CardMoves = learning_log).
/// </summary>
public const string CardMove = "lm_";
/// <summary>
/// Префикс id ссылки карточки (элемент массива links).
/// </summary>
public const string Link = "pl_";
/// <summary>
/// Префикс id файла карточки (элемент массива files).
/// </summary>
public const string File = "pf_";
/// <summary>
/// Префикс id записи истории движения (элемент массива history; projects.py store.uid("h_")).
/// </summary>
public const string History = "h_";
/// <summary>
/// Префикс id строки очереди обучения (таблица MlOutbox).
/// </summary>
public const string MlOutbox = "mle_";
}
@@ -0,0 +1,41 @@
using Deal.Modules.Settings.Application;
using Microsoft.Extensions.DependencyInjection;
namespace Deal.Modules.Kanban.Application;
/// <summary>
/// DI-регистрация модуля Kanban. Паттерн «port &amp; adapter» (Ruling 12).
/// </summary>
/// <remarks>
/// Регистрируются только сервисы модуля. Порт-адаптеры (ICardStore → KanbanStore) реализованы в
/// Deal.Infrastructure и регистрируются там (AddDealPersistence); IColumnSuggester → LocalColumnSuggester —
/// в AddDealIntegrations (Ruling 12) — модуль не знает про EF и HTTP. Зависимости модуля —
/// Deal.Modules.Settings (порт ISettingsStore: настройки хранения/colState/курсы) и Deal.Contracts
/// (IMlClient: обучающие сигналы ML, Task 5); реверс-зависимостей нет (Global Constraints).
/// </remarks>
public static class KanbanModuleRegistrar
{
/// <summary>
/// Регистрирует сервисы модуля Kanban в контейнере.
/// </summary>
/// <param name="services">Коллекция сервисов.</param>
/// <returns>Коллекция сервисов для цепочки вызовов.</returns>
/// <remarks>
/// Здесь появляются scoped-сервисы модуля по мере их создания: ContainersService (Task 6), CardsService
/// (Task 7), StorageTickService (Task 10) и далее ConversionRecomputer (Task 12) с
/// <c>AddScoped&lt;IRatesChangedListener, ConversionRecomputer&gt;()</c> (Ruling 7). Вызывается из
/// Program.cs (AddKanbanModule, Task 4) после AddDealPersistence. Сервисы scoped, потому что их
/// зависимости (ICardStore/ISettingsStore) живут в рамках tenant-запроса (EF-контекст).
/// </remarks>
public static IServiceCollection AddKanbanModule(this IServiceCollection services)
{
services.AddScoped<ContainersService>();
services.AddScoped<CardsService>();
services.AddScoped<StorageTickService>();
// Порт модуля Settings реализует ConversionRecomputer (Ruling 7, Task 12): RatesService/SettingsService
// оповещают его после записи кэша курсов / смены targetCurrency|conversionOn (список может быть пуст).
services.AddScoped<IRatesChangedListener, ConversionRecomputer>();
return services;
}
}
@@ -0,0 +1,15 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Результат добавления комментария — CardsService.AddCommentAsync (add_comment L259265, dashboard_routes L238242).
/// </summary>
/// <remarks>
/// Три исхода для эндпоинта (Task 8): <see cref="Error"/> — текст 400 «Пустой комментарий»;
/// <see cref="Comments"/> == null при <see cref="Error"/> == null — карточки нет (эндпоинт отвечает 404
/// «Карточка не найдена»); иначе <see cref="Comments"/> — полный список комментариев карточки после
/// добавления (ответ {comments: [...]} — фронт затирает массив карточки ответом, store.js addComment
/// L924–933). Автор нового комментария — «Вы» (by), текст — после Trim, метка времени — «только что».
/// </remarks>
/// <param name="Error">Текст 400 (пустой комментарий) либо null.</param>
/// <param name="Comments">Список комментариев после добавления либо null (400/карточки нет).</param>
public sealed record AddCommentResultDto(string? Error, IReadOnlyList<CardCommentDto>? Comments);
@@ -0,0 +1,16 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Пример разметки пользователя для few-shot ИИ-классификации — результат ICardStore.GetAiMarkupExamplesAsync
/// (pipeline.py _learning_examples L201215).
/// </summary>
/// <remarks>
/// Источник — журнал CardMoves (learning_log прототипа): действия move/restore над карточками с исходным
/// текстом (Cards.SourceMsg), кроме переносов в служебные колонки trash/archive; свежие первыми (ORDER BY
/// created_at DESC), максимум <paramref name="Text"/> режется до 500 кодовых точек вызывающим
/// (контекст-билдер классификации). Форма «текст → колонка» 1:1 с python: примеры подставляются в
/// user-контекст Classify как «текст: … → колонка: …».
/// </remarks>
/// <param name="Text">Исходный текст карточки (source_msg сообщения-источника).</param>
/// <param name="Board">Колонка-назначение переноса (id доски, не служебная).</param>
public sealed record AiMarkupExampleDto(string Text, string Board);
@@ -0,0 +1,11 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Бюджетный диапазон в правилах доски — поле <c>budget</c> объекта rules (§4.2 L274).
/// </summary>
/// <remarks>
/// Границы могут отсутствовать по отдельности («до X» → from=null, «от X» → to=null), валюта — код
/// (USD/RUB/…) либо символ исходного сообщения. Наружу сериализуется в camelCase: from/to/cur.
/// Используется правилами колонок (Task 3, BudgetInRange) — конвертация валюты при сравнении.
/// </remarks>
public sealed record BudgetRangeDto(double? From, double? To, string Cur);
@@ -0,0 +1,12 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Бюджет карточки — поля <c>budget</c>/<c>converted</c> карточки (§4.1 L240241, pipeline.py lead_to_dict L565574).
/// </summary>
/// <remarks>
/// <c>budget</c> — бюджет как в исходном сообщении (валюта — BudgetCur карточки), <c>converted</c> —
/// пересчитанный в целевую валюту (Ruling 7, ConversionRecomputer). Пустой BudgetCur («суммы нет»)
/// представляется null-объектом budget на карточке. Границы могут отсутствовать по отдельности.
/// Наружу сериализуется в camelCase: from/to/cur.
/// </remarks>
public sealed record CardBudgetDto(double? From, double? To, string Cur);
@@ -0,0 +1,13 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Канал-источник карточки — объект <c>ch</c> (§4.1 L244, pipeline.py L577).
/// </summary>
/// <remarks>
/// Имя/хендл/цвет канала или диалога, из которого пришло исходное сообщение. Наружу сериализуется
/// в camelCase под ключом <c>ch</c> (JsonPropertyName — свойство называется Channel): ch.name/ch.handle/ch.hue.
/// </remarks>
public sealed record CardChannelDto(
string Name,
string Handle,
string Hue);
@@ -0,0 +1,10 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Счётчик колонки в ответе counts — значение словаря «col → {count, new}» (§4.1 L257, leads.py counts L268279).
/// </summary>
/// <remarks>
/// Строка GROUP BY по Cards (col, is_new): count — всего карточек в колонке, new — из них «новых».
/// Наружу сериализуется в camelCase: {count, new}.
/// </remarks>
public sealed record CardColumnCountDto(int Count, int New);
@@ -0,0 +1,20 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Перенос карточки в другую колонку/состояние — параметр ICardStore.UpdateColumnAsync (leads.py _move L163174, restore L204222).
/// </summary>
/// <remarks>
/// Единая операция для move/trash/restore/автоархива: обновляет col, is_new, prev_col, archived_at,
/// match_hits у карточки. <see cref="PrevCol"/>: null — НЕ менять (автоархив тика не трогает prev_col,
/// Ruling 8); иначе записывается значение (при move — прежняя колонка, при restore — «inbox», Ruling 10).
/// <see cref="ArchivedAt"/>: значение пишется как есть, null — обнуляется (возврат из архива/корзины).
/// <see cref="MatchHits"/> всегда записывается целиком: для inbox/archive/trash и досок без правил —
/// пустой список (Ruling 2), пересчёт — в модуле (ColumnRules, Task 3) до вызова.
/// </remarks>
public sealed record CardColumnUpdateDto(
string CardId,
string Col,
bool IsNew,
string? PrevCol,
DateTimeOffset? ArchivedAt,
IReadOnlyList<MatchHitDto> MatchHits);
@@ -0,0 +1,16 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Комментарий карточки — элемент массива <c>comments</c> (§4.1 L252, lead_to_dict).
/// </summary>
/// <remarks>
/// Нормализация комментария таблицы LeadComments: <c>by</c> — автор («Вы» — свои комментарии),
/// <c>time</c> — человеческая метка, вычисляемая от CreatedAt при маппинге (Ruling 10, как
/// <c>time</c> карточки). Сразу после добавления комментарий отдаётся с меткой «только что»
/// (leads.py add_comment L259265). Наружу сериализуется в camelCase: id/by/text/time.
/// </remarks>
public sealed record CardCommentDto(
string Id,
string By,
string Text,
string Time);
@@ -0,0 +1,12 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Контакт карточки — элемент массива <c>contacts</c> (§4.1 L243).
/// </summary>
/// <remarks>
/// Квалифицированный контакт (прототип <c>qualify_contact</c>/<c>build_contacts</c>): type —
/// tg|phone|whatsapp|email|linkedin|site|other, value — нормализованное значение. Строковый
/// <c>contact</c> карточки — «быстрый» контакт = value основного (primary_contact, pipeline.py L424430).
/// Наружу сериализуется в camelCase: type/value.
/// </remarks>
public sealed record CardContactDto(string Type, string Value);
@@ -0,0 +1,41 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Ответ GET /api/cards/counts (§4.1 L257, leads.py counts L268279).
/// </summary>
/// <remarks>
/// <see cref="New"/> — сумма «новых» по всем колонкам (top-level <c>new</c>); <see cref="Columns"/> —
/// счётчики по каждой колонке с карточками: ключ — col (inbox/archive/trash/b_...). learning/ml/ai
/// наполняет CardsService из IMlClient.StatusAsync (Task 5) — фронт читает именно их (store.js L586592).
/// Сериализация: new/learning/ml/ai — фиксированные поля, per-колоночные счётчики живут в словаре
/// (плоская wire-форма «{new, &lt;col&gt;: {…}, learning, ml, ai}» собирается на уровне эндпоинта/сервиса
/// из этих частей — см. Task 7/8).
/// </remarks>
public sealed record CardCountsDto
{
/// <summary>
/// Суммарное число «новых» карточек по всем колонкам.
/// </summary>
public int New { get; init; }
/// <summary>
/// Счётчики колонок: col → {count, new} (только колонки с карточками).
/// </summary>
public IReadOnlyDictionary<string, CardColumnCountDto> Columns { get; init; } =
new Dictionary<string, CardColumnCountDto>();
/// <summary>
/// Число обучающих действий (записей CardMoves), счётчик learning.
/// </summary>
public int Learning { get; init; }
/// <summary>
/// Обработано ML-решений (KV mlDecisions; на этапе 3 — 0, Ruling 4).
/// </summary>
public int Ml { get; init; }
/// <summary>
/// Обработано ИИ-решений (KV aiDecisions; на этапе 3 — 0, Ruling 4).
/// </summary>
public int Ai { get; init; }
}
@@ -0,0 +1,174 @@
using System.Text.Json.Serialization;
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Карточка — единая сущность всех дашбордов: элемент GET /api/cards, ответ мутаций и payload SSE new_card.
/// </summary>
/// <remarks>
/// Единая сущность карточки (этап 9): карточка — один агрегат. Поля wire: id/containerId/col/
/// isNew/local/title/summary/source/sourceMsg/sourceDialogId/sourceMsgId/stack/budget/converted/contact/
/// contacts/channel/matchHits/comments/links/files/history/tzText/reminder/prevCol/isVacancy/isVacancyKnown/
/// time/receivedAt/createdAt/updatedAt. <see cref="Time"/> — human-метка от ReceivedAt; времена — epoch-ms.
/// <see cref="Col"/> — внутреннее имя колонки карточки, наружу отдаётся и как алиас <see cref="ContainerId"/>.
/// </remarks>
public sealed record CardDto
{
/// <summary>
/// Короткий id карточки (префикс <c>c_</c>).
/// </summary>
public string Id { get; init; } = string.Empty;
/// <summary>
/// Колонка карточки (служебная зона/стадия/доска <c>b_...</c>).
/// </summary>
public string Col { get; init; } = string.Empty;
/// <summary>
/// Id контейнера (алиас <see cref="Col" />; каноничное имя wire).
/// </summary>
public string ContainerId => Col;
/// <summary>
/// Флаг «новое» (точка на карточке; снимается mark-seen/переносом).
/// </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>
/// Заголовок карточки (очищенный, до 140 символов).
/// </summary>
public string Title { get; init; } = string.Empty;
/// <summary>
/// Блок «О заявке» (до 2000 символов).
/// </summary>
public string Summary { get; init; } = string.Empty;
/// <summary>
/// Источник (производная проекция): kind/displayName/originRef/receivedAt.
/// </summary>
public CardSourceDto Source { get; init; } = new(string.Empty, string.Empty, string.Empty, 0);
/// <summary>
/// Стек/направления (JSON-массив строк).
/// </summary>
public IReadOnlyList<string> Stack { get; init; } = Array.Empty<string>();
/// <summary>
/// Бюджет по исходному сообщению; null — сумма не указана (BudgetCur пуст).
/// </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>
/// Канал-источник (name/handle/hue).
/// </summary>
public CardChannelDto Channel { get; init; } = new(string.Empty, string.Empty, string.Empty);
/// <summary>
/// Человеческая метка возраста: «только что»/«N мин»/«N ч»/«N дн».
/// </summary>
public string Time { get; init; } = string.Empty;
/// <summary>
/// Время получения исходного сообщения, epoch-ms (выходит под ключом <c>receivedAt</c>).
/// </summary>
[property: JsonPropertyName("receivedAt")]
public long ReceivedAtMs { get; init; }
/// <summary>
/// Исходное сообщение (для переобучения ML и поиска, до 4000 символов).
/// </summary>
public string SourceMsg { get; init; } = string.Empty;
/// <summary>
/// Id диалога исходного сообщения (для «открыть исходник»).
/// </summary>
public string SourceDialogId { get; init; } = string.Empty;
/// <summary>
/// Id исходного сообщения в Telegram, либо null.
/// </summary>
public long? SourceMsgId { get; init; }
/// <summary>
/// Предыдущая колонка (для возврата из архива/корзины).
/// </summary>
public string PrevCol { get; init; } = string.Empty;
/// <summary>
/// Совпавшие критерии правил — почему карточка в этой колонке.
/// </summary>
public IReadOnlyList<MatchHitDto> MatchHits { get; init; } = Array.Empty<MatchHitDto>();
/// <summary>
/// Комментарии карточки (таблица LeadComments).
/// </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 (выходит под ключом <c>createdAt</c>).
/// </summary>
[property: JsonPropertyName("createdAt")]
public long CreatedAtMs { get; init; }
/// <summary>
/// Время последнего изменения, epoch-ms (выходит под ключом <c>updatedAt</c>).
/// </summary>
[property: JsonPropertyName("updatedAt")]
public long UpdatedAtMs { get; init; }
}
@@ -0,0 +1,18 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Файл карточки — элемент массива <c>files</c> (этап 9, T6; форма 1:1 с прежней проектной карточкой).
/// </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,20 @@
using System.Text.Json.Serialization;
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Запись истории движения карточки — элемент массива <c>history</c> (этап 9, T6).
/// </summary>
/// <remarks>
/// Ровно один из ключей type/stage: второй опускается сериализацией. Type — создание
/// (<c>created</c>/<c>createdLocal</c>), Stage — новая стадия при переносе. At — epoch-ms.
/// </remarks>
/// <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> (этап 9, T6; форма 1:1 с прежней проектной карточкой).
/// </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,26 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Начальные поля ручного («локального») создания карточки — тело POST /api/cards (Ruling 6).
/// </summary>
/// <remarks>
/// Wire-форма тела: {title, summary, stack?, budget?, contact, tzText?, containerId?/stage?} — подмножество
/// <see cref="CardPatch"/> плюс <see cref="ContainerId"/>. Дефолты — как в прототипе (pydantic): пустые
/// строки; отсутствующие stack/containerId — null (стек пуст, контейнер — planned). Наружу — camelCase.
/// </remarks>
/// <param name="Title">Заголовок карточки (при создании Trim(); пустой допустим — 1:1 прототип).</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.CardsDefaultContainers"/>);
/// null или неизвестная — карточка создаётся в <c>planned</c> (Ruling 6).</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,16 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Запись журнала действий над карточкой — параметр ICardStore.AddMoveAsync (таблица CardMoves, leads.py _log_learning L4044).
/// </summary>
/// <remarks>
/// Каждое действие пользователя (move/trash/restore/comment) пишет строку журнала; счётчик learning =
/// число записей CardMoves (Ruling 4, Task 5). Id (<c>lm_...</c>) генерирует модуль (Ruling 12);
/// CreatedAt проставляет хранилище (UTC-now). Журнал живёт дольше карточки — FK нет (Ruling 1).
/// </remarks>
public sealed record CardMoveDto(
string Id,
string LeadId,
string Action,
string? FromCol,
string? ToCol);
@@ -0,0 +1,31 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Частичная правка карточки (ICardStore.PatchCardAsync; 1:1 patch_card projects.py L159187).
/// </summary>
/// <remarks>
/// Значение null у поля означает «поле не меняется» (конвенция ContainerPatchDto); пустая строка или
/// пустой массив — осмысленное значение и применяется. JSON-поля (stack/comments/links/files) заменяются
/// ЦЕЛИКОМ, не сливаясь с текущими значениями. budget — объект {from,to,cur} либо null = «не менять»;
/// явная очистка бюджета телом PATCH передаётся объектом с пустой Cur (BudgetCur пуст = «бюджета нет»;
/// хранилище пишет from/to=null и cur="", наружу бюджет снова null). Наружу сериализуется в camelCase.
/// </remarks>
/// <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,40 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Результат ручной переклассификации карточки — параметр ICardStore.ApplyReclassificationAsync.
/// </summary>
/// <remarks>
/// Одно обновление полей классификации (leads.py reclassify_lead L346367): колонка, тип (найм/заказ),
/// заголовок/суть/стек, бюджет и его конверсия, контакты и matchHits. Пишется как есть (полная замена):
/// бюджет без валюты (<see cref="Budget"/> = null) очищает поля, как и отсутствие конверсии.
/// <see cref="IsVacancyKnown"/> на ИИ-пути = true (тип подтверждён по контексту), на локальном — маркерная
/// гипотеза. PrevCol/ArchivedAt не трогаются — переклассификация не меняет историю возврата.
/// JSON-поля (Stack/Contacts/MatchHits) сериализует адаптер при записи.
/// </remarks>
/// <param name="CardId">Id карточки (<c>c_...</c>).</param>
/// <param name="Col">Новая колонка (доска либо inbox — страховку ContainerAccepts уже применил вызывающий).</param>
/// <param name="IsNew">Флаг «новое» (переклассификация подсвечивает карточку — python is_new = TRUE).</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> (этап 9, T6).
/// </summary>
/// <param name="At">Время напоминания, epoch-ms.</param>
public sealed record CardReminderDto(long At);
@@ -0,0 +1,17 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Строка «выстрелившего» напоминания — мини-DTO выборки ICardStore.ListDueRemindersAsync (Ruling 3).
/// </summary>
/// <remarks>
/// Возвращается проверкой напоминаний (CardsService.CheckDueRemindersAsync), публикуется в SSE-событии
/// <c>reminder_due</c> ({id,title,containerId}, api-map §2) и в ответе POST /api/admin/tick (<c>reminders</c>).
/// id — карточки, containerId всегда <c>hold</c> на момент срабатывания. Сериализуется в camelCase.
/// </remarks>
/// <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,13 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Результат мутации карточки, отвечающей карточкой: тонкий record-результат (перенос, ссылки, напоминания).
/// </summary>
/// <remarks>
/// <see cref="Error"/> непуст — 400-текст фиксированной строки; <see cref="Card"/> == null при Error == null —
/// карточки нет (404-семантику даёт null, эндпоинт отвечает «Карточка не найдена»); Card непуст — успех.
/// Единая форма результатов мутаций карточки (перенос, ссылки, напоминания).
/// </remarks>
/// <param name="Error">Текст 400 (фиксированная строка) либо null.</param>
/// <param name="Card">Карточка после мутации либо null (400/карточки нет).</param>
public sealed record CardResultDto(string? Error, CardDto? Card);
@@ -0,0 +1,164 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// «Сырая» запись для создания карточки (ICardStore.AddCardAsync) — пайплайн и ручное создание.
/// </summary>
/// <remarks>
/// Write-модель: содержит полное состояние новой карточки (псевдоним строки Cards, Ruling 1), включая
/// готовый id (<c>c_...</c>, генерирует модуль) и служебные значения, вычисленные до записи:
/// matchHits (Ruling 2), conv-поля (BudgetNormalizer), prevCol=inbox, историю/локальный признак.
/// CreatedAt проставляет хранилище (UTC-now), а human-метку time/маппинг — слой чтения (CardDto).
/// JSON-массивы (Stack/Contacts/MatchHits/History) сериализует адаптер при записи.
/// </remarks>
public sealed record CardSnapshot
{
/// <summary>
/// Готовый id карточки (префикс <c>c_</c>), сгенерированный модулем.
/// </summary>
public string Id { get; init; } = string.Empty;
/// <summary>
/// Контейнер размещения: inbox (пайплайн) либо стадия/доска (ручное создание).
/// </summary>
public string Col { get; init; } = string.Empty;
/// <summary>
/// Новая карточка (точка «новое»); при ручном создании — false.
/// </summary>
public bool IsNew { get; init; } = true;
/// <summary>
/// Признак «карточка создана локально» (без внешнего первоисточника).
/// </summary>
public bool Local { get; init; }
/// <summary>
/// Найм (вакансия) vs разовая сделка (маркерная гипотеза).
/// </summary>
public bool IsVacancy { get; init; }
/// <summary>
/// Тип подтверждён ИИ (на этапе 3/демо — как в исходных данных).
/// </summary>
public bool IsVacancyKnown { get; init; }
/// <summary>
/// Заголовок карточки (очищенный, ≤140).
/// </summary>
public string Title { get; init; } = string.Empty;
/// <summary>
/// Блок «О заявке» (очищенный, ≤2000).
/// </summary>
public string Summary { get; init; } = string.Empty;
/// <summary>
/// Стек/направления.
/// </summary>
public IReadOnlyList<string> Stack { get; init; } = Array.Empty<string>();
/// <summary>
/// Нижняя граница бюджета (валюта — <see cref="BudgetCur"/>), либо null.
/// </summary>
public double? BudgetFrom { get; init; }
/// <summary>
/// Верхняя граница бюджета (валюта — <see cref="BudgetCur"/>), либо null.
/// </summary>
public double? BudgetTo { get; init; }
/// <summary>
/// Валюта бюджета; пусто — бюджет не задан.
/// </summary>
public string BudgetCur { get; init; } = string.Empty;
/// <summary>
/// Сконвертированная нижняя граница (целевая валюта — <see cref="ConvCur"/>), либо null.
/// </summary>
public double? ConvFrom { get; init; }
/// <summary>
/// Сконвертированная верхняя граница (целевая валюта — <see cref="ConvCur"/>), либо null.
/// </summary>
public double? ConvTo { get; init; }
/// <summary>
/// Валюта сконвертированного бюджета; пусто — конверсия не сделана.
/// </summary>
public string ConvCur { get; init; } = string.Empty;
/// <summary>
/// «Быстрый» контакт (value основного контакта, primary_contact).
/// </summary>
public string Contact { get; init; } = string.Empty;
/// <summary>
/// Квалифицированные контакты.
/// </summary>
public IReadOnlyList<CardContactDto> Contacts { get; init; } = Array.Empty<CardContactDto>();
/// <summary>
/// Имя канала/диалога-источника.
/// </summary>
public string ChannelName { get; init; } = string.Empty;
/// <summary>
/// Handle канала-источника.
/// </summary>
public string ChannelHandle { get; init; } = string.Empty;
/// <summary>
/// Цвет канала-источника (hex).
/// </summary>
public string ChannelHue { get; init; } = string.Empty;
/// <summary>
/// Время получения исходного сообщения (сортировка DESC, автоархив).
/// </summary>
public DateTimeOffset ReceivedAt { get; init; }
/// <summary>
/// Исходное сообщение (≤4000; для ML-обучения и поиска).
/// </summary>
public string SourceMsg { get; init; } = string.Empty;
/// <summary>
/// Id диалога исходного сообщения.
/// </summary>
public string SourceDialogId { get; init; } = string.Empty;
/// <summary>
/// Id исходного сообщения в Telegram, либо null.
/// </summary>
public long? SourceMsgId { get; init; }
/// <summary>
/// Предыдущая колонка (при создании — inbox).
/// </summary>
public string PrevCol { get; init; } = string.Empty;
/// <summary>
/// Время помещения в архив (для правил очистки), либо null.
/// </summary>
public DateTimeOffset? ArchivedAt { get; init; }
/// <summary>
/// Совпавшие критерии правил (для inbox/досок без правил — пусто).
/// </summary>
public IReadOnlyList<MatchHitDto> MatchHits { get; init; } = Array.Empty<MatchHitDto>();
/// <summary>
/// Текст технического задания (при создании может быть пустым).
/// </summary>
public string TzText { get; init; } = string.Empty;
/// <summary>
/// Стартовые комментарии карточки (ручное создание — пусто; пишутся в таблицу LeadComments).
/// </summary>
public IReadOnlyList<CardCommentDto> Comments { get; init; } = Array.Empty<CardCommentDto>();
/// <summary>
/// История движения: при ручном создании — одна запись (createdLocal); пайплайн — пусто.
/// </summary>
public IReadOnlyList<CardHistoryDto> History { get; init; } = Array.Empty<CardHistoryDto>();
}
@@ -0,0 +1,19 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Источник карточки на wire — объект <c>source</c> карточки (этап 9, T6).
/// </summary>
/// <remarks>
/// Производная проекция от строки карточки (точная полиморфная иерархия <c>ISource</c> — домен):
/// kind — вид источника, displayName — имя канала/источника, originRef — ссылка на первоисточник
/// (id диалога/файла), receivedAt — момент получения (epoch-ms).
/// </remarks>
/// <param name="Kind">Вид источника: local/telegram/web/file/row/api/ai/composite/other.</param>
/// <param name="DisplayName">Имя источника (канал/диалог).</param>
/// <param name="OriginRef">Ссылка на первоисточник (id диалога/файла).</param>
/// <param name="ReceivedAt">Момент получения, epoch-ms.</param>
public sealed record CardSourceDto(
string Kind,
string DisplayName,
string OriginRef,
long ReceivedAt);
@@ -0,0 +1,11 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Запрос списка карточек — параметр ICardStore.ListCardsAsync (фильтр по колонке, leads.py list_leads L151156).
/// </summary>
/// <remarks>
/// <see cref="Col"/> — null означает «все колонки дашборда» (без контейнеров-стадий «Выбранных»);
/// иначе — карточки одной колонки. Сортировка всегда received_at DESC (задача адаптера).
/// Валидацию значения колонки (inbox/archive/trash/существующая доска) выполняет сервис до вызова.
/// </remarks>
public sealed record CardsQuery(string? Col);
@@ -0,0 +1,13 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Результат очистки служебной колонки — CardsService.ClearColAsync (clear_col L237247, dashboard_routes L228235).
/// </summary>
/// <remarks>
/// Колонка не trash/archive → <see cref="Error"/> = текст 400 «Очищать можно только корзину или архив»;
/// иначе <see cref="Cleared"/> — сколько карточек удалено навсегда (0 — колонка пуста). Ответ эндпоинта
/// — {ok: true, cleared}; при ошибке — {detail} с текстом Error.
/// </remarks>
/// <param name="Error">Текст 400 (колонка не служебная trash/archive) либо null.</param>
/// <param name="Cleared">Число удалённых карточек (валидная колонка); 0 при ошибке.</param>
public sealed record ClearColResultDto(string? Error, int Cleared);
@@ -0,0 +1,17 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Состояние одной колонки в KV-настройке <c>colState</c> — значение словаря «col → состояние» (leads.py L138146).
/// </summary>
/// <remarks>
/// Wire-форма значения — JSON-объект `{"collapsed": bool}` (и опционально `{"width": "sm"|"md"|"lg"}`,
/// api-map §3.2 L7677): в <c>colState</c> фронт хранит только свёрнутость служебных колонок
/// (inbox/archive/trash) — ширину/свёрнутость ДОСОК держат поля Boards (Ruling 10). null у поля —
/// «значение не задано»: при PATCH не меняет текущее, при записи не сериализуется
/// (DefaultIgnoreCondition.WhenWritingNull, как прототип model_dump(exclude_none=True)).
/// Неизвестные ключи внутри значения колонки типизированная модель не сохраняет (в реальных
/// потоках фронта их нет — колонки пишутся только этим PATCH).
/// </remarks>
/// <param name="Collapsed">Свёрнута ли колонка в виджет (null — не задано).</param>
/// <param name="Width">Ширина колонки sm|md|lg (null — не задано).</param>
public sealed record ColumnStateDto(bool? Collapsed, string? Width);
@@ -0,0 +1,12 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Счётчики карточек контейнера — поле <c>counts</c> ответа /api/containers (этап 9, T4).
/// </summary>
/// <remarks>
/// Вычисляется чтением: строка GROUP BY по Cards (container → count, new). В БД не хранится.
/// Наружу сериализуется в camelCase: {total, new}.
/// </remarks>
/// <param name="Total">Всего карточек в контейнере.</param>
/// <param name="New">Из них «новых» (is_new = true).</param>
public sealed record ContainerCountsDto(int Total, int New);
@@ -0,0 +1,29 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Вход создания контейнера — параметр ContainersService.CreateAsync (POST /api/containers).
/// </summary>
/// <remarks>
/// <see cref="Name"/> — обязательный (пробельное/пустое значение сервис заменяет на «Новая колонка»);
/// <see cref="Color"/> — null/пустая строка означают «взять цвет палитры по позиции».
/// <see cref="Space"/>/<see cref="Kind"/> задают пространство и вид контейнера (по умолчанию —
/// пользовательская колонка дашборда <c>board</c>/<c>dashboard</c>). Правила — необязательны;
/// <see cref="Suggested"/>/<see cref="Note"/> использует эвристика ИИ-предложений. Wire — camelCase.
/// </remarks>
/// <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,78 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Единый контейнер карточек: колонка дашборда, стадия «Выбранных» или служебная зона.
/// </summary>
/// <remarks>
/// Приходит на смену BoardDto: таблица Containers — единственный реестр колонок/стадий/зон (этап 9, T4).
/// Поля wire (camelCase): id/name/description/color/order/space/kind/collapsed/suggested/note/rules/policy/counts.
/// <see cref="Rules"/> — null означает «правил нет»; <see cref="Policy"/> описывает поведение зоны;
/// <see cref="Counts"/> заполняется при чтении (счётчики карточек контейнера) и не хранится в БД.
/// </remarks>
public sealed record ContainerDto
{
/// <summary>
/// Короткий id контейнера (доски <c>b_…</c>, стадии <c>planned…</c>, служебные inbox/archive/trash).
/// </summary>
public string Id { get; init; } = string.Empty;
/// <summary>
/// Имя для отображения («WPF», «В работе», «Архив»).
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// Описание контейнера (пользователю и для подсказки ИИ/ML).
/// </summary>
public string Description { get; init; } = string.Empty;
/// <summary>
/// Цвет контейнера (hex).
/// </summary>
public string Color { get; init; } = string.Empty;
/// <summary>
/// Позиция в пространстве (порядок показа; wire-имя — <c>order</c>).
/// </summary>
public int Order { get; init; }
/// <summary>
/// Id пространства: <c>dashboard</c> | <c>selected</c>.
/// </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>
/// Свёрнутость колонки на дашборде (состояние UI).
/// </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>
/// Счётчики карточек контейнера (total/new); заполняются чтением, в БД не хранятся.
/// </summary>
public ContainerCountsDto Counts { get; init; } = new(0, 0);
}
@@ -0,0 +1,27 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Патч контейнера — допустимые изменения PATCH /api/containers/{id} (этап 9, T4).
/// </summary>
/// <remarks>
/// Значение null у поля означает «поле не меняется» (в PATCH-теле отсутствует либо явно null).
/// JSON-объекты rules/policy заменяются целиком, не сливаясь с текущими.
/// Наружу и из wire сериализуется в camelCase.
/// </remarks>
/// <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,27 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Политика контейнера: правила жизненного цикла зоны (этап 9, T4).
/// </summary>
/// <remarks>
/// Роль контейнера (не enum): можно ли вернуть карточку на доску пространства, терминальна ли зона,
/// задана ли автоочистка. Хранится JSON-объектом в Containers.PolicyJson (camelCase); значения по
/// умолчанию — «обычная колонка» (возврат разрешён, не терминальна, без автоочистки).
/// </remarks>
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,27 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Правила фильтра колонки — объект <c>rules</c> доски (§4.2 L270275, §6.3, BoardRulesDialog.vue).
/// </summary>
/// <remarks>
/// Форма совпадает с формой диалога правил фронта: <c>mode</c> — «all» (все группы обязательны) либо
/// «any» (любая из групп); direction/keywords/stack/grade/levels/locations/types/exclude — группы термов;
/// budget/prices — опциональные диапазоны. Правила маршрутизируют входящие карточки (Ruling 2, Task 3).
/// Пустые списки и отсутствующие ключи в JSON (хранится как text, формат <c>{}</c> — нет правил) приводит
/// к маппингу. Наружу сериализуется в camelCase:
/// mode/direction/keywords/stack/grade/exclude/budget + levels/locations/types/prices (этап 12, §6.3).
/// Новые группы добавлены в конец с дефолтами — существующие позиционные вызовы и сохранённый RulesJson
/// обратно совместимы.
/// </remarks>
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,15 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Совпадение критерия правил — элемент массива <c>matchHits</c> (§4.1 L251, rules.py hits L271296).
/// </summary>
/// <remarks>
/// «Почему карточка в колонке»: label — группа критерия («Направление»/«Слова»/«Стек»/«Грейд/уровень»/
/// «Бюджет», Ruling 2), term — совпавший терм, word — опциональное слово (для грейдов/уровней).
/// Для служебных колонок (inbox/archive/trash) и досок без активных правил — пустой список.
/// Наружу сериализуется в camelCase: label/term/word.
/// </remarks>
public sealed record MatchHitDto(
string Label,
string Term,
string? Word);
@@ -0,0 +1,20 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Строка очереди обучения ML (таблица MlOutbox) — срез для выгрузки батча (план Task 16, Ruling 6).
/// </summary>
/// <remarks>
/// Возвращается хранилищем через <see cref="IMlLearningStore.TakeOutboxBatchAsync"/> в порядке
/// <c>created_at</c> (1:1 с выборкой python flush_outbox L6669) и уходит в ml-service батчем
/// TrainBatch (поля text/label/delta — ровно колонки ml_outbox; id нужен вызывающему для удаления
/// строк только после успешной отправки).
/// </remarks>
/// <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,16 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// Статистика тика правил хранения — поле <c>storage</c> ответа POST /api/admin/tick (Ruling 8, leads.py tick_storage L454493).
/// </summary>
/// <remarks>
/// archived — карточки, ушедшие в архив по autoArchive; purgedArchive — очищено из архива по сроку
/// (archiveClearDays); purgedTrash — очищено из корзины (trashClearDays); purgedRejected — отсев
/// пайплайна (этап 4; в этапе 3 всегда 0). Тексты SSE-тостов по статистике — 1:1 (Ruling 8).
/// Наружу сериализуется в camelCase: archived/purgedArchive/purgedTrash/purgedRejected.
/// </remarks>
public sealed record StorageTickStatsDto(
int Archived,
int PurgedArchive,
int PurgedTrash,
int PurgedRejected);
@@ -0,0 +1,24 @@
namespace Deal.Modules.Kanban.Application.Models;
/// <summary>
/// План одной колонки-предложения — элемент выхода <c>SuggestHeuristics</c> (Ruling 3, Task 14).
/// </summary>
/// <remarks>
/// Чистая структура модуля Kanban: тема-слово в нижнем регистре (<see cref="Word"/>), готовое имя
/// колонки (<see cref="Name"/> — Word с заглавной буквы), id карточек «Неразобранного», собранных
/// в группу (≥2, в порядке выдачи окна анализа), и note-обоснование (<see cref="Note"/> —
/// «Эвристика (этап 3): …; реальные предложения ИИ — этап 6»). Адаптер
/// <c>LocalColumnSuggester</c> (Infrastructure) превращает план в доску suggested=true
/// (RulesJson {mode:"any", keywords:[Word]}) и раскладывает карточки. Правила с одним ключевым словом
/// достаточны: группа собирается именно по этому слову, и оно же маршрутизирует будущие входящие
/// (после принятия колонки пользователем).
/// </remarks>
/// <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,28 @@
using System.Security.Cryptography;
namespace Deal.Modules.Kanban.Application;
/// <summary>
/// Генератор коротких префиксных id модуля Kanban (Ruling 12, прототип store.uid = prefix + uuid4().hex[:12]).
/// </summary>
/// <remarks>
/// Не GUID: прототип и фронт требуют коротких ключей в JSON (id досок/карточек попадают в wire как
/// ключи). Случайная часть — 12 hex-символов (6 байт CSPRNG), 1:1 с <c>uuid4().hex[:12]</c> прототипа.
/// Префиксы — <see cref="KanbanIdPrefixes"/> (b_/c_/cm_/lm_/mle_/pl_/pf_/h_): модуль генерирует id и передаёт в
/// хранилище готовыми (порт id не создаёт). Использование: CardsService, эвристика suggest.
/// </remarks>
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,76 @@
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application;
namespace Deal.Modules.Kanban.Application;
/// <summary>
/// Сервис правил хранения: автоархив и очистка архива/корзины по срокам — Ruling 8, план Task 10.
/// </summary>
/// <remarks>
/// Чистый сервис модуля (без EF/HTTP), повторяет <c>leads.py tick_storage</c> (L454493). Настройки
/// autoArchive/archiveAfterDays/archiveClearDays/trashClearDays читает типизированным снимком
/// <see cref="TenantSettingsSnapshot"/> (C30: один GetAllAsync на тик; отсутствие/повреждение → дефолт из
/// <c>SettingsDefaults</c>). Один «now»
/// (UTC) на весь тик, как в прототипе. Последовательность 1:1 с tick_storage:
/// (1) автоархив — карточки досок и «Неразобранного» со ReceivedAt старше archiveAfterDays → col=archive,
/// is_new=false, archived_at=now (PrevCol НЕ трогается — Ruling 8: null в CardColumnUpdateDto), пачкой
/// <see cref="ICardStore.ArchiveAsync"/> (один UPDATE вместо N);
/// (2) очистка архива — col='archive' с ArchivedAt старше archiveClearDays (дефолт 90) → жёсткое удаление;
/// (3) очистка корзины — col='trash' с ReceivedAt старше trashClearDays (дефолт 7) → жёсткое удаление.
/// Удаление — пачкой <see cref="ICardStore.PurgeAsync"/> (Cards + комментарии каскадом; журнал CardMoves
/// и MlOutbox не трогаются, Ruling 10). purgedRejected — отсев пайплайна (этап 4); в этапе 3 всегда 0.
/// SSE-тосты сервис НЕ публикует: это обязанность эндпоинтов Api (Ruling 5) по статистике TickAsync.
/// </remarks>
/// <param name="store">Порт хранилища канбана: кандидаты тика, batch-перенос в архив, жёсткое удаление пачки.</param>
/// <param name="settings">KV-хранилище настроек тенанта (снимок правил хранения, Ruling 8).</param>
public sealed class StorageTickService(ICardStore store, ISettingsStore settings)
{
/// <summary>
/// Один тик правил хранения тенанта (Ruling 8; leads.py tick_storage L454493).
/// </summary>
/// <param name="ct">Токен отмены.</param>
/// <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);
// Один «now» на весь тик — границы автоархива и очисток от одного момента (tick_storage L459).
DateTimeOffset now = DateTimeOffset.UtcNow;
int archived = 0;
if (autoArchive)
{
// Автоархив: карточки досок и «Неразобранного» (tick_storage L462467). В архив — пачкой одним
// UPDATE (архивация как системный перенос: col=archive, is_new=false, archived_at=now, matchHits
// пусто; prev_col не трогается, Ruling 8); по-карточные UPDATE'ы тика заменены на batch.
IReadOnlyList<string> candidates = await store.ListArchiveCandidatesAsync(
now.AddDays(-archiveAfterDays), ct);
archived = candidates.Count == 0 ? 0 : await store.ArchiveAsync(candidates, now, ct);
}
// Очистка архива по сроку хранения (tick_storage L475478).
int purgedArchive = await PurgeAsync(
await store.ListExpiredArchiveCandidatesAsync(now.AddDays(-archiveClearDays), ct), ct);
// Очистка корзины по сроку хранения (tick_storage L480483).
int purgedTrash = await PurgeAsync(
await store.ListTrashCandidatesAsync(now.AddDays(-trashClearDays), ct), ct);
// purgedRejected — отсев пайплайна живёт в Pipeline (этап 4); в этапе 3 всегда 0 (Ruling 8).
return new StorageTickStatsDto(archived, purgedArchive, purgedTrash, PurgedRejected: 0);
}
// Жёстко удаляет пачку кандидатов очистки (Ruling 8; KanbanStore.PurgeAsync — комментарии каскадом).
// cardIds: Id карточек-кандидатов из выборки хранилища.
// ct: Токен отмены.
// Возвращает: Сколько карточек реально удалено (0 — кандидатов не было).
private async Task<int> PurgeAsync(IReadOnlyList<string> cardIds, CancellationToken ct)
{
return cardIds.Count == 0 ? 0 : await store.PurgeAsync(cardIds, ct);
}
}
@@ -0,0 +1,294 @@
using System.Text;
using Deal.Modules.Cards.Application;
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Modules.Kanban.Application;
/// <summary>
/// Чистое ядро ИИ-предложений колонок/ключей — план Task 14 L467471, Ruling 3.
/// </summary>
/// <remarks>
/// Детерминированная эвристика этапа 3 (реальные предложения ИИ — этап 6): без EF/HTTP/хранилища.
/// Тема колонки — повторяющееся слово-тема по source_msg карточек «Неразобранного»: текст
/// токенизируется на слова (только буквы, ≥3 символов, нижний регистр, минус стоп-слова), слово,
/// встречающееся в ≥2 карточках окна, становится кандидатом в колонку. Группы собираются жадным
/// алгоритмом: самый частотный кандидат (при равенстве — более длинное слово, затем лексикографически)
/// забирает свои карточки, следующие кандидаты получают только неразобранные (как used_msg прототипа,
/// suggest.py L129143). Выход — до <see cref="MaxColumns"/> планов, каждый с ≥2 карточками; похожие на
/// существующие доски слова пропускаются (suggest.py _similar_exists L5561). Suggest-keywords — те же
/// частотные слова по выборке карточек (≤60, ≤40 симв.) — прототип suggest_domain_keywords L166193.
/// Все сравнения/порядки — Ordinal: одинаковый вход даёт одинаковый выход (Acceptance Task 14).
/// </remarks>
public static class SuggestHeuristics
{
// ── Пороги анализа (прототип suggest.py L48–52 и правила промпта L24–27) ──
/// <summary>
/// Минимум карточек в «Неразобранном» для анализа (suggest.py MIN_INBOX L48).
/// </summary>
public const int MinInbox = 6;
/// <summary>
/// Минимум карточек в одной колонке-предложении (MIN_INBOX_GROUP L49, «группируй… минимум 2 сообщения»).
/// </summary>
public const int MinInboxGroup = 2;
/// <summary>
/// Сколько сообщений берём в анализ — окно по received_at DESC (MAX_TEXT L50, LIMIT 12).
/// </summary>
public const int MaxText = 12;
/// <summary>
/// Колонок-предложений за один прогон максимум (прототип cols[:4] L132, «2–4 колонки максимум»).
/// </summary>
public const int MaxColumns = 4;
/// <summary>
/// Минимальная длина слова-темы: слова короче 3 букв не несут темы (план Task 14 L468).
/// </summary>
public const int MinWordLength = 3;
/// <summary>
/// Максимальная длина слова-темы/ключа: 1:1 с прототипом (имя колонки ≤40, L136; ключ ≤40, L191).
/// </summary>
public const int MaxKeywordLength = 40;
/// <summary>
/// Максимум ключей-маркеров suggest-keywords (прототип keywords[:60] L193).
/// </summary>
public const int MaxKeywordsTotal = 60;
/// <summary>
/// Окно карточек для suggest-keywords: свежие 40 с текстом (suggest.py L172176, LIMIT 40).
/// </summary>
public const int KeywordsSampleLimit = 40;
/// <summary>
/// Минимум карточек для suggest-keywords: «нужно хотя бы 3» (suggest.py L178).
/// </summary>
public const int MinKeywordsSample = 3;
// Сколько карточек группы учитывается в note-обосновании (текст note, план L469470).
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>
/// Планы колонок-предложений по карточкам «Неразобранного» (suggest_from_inbox L76163).
/// </summary>
/// <remarks>
/// Анализируется окно из <see cref="MaxText"/> самых свежих карточек (received_at DESC — как SQL
/// L96–99); карточек в окне меньше <see cref="MinInbox"/> → пусто (причину «мало карточек…» называет
/// адаптер). Слова существующих НЕ-suggested досок исключаются из кандидатов (похожесть имени,
/// suggest.py L5561). Каждая карточка попадает не более чем в одну группу (used_msg L129143):
/// кандидаты перебираются от самого частотного, группе достаются только ещё не разобранные карточки.
/// Выход полностью детерминирован: сортировки Ordinal + порядок входа окна.
/// </remarks>
/// <param name="inbox">Карточки «Неразобранного» с непустым source_msg (ICardStore.ListInboxWithSourceAsync).</param>
/// <param name="existingBoardNames">Имена существующих (suggested=false) досок — похожие темы не предлагаются.</param>
/// <returns>Планы колонок (≤4, каждая ≥2 карточки); пусто — мало карточек/нечего сгруппировать.</returns>
public static IReadOnlyList<SuggestedColumnPlan> PlanColumns(
IReadOnlyList<CardDto> inbox,
IReadOnlyList<string> existingBoardNames)
{
// Окно анализа: свежие карточки с текстом, как выборка SQL L96–99 (сортировка — страховка
// детерминизма: адаптер уже отдаёт received_at DESC, но вход не должен влиять на выход).
List<CardDto> window = inbox
.Where(card => card.SourceMsg.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.SourceMsg).Distinct(StringComparer.Ordinal))
{
if (!termCards.TryGetValue(term, out List<string>? cardIds))
{
cardIds = [];
termCards[term] = cardIds;
}
cardIds.Add(card.Id);
}
}
// Имена существующих досок — один раз в нижнем регистре (похожесть L55–61).
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();
// Жадная сборка групп: каждый кандидат забирает только неразобранные карточки (suggest.py
// used_msg L129143); группа без ≥2 свободных карточек пропускается.
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>
/// Частотные слова-маркеры по текстам карточек (suggest_domain_keywords L166193, эвристика Ruling 3).
/// </summary>
/// <remarks>
/// Маркер — слово (те же правила токенизации, что для колонок), встречающееся в ≥2 текстах выборки.
/// Выход — до <see cref="MaxKeywordsTotal"/> слов, длина каждого ≤<see cref="MaxKeywordLength"/>;
/// порядок — частота по убыванию, при равенстве более длинное, затем лексикографически
/// (детерминизм: одинаковый вход → одинаковый выход). Выборку (свежие 40 вне trash/archive) и
/// проверку «мало карточек» выполняет адаптер LocalColumnSuggester.
/// </remarks>
/// <param name="texts">Тексты source_msg карточек выборки (непустые, свежие ≤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: Исходный текст (source_msg карточки).
// Возвращает: Уникальные слова текста в порядке появления (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);
}
// Похоже ли слово-тема на существующую доску: равенство или вхождение имени в слово/наоборот
// (suggest.py _similar_exists L5561, регистронезависимо).
// term: Слово-кандидат (нижний регистр).
// existingLowered: Имена существующих досок в нижнем регистре.
// Возвращает: True — тема уже покрыта существующей колонкой (кандидат пропускается).
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;
}
// Имя колонки из слова-темы: первая буква заглавная (python → Python, такси → Такси).
// term: Слово-тема в нижнем регистре (непустое).
// Возвращает: Слово с заглавной первой буквой.
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: используется для DI-сканирования и тестов.
/// </summary>
public sealed class KanbanModuleMarker
{
}