Разбить модули Deal.Modules.* по назначению

Application проектов Discovery, Kanban, Pipeline, Settings, Tenants
разделён на Abstractions/Exceptions/Extensions/Models/Registrars/Services;
namespace приведён к путям, using потребителей мигрированы и
дедуплицированы (169 файлов), cref/FQN обновлены.
This commit is contained in:
Rustam Khalimov
2026-09-11 13:18:14 +03:00
parent 31c434ed93
commit cd0b3b606b
356 changed files with 14436 additions and 12906 deletions
@@ -0,0 +1,181 @@
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Registrars;
namespace Deal.Modules.Kanban.Application.Services;
/// <summary>
/// Нормализация бюджета «при поступлении»: приведение к форме хранения и пересчёт в целевую валюту
/// (ai.py clean_budget L316–326, budget_to_target L342–352; 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 L279–291).
/// <see cref="ToTarget"/> пересчитывает нормализованный бюджет в целевую валюту тенанта по курсам —
/// «интерфейс курсов» это словарь «код → курс к рублю» + чистая <see cref="RatesService.ConvertAmount"/>
/// (USDT=USD, rates.py L86–103); чтение настроек 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 L271–276, согласовано с 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 L316–326).
/// </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 L337–338)
}
return new CardBudgetDto(from, to, cur);
}
/// <summary>
/// Пересчёт бюджета в целевую валюту «один раз при поступлении» (ai.py budget_to_target L342–352).
/// </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 L351–357).
/// </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 L294–313: 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 L279–291).
// 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(c => c.IsCurrencyLetter()).ToArray());
if (CurrencyAliases.TryGetValue(letters, out string? fromLetters))
{
return fromLetters;
}
if (s.Length == 3 && s.All(c => c.IsCurrencyLetter()))
{
return s;
}
return null;
}
// Конвертация суммы через курсы к рублю; null rates → null (курсов нет — граница не конвертируется).
// amount: Сумма.
// fromCurrency: Исходная валюта (код).
// toCurrency: Целевая валюта (код).
// rates: Курсы к рублю либо null.
// Возвращает: Сумма в целевой валюте или null.
private static double? Convert(double amount, string fromCurrency, string toCurrency, IReadOnlyDictionary<string, double>? rates)
{
return rates is null ? null : RatesService.ConvertAmount(amount, fromCurrency, toCurrency, rates);
}
}
@@ -0,0 +1,172 @@
using Deal.Contracts.Integrations;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Registrars;
namespace Deal.Modules.Kanban.Application.Services;
/// <summary>
/// Файлы карточки — partial-часть <see cref="CardsService"/> (этап 9: тот же домен карточки):
/// оркестрация файлового хранилища и метаданных FilesJson (files.py L57–94).
/// </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 L57–75).
/// </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 L78–83).
/// </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 L86–94).
/// </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,114 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application.Abstractions;
using Deal.Modules.Cards.Application.Dtos;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application.ColumnRules;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
// Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён — внутри пространства имён
// Deal.Modules.Kanban.Application имя ColumnRules резолвится в пространство (CS0234), нужен явный алиас.
using KanbanColumnRules = Deal.Modules.Kanban.Application.ColumnRules.ColumnRules;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Registrars;
namespace Deal.Modules.Kanban.Application.Services;
/// <summary>
/// Приватные помощники <see cref="CardsService"/> — partial-часть (C32: выделено из общего файла,
/// поведение не менялось): текст обучающего примера, перенос колонки с журналом, hits правил доски и
/// колонка возврата (leads.py L163–174, L209, L311–319; Ruling 2/4).
/// </summary>
public sealed partial class CardsService
{
// ── Внутреннее ─────────────────────────────────────────────────────────
// Текст обучающего примера: source_msg (после Trim) или title (leads.py L167, L189–190, L199–200).
// 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 L170–174).
// 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 L40–44): 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 L311–319 через 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,349 @@
using Deal.Contracts.Integrations;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application.Abstractions;
using Deal.Modules.Cards.Application.Dtos;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application.ColumnRules;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Registrars;
namespace Deal.Modules.Kanban.Application.Services;
/// <summary>
/// Публичные операции карточек — partial-часть <see cref="CardsService"/> (C32: выделено из общего
/// файла по темам, поведение не менялось): чтение списка/карточки, переносы/корзина/возврат/удаление/
/// очистка колонки, комментарии, mark-seen, счётчики и поиск (leads.py L151–279, L509–551).
/// </summary>
public sealed partial class CardsService
{
// ── Чтение (list_leads/get_lead L151–160) ───────────────────────────────
/// <summary>
/// Карточки колонки или всех колонок дашборда, received_at DESC (list_leads L151–156).
/// </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 L159–160; 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 L177–191 + _move L163–174).
/// </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 (L204–222): прямой move в доску
// прошёл бы мимо снятия у ML веса «спама» возврата из корзины (Ruling 4, L218–221).
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 L194–201): col=trash, is_new=FALSE, matchHits пусто.
/// </summary>
/// <remarks>
/// Карточка уже в корзине — no-op (как в _move L165–166). Журнал action=trash пишется при реальном
/// переносе; обучающий сигнал «спам» 1.0 — только если карточка была НЕ в trash/archive (L198–201,
/// 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 L194–201).
/// </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 L204–222).
/// </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) (L218–221, 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 L225–234): 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 L237–247).
/// </summary>
/// <remarks>Другая колонка (inbox/доска/…) → 400 <see cref="ClearColInvalidDetail"/> (как ValueError L239–240).</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 L259–265) ──────────────────────────────────
/// <summary>
/// Добавляет комментарий к карточке: строка LeadComments (id <c>cm_</c>) + журнал action=comment.
/// </summary>
/// <remarks>
/// Текст Trim'ится (пустой после Trim → 400 <see cref="EmptyCommentDetail"/>, как dashboard_routes L240–241);
/// автор — «Вы»; ответ — полный список комментариев (свежий — «только что», маппинг адаптера). Карточки
/// нет → 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 L250–256 (проверка на 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 L270–274). learning/ml/ai — из IMlClient.StatusAsync (L275–278, план 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 L509–551, LIKE-вариант Ruling 6) ──────────────────────
/// <summary>
/// Поиск карточек: FTS по Cards.SearchTsv + LIKE-дополнение (search L509–551, 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,151 @@
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Registrars;
namespace Deal.Modules.Kanban.Application.Services;
/// <summary>
/// Напоминания «Отложено» — partial-часть <see cref="CardsService"/> (этап 9: тот же домен карточки):
/// set/clear/snooze/check (projects.py L236–282), Ruling 3.
/// </summary>
/// <remarks>
/// Семантика 1:1 с прототипом (Ruling 3): set проверяет выключатель (400-результат «Напоминания об отложенных
/// выключены в настройках») и НЕ проверяет ни стадию карточки (фронт шлёт напоминание только для hold), ни
/// время at (прошлое допустимо — «выстреливает» таким at на ближайшей проверке); clear и snooze выключатель НЕ
/// проверяют; snooze = now + 24 ч. CheckDueRemindersAsync (check_reminders L264–282): выключено → только
/// очистка протухших и пустой список; включено → due-строки hold-карточек помечаются fired и возвращаются
/// списком {id,title,containerId} — SSE-события по ним публикует Api-слой, не сервис. Порядок проверок set —
/// карточка раньше выключателя (404 раньше 400).
/// </remarks>
public sealed partial class CardsService
{
/// <summary>
/// 400 set: напоминания выключены в настройках (set_reminder projects.py L237–238; Ruling 3).
/// </summary>
public const string RemindersDisabledDetail = "Напоминания об отложенных выключены в настройках";
// Ключ публичной настройки-выключателя напоминаний (Ruling 3).
private const string RemindersEnabledKey = SettingsKeys.RemindersEnabled;
// Шаг «напомнить позже» (snooze): +24 часа в epoch-мс (snooze projects.py L257–261).
private const long ReminderSnoozeMs = 86_400_000;
/// <summary>
/// Устанавливает напоминание карточке (POST /api/cards/{cardId}/reminder; set_reminder L236–243).
/// </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 L246–247).
/// </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 L264–282).
/// </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,426 @@
using System.Text.Json;
using Deal.Modules.Cards.Application.Abstractions;
using Deal.Modules.Cards.Application.Dtos;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Registrars;
namespace Deal.Modules.Kanban.Application.Services;
/// <summary>
/// Операции пространства «Выбранные» — partial-часть <see cref="CardsService"/> (этап 9: тот же домен
/// карточки): ручное создание, патч полей тела, ссылки, перенос по контейнерам-стадиям с историей и сбросом
/// напоминания, «взять в работу», очистка «Отклонено» (projects.py L103–231).
/// </summary>
public sealed partial class CardsService
{
/// <summary>
/// 400 перенос по стадии: стадии нет в каталоге (move_stage projects.py L203–204).
/// </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 L137–138).
/// </summary>
public const string EmptyLinkDetail = "Пустая ссылка";
// Схема по умолчанию ссылки, присланной без схемы (add_link L139–140: «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 L174–179).
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 L103–124).
/// </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 L159–187).
/// </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 L133–143).
/// </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 L202–216, 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 L159–187).
// 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,124 @@
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.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
// Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён — внутри пространства имён
// Deal.Modules.Kanban.Application имя ColumnRules резолвится в пространство (CS0234), нужен явный алиас.
using KanbanColumnRules = Deal.Modules.Kanban.Application.ColumnRules.ColumnRules;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Registrars;
namespace Deal.Modules.Kanban.Application.Services;
/// <summary>
/// Сервис карточек — единый домен карточки (дашборд и «Выбранные»): leads.py L151–279 + L509–551
/// и projects.py L103–282 (этап 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 L183–184, L239–240, 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 L239–240).
/// </summary>
public const string ClearColInvalidDetail = "Очищать можно только корзину или архив";
/// <summary>
/// 400 комментарий: пустой текст после Trim (dashboard_routes L240–241).
/// </summary>
public const string EmptyCommentDetail = "Пустой комментарий";
// ── Журнал CardMoves: действия (leads.py _log_learning L40–44) ──────────
// Действие журнала: перенос на доску/в «Неразобранное» (_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 L259–265; «только что» = возраст < 1 мин).
/// Единственный источник строки: адаптер KanbanStore считает её в HumanAge.
/// </summary>
public const string JustNowLabel = "только что";
// Минимальная длина поискового запроса после Trim: q короче → пустой ответ (search L511–512, 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,289 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Registrars;
namespace Deal.Modules.Kanban.Application.Services;
/// <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,98 @@
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Registrars;
namespace Deal.Modules.Kanban.Application.Services;
/// <summary>
/// Пересчёт конверсий бюджетов карточек при смене курсов/целевой валюты (Ruling 7, план Task 12).
/// Реализация порта модуля Settings <see cref="IRatesChangedListener"/> (регистрация — AddKanbanModule).
/// </summary>
/// <remarks>
/// Чистый сервис модуля (без EF/HTTP), повторяет <c>rates.py recompute_conversions</c> (L106–130). Полный
/// пересчёт: кандидаты <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 L112–113). Карточка, нижняя граница которой
/// не конвертируется (нет курса валюты либо budget_from не задан), пропускается ЦЕЛИКОМ — conv-поля не
/// трогаются (rates.py L123–124: 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 L112–113)
}
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», L121–122).
double? convFrom = RatesService.ConvertAmount(budget.From, budget.Cur, targetCurrency, cache.Rates);
if (convFrom is null)
{
continue; // нет курса валюты / нет нижней границы — строку не трогаем (rates.py L123–124)
}
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,131 @@
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Registrars;
namespace Deal.Modules.Kanban.Application.Services;
/// <summary>
/// Чистый детектор типа вложения карточки (Ruling 4; 1:1 files.py detect L31–45).
/// </summary>
/// <remarks>
/// Правила 1:1 с прототипом (files.py L13–28, L31–45): 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 L13–19); наборы не пересекаются, порядок обхода не важен.
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 L21–28).
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 L31–45).
/// </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,82 @@
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Registrars;
namespace Deal.Modules.Kanban.Application.Services;
/// <summary>
/// Сервис правил хранения: автоархив и очистка архива/корзины по срокам — Ruling 8, план Task 10.
/// </summary>
/// <remarks>
/// Чистый сервис модуля (без EF/HTTP), повторяет <c>leads.py tick_storage</c> (L454–493). Настройки
/// 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 L454–493).
/// </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 L462–467). В архив — пачкой одним
// 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 L475–478).
int purgedArchive = await PurgeAsync(
await store.ListExpiredArchiveCandidatesAsync(now.AddDays(-archiveClearDays), ct), ct);
// Очистка корзины по сроку хранения (tick_storage L480–483).
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,299 @@
using System.Text;
using Deal.Modules.Cards.Application.Abstractions;
using Deal.Modules.Cards.Application.Dtos;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Registrars;
namespace Deal.Modules.Kanban.Application.Services;
/// <summary>
/// Чистое ядро ИИ-предложений колонок/ключей — план Task 14 L467–471, Ruling 3.
/// </summary>
/// <remarks>
/// Детерминированная эвристика этапа 3 (реальные предложения ИИ — этап 6): без EF/HTTP/хранилища.
/// Тема колонки — повторяющееся слово-тема по source_msg карточек «Неразобранного»: текст
/// токенизируется на слова (только буквы, ≥3 символов, нижний регистр, минус стоп-слова), слово,
/// встречающееся в ≥2 карточках окна, становится кандидатом в колонку. Группы собираются жадным
/// алгоритмом: самый частотный кандидат (при равенстве — более длинное слово, затем лексикографически)
/// забирает свои карточки, следующие кандидаты получают только неразобранные (как used_msg прототипа,
/// suggest.py L129–143). Выход — до <see cref="MaxColumns"/> планов, каждый с ≥2 карточками; похожие на
/// существующие доски слова пропускаются (suggest.py _similar_exists L55–61). Suggest-keywords — те же
/// частотные слова по выборке карточек (≤60, ≤40 симв.) — прототип suggest_domain_keywords L166–193.
/// Все сравнения/порядки — 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 L172–176, LIMIT 40).
/// </summary>
public const int KeywordsSampleLimit = 40;
/// <summary>
/// Минимум карточек для suggest-keywords: «нужно хотя бы 3» (suggest.py L178).
/// </summary>
public const int MinKeywordsSample = 3;
// Сколько карточек группы учитывается в note-обосновании (текст note, план L469–470).
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 L76–163).
/// </summary>
/// <remarks>
/// Анализируется окно из <see cref="MaxText"/> самых свежих карточек (received_at DESC — как SQL
/// L96–99); карточек в окне меньше <see cref="MinInbox"/> → пусто (причину «мало карточек…» называет
/// адаптер). Слова существующих НЕ-suggested досок исключаются из кандидатов (похожесть имени,
/// suggest.py L55–61). Каждая карточка попадает не более чем в одну группу (used_msg L129–143):
/// кандидаты перебираются от самого частотного, группе достаются только ещё не разобранные карточки.
/// Выход полностью детерминирован: сортировки 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 L129–143); группа без ≥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 L166–193, эвристика 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 L55–61, регистронезависимо).
// 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..];
}