Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Settings.Application;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Нормализация бюджета «при поступлении»: приведение к форме хранения и пересчёт в целевую валюту
|
||||
/// (ai.py clean_budget 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(IsCurrencyLetter).ToArray());
|
||||
if (CurrencyAliases.TryGetValue(letters, out string? fromLetters))
|
||||
{
|
||||
return fromLetters;
|
||||
}
|
||||
|
||||
if (s.Length == 3 && s.All(IsCurrencyLetter))
|
||||
{
|
||||
return s;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Буква кода валюты: латиница A–Z или кириллица А–Я (regex прототипа [^A-ZА-Я], ai.py L286).
|
||||
// c: Символ (строка уже в верхнем регистре).
|
||||
// Возвращает: True — буква, участвующая в распознавании валюты.
|
||||
private static bool IsCurrencyLetter(char c)
|
||||
{
|
||||
return c is >= 'A' and <= 'Z' or >= 'А' and <= 'Я';
|
||||
}
|
||||
|
||||
// Конвертация суммы через курсы к рублю; null rates → null (курсов нет — граница не конвертируется).
|
||||
// amount: Сумма.
|
||||
// fromCurrency: Исходная валюта (код).
|
||||
// toCurrency: Целевая валюта (код).
|
||||
// rates: Курсы к рублю либо null.
|
||||
// Возвращает: Сумма в целевой валюте или null.
|
||||
private static double? Convert(double amount, string fromCurrency, string toCurrency, IReadOnlyDictionary<string, double>? rates)
|
||||
{
|
||||
return rates is null ? null : RatesService.ConvertAmount(amount, fromCurrency, toCurrency, rates);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Deal.Modules.Kanban.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Результат <see cref="FileKindDetector.Detect"/> — kind/label вложения (1:1 files.py detect L31–45).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// kind — категория файла для wire-поля CardFileDto.kind и иконок фронта; label — человекочитаемая
|
||||
/// метка (wire-поле CardFileDto.label). Значения 1:1 с прототипом (files.py KIND_BY_EXT/KIND_LABELS):
|
||||
/// image/video/audio/archive/document/other и «Изображение»/«Видео»/«Аудио»/«Архив»/«Документ»/«Файл».
|
||||
/// </remarks>
|
||||
/// <param name="Kind">Категория файла: <c>image|video|audio|archive|document|other</c>.</param>
|
||||
/// <param name="Label">Человекочитаемая метка («Изображение», «Документ», «Файл» …).</param>
|
||||
public sealed record CardFileKind(
|
||||
string Kind,
|
||||
string Label);
|
||||
@@ -0,0 +1,169 @@
|
||||
using Deal.Contracts.Integrations;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Файлы карточки — partial-часть <see cref="CardsService"/> (этап 9: тот же домен карточки):
|
||||
/// оркестрация файлового хранилища и метаданных FilesJson (files.py 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,106 @@
|
||||
using Deal.Contracts.Integrations;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Cards.Application;
|
||||
using Deal.Modules.Kanban.Application.ColumnRules;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Settings.Application;
|
||||
// Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён — внутри пространства имён
|
||||
// Deal.Modules.Kanban.Application имя ColumnRules резолвится в пространство (CS0234), нужен явный алиас.
|
||||
using KanbanColumnRules = Deal.Modules.Kanban.Application.ColumnRules.ColumnRules;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Приватные помощники <see cref="CardsService"/> — partial-часть (C32: выделено из общего файла,
|
||||
/// поведение не менялось): текст обучающего примера, перенос колонки с журналом, hits правил доски и
|
||||
/// колонка возврата (leads.py 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,341 @@
|
||||
using Deal.Contracts.Integrations;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Cards.Application;
|
||||
using Deal.Modules.Kanban.Application.ColumnRules;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Settings.Application;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Публичные операции карточек — partial-часть <see cref="CardsService"/> (C32: выделено из общего
|
||||
/// файла по темам, поведение не менялось): чтение списка/карточки, переносы/корзина/возврат/удаление/
|
||||
/// очистка колонки, комментарии, mark-seen, счётчики и поиск (leads.py 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, <col>:{…}, 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,145 @@
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Settings.Application;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Напоминания «Отложено» — partial-часть <see cref="CardsService"/> (этап 9: тот же домен карточки):
|
||||
/// set/clear/snooze/check (projects.py 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,421 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Modules.Cards.Application;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Операции пространства «Выбранные» — partial-часть <see cref="CardsService"/> (этап 9: тот же домен
|
||||
/// карточки): ручное создание, патч полей тела, ссылки, перенос по контейнерам-стадиям с историей и сбросом
|
||||
/// напоминания, «взять в работу», очистка «Отклонено» (projects.py 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:<новый>}.
|
||||
/// </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,118 @@
|
||||
using Deal.Contracts.Integrations;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Kanban.Application.ColumnRules;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Settings.Application;
|
||||
// Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён — внутри пространства имён
|
||||
// Deal.Modules.Kanban.Application имя ColumnRules резолвится в пространство (CS0234), нужен явный алиас.
|
||||
using KanbanColumnRules = Deal.Modules.Kanban.Application.ColumnRules.ColumnRules;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Сервис карточек — единый домен карточки (дашборд и «Выбранные»): leads.py 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,217 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application.ColumnRules;
|
||||
|
||||
/// <summary>
|
||||
/// Парсер сумм из текста сообщения (rules.py extract_amounts L93–144, _norm_amount L62–73, _cur_from_tail L76–90).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Сохраняет смысл суммы: «от A до B»/«A–B» → from/to, «до B» → только верхняя граница, одна сумма/«от A»
|
||||
/// → from=to. Валюту ищем сразу после суммы (символ «$ € ₽ ₮ £ ¥» или слово «usd eur rub … руб долл бакс»)
|
||||
/// либо перед ней для «$1 200»; суффикс «к/К» в числе — тысячи («2к» → 2000, «1.5к$» → 1500 USD).
|
||||
/// Суммы БЕЗ валюты игнорируются (прототип L98); повторное срабатывание конструкций на одном тексте
|
||||
/// отсекается занятыми диапазонами (_free/_add L104–112) — порядок проходов 1:1 с прототипом.
|
||||
/// Семантика 1:1, включая особенности: «usdt» словом после числа распознаётся как USD (первым срабатывает
|
||||
/// startswith «usd», rules.py L82), «евро»/«рубли» словами не распознаются (в _CUR_WORDS их нет).
|
||||
/// </remarks>
|
||||
public static class AmountParser
|
||||
{
|
||||
// Цифры суммы: число с разделителями тысяч и опциональным суффиксом «к/К» (rules.py _AMT L58).
|
||||
private const string Amount = @"\d[\d\s\u00a0]*(?:[.,]\d+)?[кkКK]?";
|
||||
|
||||
// Символы валют сразу после суммы (rules.py _CUR_SYMBOLS L15, порядок 1:1).
|
||||
private static readonly (string Symbol, string Code)[] CurrencySymbols =
|
||||
{
|
||||
("$", "USD"), ("€", "EUR"), ("₽", "RUB"), ("₮", "USDT"), ("£", "GBP"), ("¥", "CNY"),
|
||||
};
|
||||
|
||||
// Слова валют сразу после суммы (rules.py _CUR_WORDS L16, порядок 1:1 — важен для «usdt»).
|
||||
private static readonly (string Word, string Code)[] CurrencyWords =
|
||||
{
|
||||
("usd", "USD"), ("eur", "EUR"), ("rub", "RUB"), ("usdt", "USDT"), ("gbp", "GBP"), ("cny", "CNY"),
|
||||
("руб", "RUB"), ("долл", "USD"), ("бакс", "USD"),
|
||||
};
|
||||
|
||||
// Проход 1: словесный диапазон «от A до B» (rules.py L115).
|
||||
private static readonly Regex WordRangeRe = new(@"\bот\s+(" + Amount + @")\s+до\s+(" + Amount + @")", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
|
||||
// Проход 2: «до B» без пары «от…» — только верхняя граница (rules.py L121).
|
||||
private static readonly Regex ToOnlyRe = new(@"\bдо\s+(" + Amount + @")", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
|
||||
// Проход 3: «от A» без верхней границы — считаем одной суммой (rules.py L127).
|
||||
private static readonly Regex FromOnlyRe = new(@"\bот\s+(" + Amount + @")", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
|
||||
// Проход 4: числовой диапазон «A–B» / «A - B» / «A до B» (rules.py L133).
|
||||
private static readonly Regex NumericRangeRe = new("(" + Amount + @")\s*(?:[-–—]|\s+до\s+)\s*(" + Amount + @")", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
|
||||
// Проход 5: одиночные суммы с валютой (rules.py L139).
|
||||
private static readonly Regex AnyAmountRe = new(Amount, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
|
||||
/// <summary>
|
||||
/// Разбирает суммы в тексте (rules.py extract_amounts L93–144).
|
||||
/// </summary>
|
||||
/// <param name="text">Текст сообщения (сырой, как сохранил источник).</param>
|
||||
/// <returns>
|
||||
/// Список распознанных сумм/диапазонов с кодом валюты; пустой список — сумм с валютой нет.
|
||||
/// Порядок — по проходам парсера (1:1 с прототипом), а не по позиции в тексте.
|
||||
/// </returns>
|
||||
public static IReadOnlyList<AmountRange> Parse(string? text)
|
||||
{
|
||||
string t = text ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(t))
|
||||
{
|
||||
return Array.Empty<AmountRange>();
|
||||
}
|
||||
|
||||
var result = new List<AmountRange>();
|
||||
var occupied = new List<(int Start, int End)>();
|
||||
|
||||
// Свободен ли диапазон (не пересекается с уже занятым конструкцией выше) — прототип _free L106–107.
|
||||
static bool IsFree(List<(int Start, int End)> spans, int start, int end)
|
||||
{
|
||||
foreach ((int os, int oe) in spans)
|
||||
{
|
||||
if (start < oe && end > os)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void Add(double? from, double? to, string? cur, int start, int end)
|
||||
{
|
||||
if (cur is not null && (from is not null || to is not null) && IsFree(occupied, start, end))
|
||||
{
|
||||
result.Add(new AmountRange(from, to, cur));
|
||||
occupied.Add((start, end));
|
||||
}
|
||||
}
|
||||
|
||||
// 1) «от A до B» (+ валюта после B).
|
||||
foreach (Match m in WordRangeRe.Matches(t))
|
||||
{
|
||||
double? a = NormalizeAmount(m.Groups[1].Value);
|
||||
double? b = NormalizeAmount(m.Groups[2].Value);
|
||||
string? cur = CurFromTail(t, m.Groups[2].Index + m.Groups[2].Length);
|
||||
if (a is not null && b is not null && cur is not null)
|
||||
{
|
||||
Add(a, b, cur, m.Groups[1].Index, m.Groups[2].Index + m.Groups[2].Length);
|
||||
}
|
||||
}
|
||||
|
||||
// 2) «до B» (без пары «от … до») — верхняя граница.
|
||||
foreach (Match m in ToOnlyRe.Matches(t))
|
||||
{
|
||||
double? b = NormalizeAmount(m.Groups[1].Value);
|
||||
string? cur = CurFromTail(t, m.Groups[1].Index + m.Groups[1].Length);
|
||||
if (b is not null && cur is not null)
|
||||
{
|
||||
Add(null, b, cur, m.Groups[1].Index, m.Groups[1].Index + m.Groups[1].Length);
|
||||
}
|
||||
}
|
||||
|
||||
// 3) «от A» без верхней границы — считаем одной суммой.
|
||||
foreach (Match m in FromOnlyRe.Matches(t))
|
||||
{
|
||||
double? a = NormalizeAmount(m.Groups[1].Value);
|
||||
string? cur = CurFromTail(t, m.Groups[1].Index + m.Groups[1].Length);
|
||||
if (a is not null && cur is not null)
|
||||
{
|
||||
Add(a, a, cur, m.Groups[1].Index, m.Groups[1].Index + m.Groups[1].Length);
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Числовые диапазоны «A–B» / «A - B» / «A до B».
|
||||
foreach (Match m in NumericRangeRe.Matches(t))
|
||||
{
|
||||
double? a = NormalizeAmount(m.Groups[1].Value);
|
||||
double? b = NormalizeAmount(m.Groups[2].Value);
|
||||
string? cur = CurFromTail(t, m.Groups[2].Index + m.Groups[2].Length)
|
||||
?? CurFromTail(t, m.Groups[1].Index + m.Groups[1].Length);
|
||||
if (a is not null && b is not null && cur is not null)
|
||||
{
|
||||
Add(a, b, cur, m.Groups[1].Index, m.Groups[2].Index + m.Groups[2].Length);
|
||||
}
|
||||
}
|
||||
|
||||
// 5) Одиночные суммы с валютой (не вошедшие в конструкции выше).
|
||||
foreach (Match m in AnyAmountRe.Matches(t))
|
||||
{
|
||||
double? a = NormalizeAmount(m.Value);
|
||||
string? cur = CurFromTail(t, m.Index + m.Length);
|
||||
if (a is not null && cur is not null)
|
||||
{
|
||||
Add(a, a, cur, m.Index, m.Index + m.Length);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Число из строки суммы: пробелы и неразрывные пробелы убираем, «,» — десятичный разделитель,
|
||||
// суффикс «к/К» — множитель 1000 (прототип _norm_amount L62–73).
|
||||
// raw: Сырая цифровая часть из regex (может содержать «к»/«К» на конце).
|
||||
// Возвращает: Значение суммы или null при ошибке парсинга.
|
||||
private static double? NormalizeAmount(string raw)
|
||||
{
|
||||
string s = raw.Replace("\u00a0", " ").Replace(" ", string.Empty).Replace(",", ".");
|
||||
double multiplier = 1.0;
|
||||
if (s.Length > 0)
|
||||
{
|
||||
char last = s[^1];
|
||||
if (char.ToLowerInvariant(last) == 'k' || char.ToLowerInvariant(last) == 'к')
|
||||
{
|
||||
multiplier = 1000.0;
|
||||
s = s[..^1];
|
||||
}
|
||||
}
|
||||
|
||||
return double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out double value)
|
||||
? value * multiplier
|
||||
: null;
|
||||
}
|
||||
|
||||
// Код валюты рядом с суммой: символ/слово сразу после позиции либо символ за ≤3 символа до неё
|
||||
// («$1 200», прототип _cur_from_tail L76–90).
|
||||
// text: Весь текст сообщения.
|
||||
// position: Позиция сразу после конца суммы (m.end).
|
||||
// Возвращает: Код валюты или null, если валюты рядом нет.
|
||||
private static string? CurFromTail(string text, int position)
|
||||
{
|
||||
// Окно до 12 символов после суммы (прототип: text[m_start:m_start+12].lower().strip()).
|
||||
int tailLength = Math.Min(12, text.Length - position);
|
||||
string tail = text.Substring(Math.Max(0, position), Math.Max(0, tailLength)).ToLowerInvariant().Trim();
|
||||
|
||||
foreach ((string symbol, string code) in CurrencySymbols)
|
||||
{
|
||||
if (tail.StartsWith(symbol, StringComparison.Ordinal))
|
||||
{
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ((string word, string code) in CurrencyWords)
|
||||
{
|
||||
if (tail.StartsWith(word, StringComparison.Ordinal))
|
||||
{
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
// Валюта перед числом («$1 200»): символ за ≤3 символа до конца суммы (прототип L86–89).
|
||||
int headStart = Math.Max(0, position - 3);
|
||||
string head = text.Substring(headStart, position - headStart).Trim();
|
||||
foreach ((string symbol, string code) in CurrencySymbols)
|
||||
{
|
||||
if (head.EndsWith(symbol, StringComparison.Ordinal))
|
||||
{
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Deal.Modules.Kanban.Application.ColumnRules;
|
||||
|
||||
/// <summary>
|
||||
/// Одна распознанная сумма/диапазон из текста сообщения (rules.py extract_amounts L93–144).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Элемент результата <see cref="AmountParser.Parse"/>: «от A до B»/«A–B» → from=A, to=B; «до B»
|
||||
/// (только верхняя граница) → from=null, to=B; одна сумма/«от A» → from=to=A. <see cref="Cur"/> —
|
||||
/// код валюты (USD/RUB/…) из символа или слова сразу после суммы (для «$1 200» — символа перед ней);
|
||||
/// суммы без валюты парсер игнорирует (прототип, rules.py L98).
|
||||
/// </remarks>
|
||||
/// <param name="From">Нижняя граница (null — «до B» без нижней).</param>
|
||||
/// <param name="To">Верхняя граница (null не возвращается: «от A» даёт from=to=A).</param>
|
||||
/// <param name="Cur">Код валюты (непустой).</param>
|
||||
public sealed record AmountRange(double? From, double? To, string Cur);
|
||||
@@ -0,0 +1,80 @@
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Settings.Application;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application.ColumnRules;
|
||||
|
||||
/// <summary>
|
||||
/// Проверка попадания распознанных сумм в бюджетный диапазон правил колонки (rules.py _amount_in_range L147–173).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// «Интерфейс курсов» — чистый: сравнение в одной валюте не требует курсов, при разных валютах суммы
|
||||
/// конвертируются по словарю «код → курс к рублю» через чистую <see cref="RatesService.ConvertAmount"/>
|
||||
/// (Settings, rates.py L86–103): USDT приравнивается к USD, отсутствующая валюта → сумма пропускается.
|
||||
/// Курсы — параметр функции: чтение кэша ratesCache (Ruling 7) остаётся за вызывающим (CardsService и
|
||||
/// др.), модуль и функция не ходят в БД. Границы из null → диапазон без ограничений (True), как прототип.
|
||||
/// </remarks>
|
||||
public static class BudgetInRange
|
||||
{
|
||||
/// <summary>
|
||||
/// Попадает ли хотя бы одна распознанная сумма в диапазон правил (rules.py L147–173).
|
||||
/// </summary>
|
||||
/// <param name="amounts">Суммы из текста (<see cref="AmountParser.Parse"/>).</param>
|
||||
/// <param name="budget">Бюджет правил колонки (валюта пуста → USD, как в прототипе L157).</param>
|
||||
/// <param name="rates">Курсы к рублю «код → курс» либо null (конвертация невозможна).</param>
|
||||
/// <returns>
|
||||
/// True — есть сумма, которая после конвертации (при необходимости) ≥ from и ≤ to; обе границы null → True;
|
||||
/// пустые amounts или непереводимая валюта → False.
|
||||
/// </returns>
|
||||
public static bool IsInRange(IReadOnlyList<AmountRange> amounts, BudgetRangeDto budget, IReadOnlyDictionary<string, double>? rates)
|
||||
{
|
||||
double? lo = budget.From;
|
||||
double? hi = budget.To;
|
||||
if (lo is null && hi is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
string targetCurrency = string.IsNullOrWhiteSpace(budget.Cur) ? "USD" : budget.Cur.ToUpperInvariant();
|
||||
foreach (AmountRange amount in amounts)
|
||||
{
|
||||
double? rawValue = amount.From ?? amount.To;
|
||||
if (rawValue is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
double? value;
|
||||
if (string.Equals(amount.Cur, targetCurrency, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = rawValue;
|
||||
}
|
||||
else if (rates is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
else
|
||||
{
|
||||
value = RatesService.ConvertAmount(rawValue, amount.Cur, targetCurrency, rates);
|
||||
}
|
||||
|
||||
if (value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lo is not null && value < lo)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hi is not null && value > hi)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application.ColumnRules;
|
||||
|
||||
/// <summary>
|
||||
/// Слова-исключения колонки: veto, применяется ДО положительных правил (rules.py excluded_terms L230–244, is_excluded L247–248).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Исключения — вето колонки: если в содержании сообщения (без ссылок и служебных хвостов,
|
||||
/// <see cref="ContentNormalizer.ContentText"/>) встречается любое из них, карточка в колонку не попадает,
|
||||
/// даже если все положительные условия совпали. Ветo учитывает ТОЛЬКО размещение (страховка
|
||||
/// ContainerAccepts/route — rules.py board_accepts L251–268); сам матчинг положительных критериев
|
||||
/// (MatchText/Hits) exclude не видит, как в прототипе.
|
||||
/// </remarks>
|
||||
public static class ColumnExclusions
|
||||
{
|
||||
/// <summary>
|
||||
/// Какие слова-исключения колонки есть в тексте (rules.py excluded_terms L230–244).
|
||||
/// </summary>
|
||||
/// <param name="rules">Правила колонки (null → пустой список).</param>
|
||||
/// <param name="text">Текст сообщения.</param>
|
||||
/// <returns>Совпавшие термины как сохранены (с регистром пользователя); пусто — совпадений нет.</returns>
|
||||
public static IReadOnlyList<string> ExcludedTerms(ContainerRulesDto? rules, string? text)
|
||||
{
|
||||
var result = new List<string>();
|
||||
if (rules is null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
string lower = ContentNormalizer.ContentText(text ?? string.Empty).ToLowerInvariant();
|
||||
foreach (string? raw in rules.Exclude)
|
||||
{
|
||||
string term = (raw ?? string.Empty).Trim();
|
||||
if (term.Length > 0 && lower.Contains(term.ToLowerInvariant(), StringComparison.Ordinal))
|
||||
{
|
||||
result.Add(term);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Есть ли в тексте слово-исключение колонки (rules.py is_excluded L247–248).
|
||||
/// </summary>
|
||||
/// <param name="rules">Правила колонки.</param>
|
||||
/// <param name="text">Текст сообщения.</param>
|
||||
/// <returns>True — хотя бы один exclude-термин найден (вето).</returns>
|
||||
public static bool IsExcluded(ContainerRulesDto? rules, string? text)
|
||||
{
|
||||
return ExcludedTerms(rules, text).Count > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application.ColumnRules;
|
||||
|
||||
/// <summary>
|
||||
/// Матчинг текста сообщения по правилам колонки: направление/слова/стек/грейд/бюджет (rules.py match_text L176–209,
|
||||
/// score_text L212–227, has_active_rules L322–338).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Колонка — набор опциональных фильтров; пустая группа не участвует. Режим <c>all</c> — совпасть должны
|
||||
/// ВСЕ включённые группы, <c>any</c> — хотя бы одна (пустые группы не считаются совпавшими «сами по себе»,
|
||||
/// иначе колонка с одним фильтром ловила бы всё). Все термины ищутся подстрокой в тексте, приведённом к
|
||||
/// нижнему регистру и очищенном от ссылок (<see cref="ContentNormalizer.ContentText"/>); бюджет
|
||||
/// проверяется по суммам исходного текста через <see cref="AmountParser"/> + <see cref="BudgetInRange"/>.
|
||||
/// Исключения (exclude) здесь НЕ участвуют — это вето размещения (ColumnExclusions/ColumnRules.ContainerAccepts).
|
||||
/// </remarks>
|
||||
public static class ColumnMatcher
|
||||
{
|
||||
/// <summary>
|
||||
/// Соответствует ли текст правилам колонки (rules.py match_text L176–209).
|
||||
/// </summary>
|
||||
/// <param name="rules">Правила колонки (null/пустые → False: пустая колонка не матчит).</param>
|
||||
/// <param name="text">Текст сообщения/карточки.</param>
|
||||
/// <param name="rates">Курсы для конвертации бюджета (см. <see cref="BudgetInRange"/>); null — если бюджет в
|
||||
/// другой валюте, суммы не конвертируются и группа бюджета не совпадает.</param>
|
||||
/// <returns>True — текст прошёл правила в режиме all/any.</returns>
|
||||
public static bool MatchText(ContainerRulesDto? rules, string? text, IReadOnlyDictionary<string, double>? rates = null)
|
||||
{
|
||||
if (rules is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string lower = ContentNormalizer.ContentText(text ?? string.Empty).ToLowerInvariant();
|
||||
IReadOnlyList<string> keywords = NormalizeTerms(rules.Keywords);
|
||||
IReadOnlyList<string> stack = NormalizeTerms(rules.Stack);
|
||||
IReadOnlyList<string> direction = NormalizeTerms(rules.Direction);
|
||||
IReadOnlyList<string> grade = NormalizeTerms(rules.Grade);
|
||||
IReadOnlyList<string> gradeTerms = GradeAliases.ExpandTerms(grade);
|
||||
IReadOnlyList<string> levels = NormalizeTerms(rules.Levels);
|
||||
IReadOnlyList<string> levelTerms = GradeAliases.ExpandTerms(levels);
|
||||
IReadOnlyList<string> locations = NormalizeTerms(rules.Locations);
|
||||
IReadOnlyList<string> types = NormalizeTerms(rules.Types);
|
||||
IReadOnlyList<string> typeTerms = TypeAliases.ExpandTerms(types);
|
||||
bool budgetEnabled = HasBudget(rules.Budget);
|
||||
bool pricesEnabled = HasBudget(rules.Prices);
|
||||
|
||||
bool anyEnabled = keywords.Count > 0 || stack.Count > 0 || direction.Count > 0 || grade.Count > 0
|
||||
|| levels.Count > 0 || locations.Count > 0 || types.Count > 0 || budgetEnabled || pricesEnabled;
|
||||
if (!anyEnabled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Значения групп: пустая (выключенная) группа считается True, но в свёртку не входит.
|
||||
bool keywordsOk = keywords.Count == 0 || ContainsAny(lower, keywords);
|
||||
bool stackOk = stack.Count == 0 || ContainsAny(lower, stack);
|
||||
bool directionOk = direction.Count == 0 || ContainsAny(lower, direction);
|
||||
bool gradeOk = gradeTerms.Count == 0 || ContainsAny(lower, gradeTerms);
|
||||
bool levelsOk = levelTerms.Count == 0 || ContainsAny(lower, levelTerms);
|
||||
bool locationsOk = locations.Count == 0 || ContainsAny(lower, locations);
|
||||
bool typesOk = typeTerms.Count == 0 || ContainsAny(lower, typeTerms);
|
||||
bool budgetOk = !budgetEnabled || BudgetInRange.IsInRange(AmountParser.Parse(text), rules.Budget!, rates);
|
||||
bool pricesOk = !pricesEnabled || BudgetInRange.IsInRange(AmountParser.Parse(text), rules.Prices!, rates);
|
||||
|
||||
if (string.Equals(rules.Mode, "any", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// «любое»: совпасть должна хотя бы одна ВКЛЮЧЁННАЯ (непустая) группа.
|
||||
return (keywords.Count > 0 && keywordsOk)
|
||||
|| (stack.Count > 0 && stackOk)
|
||||
|| (direction.Count > 0 && directionOk)
|
||||
|| (grade.Count > 0 && gradeOk)
|
||||
|| (levels.Count > 0 && levelsOk)
|
||||
|| (locations.Count > 0 && locationsOk)
|
||||
|| (types.Count > 0 && typesOk)
|
||||
|| (budgetEnabled && budgetOk)
|
||||
|| (pricesEnabled && pricesOk);
|
||||
}
|
||||
|
||||
// all: каждая включённая группа обязана совпасть.
|
||||
return (keywords.Count == 0 || keywordsOk)
|
||||
&& (stack.Count == 0 || stackOk)
|
||||
&& (direction.Count == 0 || directionOk)
|
||||
&& (grade.Count == 0 || gradeOk)
|
||||
&& (levels.Count == 0 || levelsOk)
|
||||
&& (locations.Count == 0 || locationsOk)
|
||||
&& (types.Count == 0 || typesOk)
|
||||
&& (!budgetEnabled || budgetOk)
|
||||
&& (!pricesEnabled || pricesOk);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Число совпавших ТЕРМОВ правил (для выбора лучшей колонки; rules.py score_text L212–227).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// В отличие от групп MatchText, здесь считается каждый совпавший терм: direction/keywords/stack — по
|
||||
/// терму, grade — по каждому расширенному синониму (тег с двумя найденными алиасами даёт +2). Бюджет и
|
||||
/// exclude в счёт не входят (как в прототипе — score нужен для сравнения колонок с совпавшими словами).
|
||||
/// </remarks>
|
||||
/// <param name="rules">Правила колонки.</param>
|
||||
/// <param name="text">Текст сообщения.</param>
|
||||
/// <returns>Суммарный балл (0 — текст пуст или совпадений нет).</returns>
|
||||
public static int ScoreText(ContainerRulesDto? rules, string? text)
|
||||
{
|
||||
if (rules is null || string.IsNullOrEmpty(text))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
string lower = ContentNormalizer.ContentText(text).ToLowerInvariant();
|
||||
int score = 0;
|
||||
score += CountMatchedTerms(lower, rules.Direction);
|
||||
score += CountMatchedTerms(lower, rules.Keywords);
|
||||
score += CountMatchedTerms(lower, rules.Stack);
|
||||
score += CountMatchedTerms(lower, rules.Locations);
|
||||
foreach (string alias in GradeAliases.ExpandTerms(NormalizeTerms(rules.Grade)))
|
||||
{
|
||||
if (lower.Contains(alias, StringComparison.Ordinal))
|
||||
{
|
||||
score += 1;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (string alias in GradeAliases.ExpandTerms(NormalizeTerms(rules.Levels)))
|
||||
{
|
||||
if (lower.Contains(alias, StringComparison.Ordinal))
|
||||
{
|
||||
score += 1;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (string alias in TypeAliases.ExpandTerms(NormalizeTerms(rules.Types)))
|
||||
{
|
||||
if (lower.Contains(alias, StringComparison.Ordinal))
|
||||
{
|
||||
score += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Есть ли в правилах хотя бы одна реально работающая группа фильтров (rules.py has_active_rules L322–338).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Нужно для ML и страховки: колонка с активными правилами раскладывается только самими правилами
|
||||
/// (детерминированно); колонка без активных правил принимает любой текст. «Бюджет» активен только при
|
||||
/// заданной границе (from/to), одна валюта без границ активной группой не считается.
|
||||
/// </remarks>
|
||||
/// <param name="rules">Правила колонки (null → False).</param>
|
||||
/// <returns>True — есть непустой терм группы или бюджет с границей.</returns>
|
||||
public static bool HasActiveRules(ContainerRulesDto? rules)
|
||||
{
|
||||
if (rules is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return HasAnyTerm(rules.Direction)
|
||||
|| HasAnyTerm(rules.Keywords)
|
||||
|| HasAnyTerm(rules.Stack)
|
||||
|| HasAnyTerm(rules.Grade)
|
||||
|| HasAnyTerm(rules.Levels)
|
||||
|| HasAnyTerm(rules.Locations)
|
||||
|| HasAnyTerm(rules.Types)
|
||||
|| (rules.Budget is not null && (rules.Budget.From is not null || rules.Budget.To is not null))
|
||||
|| (rules.Prices is not null && (rules.Prices.From is not null || rules.Prices.To is not null));
|
||||
}
|
||||
|
||||
// Есть ли в списке непустой (после trim) терм.
|
||||
// terms: Список термов группы.
|
||||
// Возвращает: True — хотя бы один терм непустой.
|
||||
private static bool HasAnyTerm(IReadOnlyList<string>? terms)
|
||||
{
|
||||
if (terms is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (string? raw in terms)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Совпал ли хотя бы один терм (подстрока в тексте).
|
||||
// lower: Текст в нижнем регистре (очищен от ссылок).
|
||||
// terms: Термы в нижнем регистре.
|
||||
// Возвращает: True — есть совпадение подстрокой.
|
||||
private static bool ContainsAny(string lower, IReadOnlyList<string> terms)
|
||||
{
|
||||
foreach (string term in terms)
|
||||
{
|
||||
if (lower.Contains(term, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Сколько термов списка совпало в тексте (для score).
|
||||
// lower: Текст в нижнем регистре.
|
||||
// rawTerms: Термы как сохранены (регистр не важен).
|
||||
// Возвращает: Число совпавших термов.
|
||||
private static int CountMatchedTerms(string lower, IReadOnlyList<string>? rawTerms)
|
||||
{
|
||||
if (rawTerms is null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int count = 0;
|
||||
foreach (string? raw in rawTerms)
|
||||
{
|
||||
string term = (raw ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (term.Length > 0 && lower.Contains(term, StringComparison.Ordinal))
|
||||
{
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
// Приводит термы группы к нижнему регистру и убирает пустые (прототип match_text L185–188).
|
||||
// terms: Термы как сохранены.
|
||||
// Возвращает: Непустые термы в нижнем регистре.
|
||||
private static IReadOnlyList<string> NormalizeTerms(IReadOnlyList<string>? terms)
|
||||
{
|
||||
var result = new List<string>();
|
||||
if (terms is null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (string? raw in terms)
|
||||
{
|
||||
string term = (raw ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (term.Length > 0)
|
||||
{
|
||||
result.Add(term);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Активна ли бюджетная группа: объект есть и не «пустой» (прототип bool(budget) — {} выключен,
|
||||
// {cur} без границ включён, но границ не задаёт — группа совпадает всегда).
|
||||
// budget: Поле budget правил.
|
||||
// Возвращает: True — группа бюджета участвует в матчинге.
|
||||
private static bool HasBudget(BudgetRangeDto? budget)
|
||||
{
|
||||
return budget is not null
|
||||
&& (budget.From is not null || budget.To is not null || !string.IsNullOrWhiteSpace(budget.Cur));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application.ColumnRules;
|
||||
|
||||
/// <summary>
|
||||
/// Чистые правила колонок — единая точка входа модуля (Ruling 2, Task 3; rules.py board_accepts L251–268,
|
||||
/// hits_for_board L311–319, has_active_rules L322–338, describe L341–368).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Все функции — чистые (без EF/HTTP/хранилища): правила передаются объектом <see cref="ContainerRulesDto"/>,
|
||||
/// текст — строкой, курсы для бюджетной конвертации — словарём (загрузка кэша ratesCache за вызывающим,
|
||||
/// Ruling 7). Соответствие прототипу: <see cref="ComputeHits"/> = hits_for_board (для колонок без активных
|
||||
/// правил и null-правил — пустой список), <see cref="ContainerAccepts"/> = board_accepts (вето исключений →
|
||||
/// колонка без активных правил принимает любой текст → иначе MatchText), <see cref="Describe"/> = describe.
|
||||
/// Для служебных колонок (inbox/archive/trash, Ruling 2) правила не вычисляются: сервис карточек
|
||||
/// вызывает ComputeHits только для колонок-досок (правила null/отсутствуют → []).
|
||||
/// </remarks>
|
||||
public static class ColumnRules
|
||||
{
|
||||
/// <summary>
|
||||
/// Пропускает ли колонка этот текст (страховка «ИИ/ML не кладут в отфильтрованную колонку»,
|
||||
/// rules.py board_accepts L251–268).
|
||||
/// </summary>
|
||||
/// <param name="rules">Правила колонки; null = «правил нет» (принимает любой текст).</param>
|
||||
/// <param name="text">Текст сообщения/карточки.</param>
|
||||
/// <param name="rates">Курсы для конвертации бюджета (см. <see cref="ColumnMatcher.MatchText"/>).</param>
|
||||
/// <returns>
|
||||
/// False — сработало слово-исключение (veto) либо текст не прошёл активные правила;
|
||||
/// True — правил нет/неактивны или текст прошёл правила.
|
||||
/// </returns>
|
||||
public static bool ContainerAccepts(ContainerRulesDto? rules, string? text, IReadOnlyDictionary<string, double>? rates = null)
|
||||
{
|
||||
if (ColumnExclusions.IsExcluded(rules, text))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!ColumnMatcher.HasActiveRules(rules))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return ColumnMatcher.MatchText(rules, text, rates);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Совпал ли текст с правилами колонки (обёртка над <see cref="ColumnMatcher.MatchText"/>).
|
||||
/// </summary>
|
||||
/// <param name="rules">Правила колонки.</param>
|
||||
/// <param name="text">Текст сообщения.</param>
|
||||
/// <param name="rates">Курсы для конвертации бюджета.</param>
|
||||
/// <returns>True — текст прошёл правила в режиме all/any (исключения не учитываются).</returns>
|
||||
public static bool Matches(ContainerRulesDto? rules, string? text, IReadOnlyDictionary<string, double>? rates = null)
|
||||
{
|
||||
return ColumnMatcher.MatchText(rules, text, rates);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Есть ли в правилах активная группа фильтров (rules.py has_active_rules L322–338).
|
||||
/// </summary>
|
||||
/// <param name="rules">Правила колонки.</param>
|
||||
/// <returns>True — правила реально фильтруют (см. <see cref="ColumnMatcher.HasActiveRules"/>).</returns>
|
||||
public static bool HasActiveRules(ContainerRulesDto? rules)
|
||||
{
|
||||
return ColumnMatcher.HasActiveRules(rules);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Совпавшие критерии правил для размещения карточки в колонку-доску (rules.py hits_for_board L311–319).
|
||||
/// </summary>
|
||||
/// <param name="rules">Правила колонки-доски; null — доски нет/правил нет.</param>
|
||||
/// <param name="text">Текст карточки для правил (source_msg или title — выбор за вызывающим, leads.py L167).</param>
|
||||
/// <param name="rates">Курсы для конвертации бюджета.</param>
|
||||
/// <returns>
|
||||
/// Список совпавших критериев (MatchHitDto label/term/word); для пустых/null правил и колонок без
|
||||
/// активных правил — [] (Ruling 2). Исключения (veto) в список не входят (прототип hits L271–296).
|
||||
/// </returns>
|
||||
public static IReadOnlyList<MatchHitDto> ComputeHits(ContainerRulesDto? rules, string? text, IReadOnlyDictionary<string, double>? rates = null)
|
||||
{
|
||||
if (!ColumnMatcher.HasActiveRules(rules))
|
||||
{
|
||||
return Array.Empty<MatchHitDto>();
|
||||
}
|
||||
|
||||
return MatchHitBuilder.BuildHits(rules, text, rates);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Русская расшифровка правил для UI/note (обёртка над <see cref="RulesDescriber.Describe"/>).
|
||||
/// </summary>
|
||||
/// <param name="rules">Правила колонки.</param>
|
||||
/// <returns>«все условия · стек: …» или «без правил (решает ИИ/ML)».</returns>
|
||||
public static string Describe(ContainerRulesDto? rules)
|
||||
{
|
||||
return RulesDescriber.Describe(rules);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application.ColumnRules;
|
||||
|
||||
/// <summary>
|
||||
/// Нормализация текста сообщения перед матчингом правил колонок (rules.py content_text L51–54).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Вырезает markdown-ссылки <c>[текст](url)</c> и голые URL: иначе фильтр ловит слова из трекерных
|
||||
/// хвостов и служебных строк адресов (например, «desktop» в utm_medium=member_desktop) — в колонку
|
||||
/// попадает мусор, не имеющий отношения к содержанию сообщения. Нормализатор вызывается внутри
|
||||
/// matcher/hits/exclude-функций (как в прототипе) — вызывающий код передаёт «сырой» текст карточки.
|
||||
/// Регистр не меняется: lower-преобразование выполняют функции сравнения (прототип content_text L51–54).
|
||||
/// </remarks>
|
||||
public static class ContentNormalizer
|
||||
{
|
||||
// Markdown-ссылка [текст](url) (правила прототипа _MD_URL_RE L47).
|
||||
private static readonly Regex MarkdownLink = new(@"\[[^\]]*\]\([^)\s]+\)");
|
||||
|
||||
// Голый URL http(s)://… или www.… (правила прототипа _RAW_URL_RE L48).
|
||||
private static readonly Regex RawUrl = new(@"https?://[^\s<>""']+|www\.[^\s<>""']+");
|
||||
|
||||
/// <summary>
|
||||
/// Возвращает текст с вырезанными ссылками (markdown- и голыми), прототип content_text L51–54.
|
||||
/// </summary>
|
||||
/// <param name="text">Исходный текст (null → пустая строка).</param>
|
||||
/// <returns>Текст, где на месте ссылок — пробелы (слова ссылок не участвуют в поиске).</returns>
|
||||
public static string ContentText(string? text)
|
||||
{
|
||||
string s = text ?? string.Empty;
|
||||
s = MarkdownLink.Replace(s, " ");
|
||||
return RawUrl.Replace(s, " ");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace Deal.Modules.Kanban.Application.ColumnRules;
|
||||
|
||||
/// <summary>
|
||||
/// Синонимы грейдов/уровней правил колонки (rules.py _GRADE_ALIASES L20–27, _grade_terms L30–40).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Тег из правил расширяется синонимами, чтобы «middle» находил и «mid», и «мидл», а «сеньор» —
|
||||
/// senior и т.п. Неизвестный тег участвует как есть (в нижнем регистре) — это позволяет хранить в
|
||||
/// группе grade произвольные слова (например, «middle+» ищется буквально). Списки — 1:1 с прототипом;
|
||||
/// используются ColumnMatcher (группа grade) и MatchHitBuilder (word-совпадение грейда).
|
||||
/// </remarks>
|
||||
public static class GradeAliases
|
||||
{
|
||||
// Карта «тег → синонимы» в нижнем регистре (rules.py L20–27).
|
||||
private static readonly IReadOnlyDictionary<string, string[]> Aliases = new Dictionary<string, string[]>
|
||||
{
|
||||
["junior"] = new[] { "junior", "джун", "джуниор" },
|
||||
["middle"] = new[] { "middle", "mid", "мидл" },
|
||||
["senior"] = new[] { "senior", "сеньор", "сеньйор" },
|
||||
["lead"] = new[] { "lead", "тимлид", "тиэмлид", "team lead", "teamlead", "tech lead", "техлид" },
|
||||
["architect"] = new[] { "architect", "архитектор" },
|
||||
["intern"] = new[] { "intern", "стажёр", "стажер", "trainee" },
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Расширяет теги правил синонимами (прототип _grade_terms L30–40).
|
||||
/// </summary>
|
||||
/// <param name="tags">Теги группы grade (как сохранил пользователь/фронт).</param>
|
||||
/// <returns>
|
||||
/// Список термов для поиска: для известного тега — его синонимы, для неизвестного — сам тег в
|
||||
/// нижнем регистре; пустые теги пропускаются.
|
||||
/// </returns>
|
||||
public static IReadOnlyList<string> ExpandTerms(IEnumerable<string?>? tags)
|
||||
{
|
||||
var result = new List<string>();
|
||||
if (tags is null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (string? raw in tags)
|
||||
{
|
||||
string key = (raw ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (key.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Aliases.TryGetValue(key, out string[]? synonyms))
|
||||
{
|
||||
result.AddRange(synonyms);
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application.ColumnRules;
|
||||
|
||||
/// <summary>
|
||||
/// Список совпавших критериев правил — «почему карточка в колонке» (rules.py hits L271–296, _budget_label L299–308).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Для каждой группы правил колонки находит совпавшие термины и возвращает их в формате MatchHitDto
|
||||
/// (label/term/word?): direction → «Направление», keywords → «Слова», stack → «Стек», grade → «Грейд/уровень»
|
||||
/// (term — тег как сохранён, word — фактически найденный синоним), budget → «Бюджет» (term — человекочитаемый
|
||||
/// диапазон «от X до Y CUR»). Ключевые слова ищутся по всему тексту сообщения (включая стек, требования и
|
||||
/// «будет плюсом»). Грейд — только первый найденный синоним тега; exclude в hits не входит (это вето
|
||||
/// размещения), пустые правила → пустой список.
|
||||
/// </remarks>
|
||||
public static class MatchHitBuilder
|
||||
{
|
||||
// Метка группы «направление» (как в UI и прототипе).
|
||||
private const string DirectionLabel = "Направление";
|
||||
|
||||
// Метка группы «ключевые слова».
|
||||
private const string KeywordsLabel = "Слова";
|
||||
|
||||
// Метка группы «стек».
|
||||
private const string StackLabel = "Стек";
|
||||
|
||||
// Метка группы «грейд/уровень».
|
||||
private const string GradeLabel = "Грейд/уровень";
|
||||
|
||||
// Метка отдельной группы «уровень» (§6.3).
|
||||
private const string LevelsLabel = "Уровень";
|
||||
|
||||
// Метка группы «локация» (§6.3).
|
||||
private const string LocationsLabel = "Локация";
|
||||
|
||||
// Метка группы «тип» (§6.3).
|
||||
private const string TypesLabel = "Тип";
|
||||
|
||||
// Метка группы «бюджет».
|
||||
private const string BudgetLabel = "Бюджет";
|
||||
|
||||
// Метка отдельной группы «цена» (§6.3).
|
||||
private const string PricesLabel = "Цена";
|
||||
|
||||
/// <summary>
|
||||
/// Совпавшие критерии фильтра колонки (rules.py hits L271–296).
|
||||
/// </summary>
|
||||
/// <param name="rules">Правила колонки (null → пустой список).</param>
|
||||
/// <param name="text">Текст сообщения.</param>
|
||||
/// <param name="rates">Курсы для конвертации бюджета (см. <see cref="BudgetInRange"/>).</param>
|
||||
/// <returns>Список совпадений по группам; порядок — direction → keywords → stack → grade → levels →
|
||||
/// locations → types → budget → prices.</returns>
|
||||
public static IReadOnlyList<MatchHitDto> BuildHits(ContainerRulesDto? rules, string? text, IReadOnlyDictionary<string, double>? rates = null)
|
||||
{
|
||||
var result = new List<MatchHitDto>();
|
||||
if (rules is null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
string lower = ContentNormalizer.ContentText(text ?? string.Empty).ToLowerInvariant();
|
||||
|
||||
// Направление / слова / стек / локация: каждый совпавший терм — отдельный hit (term как сохранён).
|
||||
result.AddRange(MatchedTermHits(rules.Direction, DirectionLabel, lower));
|
||||
result.AddRange(MatchedTermHits(rules.Keywords, KeywordsLabel, lower));
|
||||
result.AddRange(MatchedTermHits(rules.Stack, StackLabel, lower));
|
||||
result.AddRange(MatchedTermHits(rules.Locations, LocationsLabel, lower));
|
||||
|
||||
// Грейд и уровень: по тегу — первый найденный синоним (rules.py L288–292).
|
||||
AddAliasHits(result, rules.Grade, GradeAliases.ExpandTerms, GradeLabel, lower);
|
||||
AddAliasHits(result, rules.Levels, GradeAliases.ExpandTerms, LevelsLabel, lower);
|
||||
|
||||
// Тип: синонимы «vacancy/freelance/announcement» (TypeAliases).
|
||||
AddAliasHits(result, rules.Types, TypeAliases.ExpandTerms, TypesLabel, lower);
|
||||
|
||||
// Бюджет/цена: один hit с человекочитаемым диапазоном, если сумма в границах (rules.py L293–295).
|
||||
AddRangeHit(result, rules.Budget, BudgetLabel, text, rates);
|
||||
AddRangeHit(result, rules.Prices, PricesLabel, text, rates);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Добавляет hits группы с синонимами: на каждый тег — первый найденный синоним (term — тег,
|
||||
// word — фактический синоним).
|
||||
// result: Накопитель hits.
|
||||
// rawTerms: Теги группы как сохранены.
|
||||
// expand: Функция расширения тега синонимами.
|
||||
// label: Метка группы.
|
||||
// lower: Текст в нижнем регистре.
|
||||
private static void AddAliasHits(
|
||||
List<MatchHitDto> result,
|
||||
IReadOnlyList<string>? rawTerms,
|
||||
Func<IEnumerable<string?>?, IReadOnlyList<string>> expand,
|
||||
string label,
|
||||
string lower)
|
||||
{
|
||||
if (rawTerms is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (string? raw in rawTerms)
|
||||
{
|
||||
string term = (raw ?? string.Empty).Trim();
|
||||
if (term.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (string alias in expand(new[] { raw }))
|
||||
{
|
||||
if (lower.Contains(alias, StringComparison.Ordinal))
|
||||
{
|
||||
result.Add(new MatchHitDto(label, term, alias));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Добавляет hit диапазона (бюджет/цена), если сумма текста попадает в границы.
|
||||
// result: Накопитель hits.
|
||||
// range: Диапазон группы (null — группа выключена).
|
||||
// label: Метка группы.
|
||||
// text: Текст сообщения.
|
||||
// rates: Курсы конвертации.
|
||||
private static void AddRangeHit(
|
||||
List<MatchHitDto> result,
|
||||
BudgetRangeDto? range,
|
||||
string label,
|
||||
string? text,
|
||||
IReadOnlyDictionary<string, double>? rates)
|
||||
{
|
||||
if (range is not null
|
||||
&& (range.From is not null || range.To is not null || !string.IsNullOrWhiteSpace(range.Cur))
|
||||
&& BudgetInRange.IsInRange(AmountParser.Parse(text), range, rates))
|
||||
{
|
||||
result.Add(new MatchHitDto(label, DescribeRange(range), Word: null));
|
||||
}
|
||||
}
|
||||
|
||||
// Совпавшие термины текстовой группы (direction/keywords/stack) — прототип L283–287.
|
||||
// rawTerms: Термы как сохранены.
|
||||
// label: Метка группы.
|
||||
// lower: Текст в нижнем регистре.
|
||||
// Возвращает: Hits по каждому совпавшему терму (term сохраняет регистр пользователя).
|
||||
private static IEnumerable<MatchHitDto> MatchedTermHits(IReadOnlyList<string>? rawTerms, string label, string lower)
|
||||
{
|
||||
if (rawTerms is null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (string? raw in rawTerms)
|
||||
{
|
||||
string term = (raw ?? string.Empty).Trim();
|
||||
if (term.Length > 0 && lower.Contains(term.ToLowerInvariant(), StringComparison.Ordinal))
|
||||
{
|
||||
yield return new MatchHitDto(label, term, Word: null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Человекочитаемый диапазон бюджета для term-а hit'а (rules.py _budget_label L299–308).
|
||||
// budget: Бюджет правил.
|
||||
// Возвращает: «от X до Y CUR» / «до X CUR» / «от X CUR» / «бюджет».
|
||||
private static string DescribeRange(BudgetRangeDto budget)
|
||||
{
|
||||
string cur = (budget.Cur ?? string.Empty).ToUpperInvariant();
|
||||
if (budget.From is not null && budget.To is not null)
|
||||
{
|
||||
return $"от {FormatAmount(budget.From.Value)} до {FormatAmount(budget.To.Value)} {cur}".Trim();
|
||||
}
|
||||
|
||||
if (budget.To is not null)
|
||||
{
|
||||
return $"до {FormatAmount(budget.To.Value)} {cur}".Trim();
|
||||
}
|
||||
|
||||
if (budget.From is not null)
|
||||
{
|
||||
return $"от {FormatAmount(budget.From.Value)} {cur}".Trim();
|
||||
}
|
||||
|
||||
return "бюджет";
|
||||
}
|
||||
|
||||
// Формат числа как python %g (до 6 значащих цифр, без «.0»): «1000», «1500.5».
|
||||
// value: Число.
|
||||
// Возвращает: Строка по инвариантной культуре.
|
||||
private static string FormatAmount(double value)
|
||||
{
|
||||
return value.ToString("G6", System.Globalization.CultureInfo.InvariantCulture).Replace('E', 'e');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Globalization;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application.ColumnRules;
|
||||
|
||||
/// <summary>
|
||||
/// Человекочитаемое описание правил колонки для note/подсказки (rules.py describe L341–368).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Формат 1:1 с прототипом: «все условия · направление: …; стек: …; слова: …; грейд: …; исключено: …;
|
||||
/// бюджет: …» (режим any → «любое из условий · …»). Списки обрезаются (direction ≤6, остальные ≤8), бюджет
|
||||
/// печатается границами «lo–hi CUR» с удалением пробелов у тире. Правил нет → «без правил (решает ИИ/ML)».
|
||||
/// </remarks>
|
||||
public static class RulesDescriber
|
||||
{
|
||||
/// <summary>
|
||||
/// Описывает правила колонки (rules.py describe L341–368).
|
||||
/// </summary>
|
||||
/// <param name="rules">Правила колонки (null/пустые → «без правил (решает ИИ/ML)»).</param>
|
||||
/// <returns>Строка-расшифровка для UI/промпта.</returns>
|
||||
public static string Describe(ContainerRulesDto? rules)
|
||||
{
|
||||
if (rules is null)
|
||||
{
|
||||
return NoRulesText;
|
||||
}
|
||||
|
||||
var parts = new List<string>();
|
||||
if (rules.Direction.Count > 0)
|
||||
{
|
||||
parts.Add($"направление: {JoinLimited(rules.Direction, DirectionLimit)}");
|
||||
}
|
||||
|
||||
if (rules.Stack.Count > 0)
|
||||
{
|
||||
parts.Add($"стек: {JoinLimited(rules.Stack, TermsLimit)}");
|
||||
}
|
||||
|
||||
if (rules.Keywords.Count > 0)
|
||||
{
|
||||
parts.Add($"слова: {JoinLimited(rules.Keywords, TermsLimit)}");
|
||||
}
|
||||
|
||||
if (rules.Grade.Count > 0)
|
||||
{
|
||||
parts.Add($"грейд: {JoinLimited(rules.Grade, TermsLimit)}");
|
||||
}
|
||||
|
||||
if (rules.Levels is { Count: > 0 })
|
||||
{
|
||||
parts.Add($"уровень: {JoinLimited(rules.Levels, TermsLimit)}");
|
||||
}
|
||||
|
||||
if (rules.Locations is { Count: > 0 })
|
||||
{
|
||||
parts.Add($"локация: {JoinLimited(rules.Locations, TermsLimit)}");
|
||||
}
|
||||
|
||||
if (rules.Types is { Count: > 0 })
|
||||
{
|
||||
parts.Add($"тип: {JoinLimited(rules.Types, TermsLimit)}");
|
||||
}
|
||||
|
||||
if (rules.Exclude.Count > 0)
|
||||
{
|
||||
parts.Add($"исключено: {JoinLimited(rules.Exclude, TermsLimit)}");
|
||||
}
|
||||
|
||||
AddRangePart(parts, rules.Budget, "бюджет");
|
||||
AddRangePart(parts, rules.Prices, "цена");
|
||||
|
||||
if (parts.Count == 0)
|
||||
{
|
||||
return NoRulesText;
|
||||
}
|
||||
|
||||
string mode = string.Equals(rules.Mode, "any", StringComparison.OrdinalIgnoreCase)
|
||||
? AnyModePrefix
|
||||
: AllModePrefix;
|
||||
return mode + " · " + string.Join("; ", parts);
|
||||
}
|
||||
|
||||
// Текст колонки без правил (прототип L366).
|
||||
private const string NoRulesText = "без правил (решает ИИ/ML)";
|
||||
|
||||
// Префикс режима «все условия» (прототип L367).
|
||||
private const string AllModePrefix = "все условия";
|
||||
|
||||
// Префикс режима «любое из условий».
|
||||
private const string AnyModePrefix = "любое из условий";
|
||||
|
||||
// Лимит термов направления в описании (прототип L350: direction[:6]).
|
||||
private const int DirectionLimit = 6;
|
||||
|
||||
// Лимит термов остальных групп (прототип L352–359: stack/kw/grade/exclude[:8]).
|
||||
private const int TermsLimit = 8;
|
||||
|
||||
// Добавляет в описание диапазон группы (бюджет/цена), если задана хотя бы одна граница.
|
||||
// parts: Накопитель частей описания.
|
||||
// range: Диапазон группы (null — группа выключена).
|
||||
// title: Подпись группы («бюджет»/«цена»).
|
||||
private static void AddRangePart(List<string> parts, BudgetRangeDto? range, string title)
|
||||
{
|
||||
if (range is null || (range.From is null && range.To is null))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string lo = range.From is not null ? range.From.Value.ToString(CultureInfo.InvariantCulture) : string.Empty;
|
||||
string hi = range.To is not null ? range.To.Value.ToString(CultureInfo.InvariantCulture) : string.Empty;
|
||||
string cur = range.Cur ?? string.Empty;
|
||||
parts.Add($"{title}: {lo}–{hi} {cur}".Replace("– ", "–").Replace(" –", "–"));
|
||||
}
|
||||
|
||||
// Соединяет первые N термов группы через «, » (прототип describe).
|
||||
// terms: Термы группы.
|
||||
// limit: Максимум термов.
|
||||
// Возвращает: Строка «t1, t2, …».
|
||||
private static string JoinLimited(IReadOnlyList<string> terms, int limit)
|
||||
{
|
||||
int count = Math.Min(terms.Count, limit);
|
||||
return string.Join(", ", terms.Take(count));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
namespace Deal.Modules.Kanban.Application.ColumnRules;
|
||||
|
||||
/// <summary>
|
||||
/// Синонимы типов заявки для группы <c>types</c> правил колонки и глобальных исключений (§6.3).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Тег из правил расширяется синонимами, чтобы «vacancy» находил «вакансия»/«найм», «freelance» —
|
||||
/// «заказ»/«проект», «announcement» — «объявление». Неизвестный тег участвует как есть (в нижнем
|
||||
/// регистре) — можно хранить произвольные слова («срочно»). Используется <see cref="ColumnMatcher"/>
|
||||
/// (группа types) и <c>MatchHitBuilder</c> (word-совпадение типа).
|
||||
/// </remarks>
|
||||
public static class TypeAliases
|
||||
{
|
||||
// Карта «тег типа → синонимы» в нижнем регистре.
|
||||
private static readonly IReadOnlyDictionary<string, string[]> Aliases = new Dictionary<string, string[]>
|
||||
{
|
||||
["vacancy"] = new[] { "вакансия", "вакансию", "вакансии", "вакант", "найм", "нанимаем", "full-time", "занятость", "в штат", "в команду" },
|
||||
["freelance"] = new[] { "фриланс", "заказ", "закажу", "проект", "подработка", "подряд", "разово", "услуга" },
|
||||
["announcement"] = new[] { "объявление", "продам", "куплю", "отдам", "продаётся", "продается" },
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Расширяет теги типов синонимами.
|
||||
/// </summary>
|
||||
/// <param name="tags">Теги группы types (как сохранил пользователь/фронт).</param>
|
||||
/// <returns>
|
||||
/// Список термов для поиска: для известного тега — его синонимы, для неизвестного — сам тег в
|
||||
/// нижнем регистре; пустые теги пропускаются.
|
||||
/// </returns>
|
||||
public static IReadOnlyList<string> ExpandTerms(IEnumerable<string?>? tags)
|
||||
{
|
||||
var result = new List<string>();
|
||||
if (tags is null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (string? raw in tags)
|
||||
{
|
||||
string key = (raw ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (key.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Aliases.TryGetValue(key, out string[]? synonyms))
|
||||
{
|
||||
result.AddRange(synonyms);
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace Deal.Modules.Kanban.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Реестр видов контейнеров (этап 9, T4): роль колонки в пространстве.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>board</c> — пользовательская колонка-фильтр (создаёт пользователь/ИИ); <c>stage</c> — стадия
|
||||
/// «Выбранных»; <c>service</c> — служебная зона (inbox/archive/trash); <c>terminal</c> — терминальная
|
||||
/// стадия (finished/rejected). Вид контейнера влияет на показ и допустимые переходы.
|
||||
/// </remarks>
|
||||
public static class ContainerKinds
|
||||
{
|
||||
/// <summary>
|
||||
/// Пользовательская колонка-фильтр дашборда.
|
||||
/// </summary>
|
||||
public const string Board = "board";
|
||||
|
||||
/// <summary>
|
||||
/// Стадия пространства «Выбранные».
|
||||
/// </summary>
|
||||
public const string Stage = "stage";
|
||||
|
||||
/// <summary>
|
||||
/// Служебная зона (неразобранное/архив/корзина).
|
||||
/// </summary>
|
||||
public const string Service = "service";
|
||||
|
||||
/// <summary>
|
||||
/// Терминальная стадия (выполнено/отклонено).
|
||||
/// </summary>
|
||||
public const string Terminal = "terminal";
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Deal.Modules.Kanban.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Реестр пространств контейнеров (этап 9, T4): где показывается карточка.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Пространство — свойство контейнера, не карточки. Дашборд (<c>dashboard</c>) — служебные зоны и
|
||||
/// пользовательские колонки; «Выбранные» (<c>selected</c>) — каталог стадий. Карточка живёт в одном
|
||||
/// пространстве: её контейнер однозначно определяет вид.
|
||||
/// </remarks>
|
||||
public static class ContainerSpaces
|
||||
{
|
||||
/// <summary>
|
||||
/// Пространство дашборда (служебные зоны и пользовательские колонки-доски).
|
||||
/// </summary>
|
||||
public const string Dashboard = "dashboard";
|
||||
|
||||
/// <summary>
|
||||
/// Пространство «Выбранные» (стадии работы над карточкой).
|
||||
/// </summary>
|
||||
public const string Selected = "selected";
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Settings.Application;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Сервис контейнеров (колонок/стадий/зон) и состояния колонок (colState) — этап 9, T4.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Приходит на смену BoardsService: Containers — единственный реестр колонок; сервис выполняет CRUD
|
||||
/// (список по пространству, создание, патч, удаление с переносом карточек в inbox, reorder, приём
|
||||
/// ИИ-предложений) и держит colState (KV-ключ <see cref="SettingsKeys.ColState"/>). Счётчики карточек
|
||||
/// контейнера заполняются при чтении из реестра Cards. Чистый сервис модуля (без EF/HTTP).
|
||||
/// </remarks>
|
||||
/// <param name="store">Порт хранилища (контейнеры/карточки тенанта).</param>
|
||||
/// <param name="settings">KV-хранилище настроек тенанта (ключ colState).</param>
|
||||
public sealed class ContainersService(ICardStore store, ISettingsStore settings)
|
||||
{
|
||||
/// <summary>
|
||||
/// Имя по умолчанию: пустое имя → «Новая колонка».
|
||||
/// </summary>
|
||||
public const string DefaultContainerName = "Новая колонка";
|
||||
|
||||
// Палитра колонок по умолчанию: цвет = Palette[order % 8], если цвет не задан.
|
||||
private static readonly string[] Palette =
|
||||
["#818cf8", "#fbbf24", "#22d3ee", "#e879f9", "#34d399", "#fb7185", "#a78bfa", "#f97316"];
|
||||
|
||||
// Опции JSON colState: camelCase при записи, терпимость регистра при чтении; null-поля не пишутся.
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
// ── Контейнеры ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Контейнеры пространства в порядке показа, со счётчиками карточек (этап 9, T4).
|
||||
/// </summary>
|
||||
/// <param name="space">Пространство (dashboard/selected) либо null — все контейнеры.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Контейнеры с заполненными counts; пусто — контейнеров нет.</returns>
|
||||
public async Task<IReadOnlyList<ContainerDto>> ListAsync(string? space, CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<ContainerDto> containers = await store.ListContainersAsync(space, ct);
|
||||
IReadOnlyDictionary<string, CardColumnCountDto> counts = await store.CountCardsByColAsync(ct);
|
||||
return containers
|
||||
.Select(container => container with
|
||||
{
|
||||
Counts = counts.TryGetValue(container.Id, out CardColumnCountDto? count)
|
||||
? new ContainerCountsDto(count.Count, count.New)
|
||||
: new ContainerCountsDto(0, 0),
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Один контейнер со счётчиками; null — контейнера нет.
|
||||
/// </summary>
|
||||
/// <param name="containerId">Id контейнера.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Контейнер со счётчиками либо null.</returns>
|
||||
public async Task<ContainerDto?> GetAsync(string containerId, CancellationToken ct)
|
||||
{
|
||||
ContainerDto? container = await store.GetContainerAsync(containerId, ct);
|
||||
if (container is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
IReadOnlyDictionary<string, CardColumnCountDto> counts = await store.CountCardsByColAsync(ct);
|
||||
ContainerCountsDto containerCounts = counts.TryGetValue(container.Id, out CardColumnCountDto? count)
|
||||
? new ContainerCountsDto(count.Count, count.New)
|
||||
: new ContainerCountsDto(0, 0);
|
||||
return container with { Counts = containerCounts };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт контейнер с дефолтами: order = MAX+1, цвет палитры, имя «Новая колонка» при пустом.
|
||||
/// </summary>
|
||||
/// <param name="create">Вход создания (имя обязательно; цвет/правила/пространство/вид — опциональны).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Созданный контейнер (id <c>b_</c> + 12 hex).</returns>
|
||||
public async Task<ContainerDto> CreateAsync(ContainerCreateDto create, CancellationToken ct)
|
||||
{
|
||||
string space = string.IsNullOrWhiteSpace(create.Space) ? ContainerSpaces.Dashboard : create.Space;
|
||||
IReadOnlyList<ContainerDto> existing = await store.ListContainersAsync(space, ct);
|
||||
int order = existing.Count == 0 ? 0 : existing.Max(container => container.Order) + 1;
|
||||
|
||||
var container = new ContainerDto
|
||||
{
|
||||
Id = PrefixId.New(KanbanIdPrefixes.Board),
|
||||
Name = BuildName(create.Name),
|
||||
Description = create.Description.Trim(),
|
||||
Color = string.IsNullOrEmpty(create.Color) ? Palette[order % Palette.Length] : create.Color,
|
||||
Order = order,
|
||||
Space = space,
|
||||
Kind = string.IsNullOrWhiteSpace(create.Kind) ? ContainerKinds.Board : create.Kind,
|
||||
Suggested = create.Suggested,
|
||||
Note = create.Note ?? string.Empty,
|
||||
Rules = NormalizeRules(create.Rules),
|
||||
Policy = new ContainerPolicyDto(),
|
||||
};
|
||||
|
||||
await store.CreateContainerAsync(container, ct);
|
||||
return container;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Частичное обновление контейнера: меняются только не-null поля патча; rules/policy заменяются целиком.
|
||||
/// </summary>
|
||||
/// <param name="containerId">Id контейнера.</param>
|
||||
/// <param name="patch">Изменения; null-поле означает «не менять».</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Контейнер после патча; null — контейнера нет (404 «Контейнер не найден»).</returns>
|
||||
public async Task<ContainerDto?> PatchAsync(string containerId, ContainerPatchDto patch, CancellationToken ct)
|
||||
{
|
||||
ContainerDto? current = await store.GetContainerAsync(containerId, ct);
|
||||
if (current is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
ContainerDto updated = ApplyPatch(current, patch);
|
||||
await store.UpdateContainerAsync(updated, ct);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Принимает ИИ-предложение: снимает флаг suggested у контейнера.
|
||||
/// </summary>
|
||||
/// <param name="containerId">Id контейнера-предложения.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Контейнер после принятия; null — контейнера нет (404).</returns>
|
||||
public Task<ContainerDto?> AcceptSuggestedAsync(string containerId, CancellationToken ct)
|
||||
{
|
||||
return PatchAsync(containerId, new ContainerPatchDto(
|
||||
Name: null,
|
||||
Description: null,
|
||||
Color: null,
|
||||
Collapsed: null,
|
||||
Suggested: false,
|
||||
Note: null,
|
||||
Rules: null,
|
||||
Policy: null), ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Удаляет контейнер, перенося его карточки в «Неразобранное» новыми.
|
||||
/// </summary>
|
||||
/// <param name="containerId">Id удаляемого контейнера.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Сколько карточек перенесено в inbox (ответ {ok, movedToInbox}).</returns>
|
||||
public Task<int> DeleteAsync(string containerId, CancellationToken ct)
|
||||
{
|
||||
return store.DeleteContainerAsync(containerId, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Переставляет контейнеры пространства: позиции 0..N-1 в порядке списка.
|
||||
/// </summary>
|
||||
/// <param name="space">Пространство переставляемых контейнеров.</param>
|
||||
/// <param name="containerIds">Id контейнеров в новом порядке.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
public Task ReorderAsync(string space, IReadOnlyList<string> containerIds, CancellationToken ct)
|
||||
{
|
||||
return store.ReorderContainersAsync(space, containerIds, ct);
|
||||
}
|
||||
|
||||
// ── Состояние колонок (colState, KV через ISettingsStore) ────────────
|
||||
|
||||
/// <summary>
|
||||
/// Весь объект colState: словарь «контейнер → состояние».
|
||||
/// </summary>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Состояния всех колонок; пусто — настройка не сохранена/повреждена.</returns>
|
||||
public async Task<IReadOnlyDictionary<string, ColumnStateDto>> GetColStateAsync(CancellationToken ct)
|
||||
{
|
||||
return await ReadColStateAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PATCH состояния одной колонки: merge патча в текущее значение и запись всего объекта.
|
||||
/// </summary>
|
||||
/// <param name="colId">Id контейнера.</param>
|
||||
/// <param name="patch">Изменяемые поля состояния.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Состояние колонки после merge.</returns>
|
||||
public async Task<ColumnStateDto> PatchColStateAsync(string colId, ColumnStateDto patch, CancellationToken ct)
|
||||
{
|
||||
Dictionary<string, ColumnStateDto> state = await ReadColStateAsync(ct);
|
||||
state.TryGetValue(colId, out ColumnStateDto? current);
|
||||
|
||||
var merged = new ColumnStateDto(
|
||||
patch.Collapsed ?? current?.Collapsed,
|
||||
patch.Width ?? current?.Width);
|
||||
state[colId] = merged;
|
||||
|
||||
await settings.SetAsync(SettingsKeys.ColState, JsonSerializer.Serialize(state, JsonOptions), ct);
|
||||
return merged;
|
||||
}
|
||||
|
||||
// ── Внутреннее ───────────────────────────────────────────────────────
|
||||
|
||||
// Имя контейнера: trim; пустое → «Новая колонка».
|
||||
// name: Имя из запроса.
|
||||
// Возвращает: Непустое имя контейнера.
|
||||
private static string BuildName(string name)
|
||||
{
|
||||
string trimmed = name.Trim();
|
||||
return trimmed.Length == 0 ? DefaultContainerName : trimmed;
|
||||
}
|
||||
|
||||
// Правила: пустые правила (все группы пусты) нормализуются в null — хранилище пишет каноничное {}.
|
||||
// rules: Правила из запроса.
|
||||
// Возвращает: Нормализованные правила либо null.
|
||||
private static ContainerRulesDto? NormalizeRules(ContainerRulesDto? rules)
|
||||
{
|
||||
if (rules is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
bool isEmpty = rules.Mode.Length == 0
|
||||
&& rules.Direction.Count == 0
|
||||
&& rules.Keywords.Count == 0
|
||||
&& rules.Stack.Count == 0
|
||||
&& rules.Grade.Count == 0
|
||||
&& rules.Exclude.Count == 0
|
||||
&& (rules.Levels?.Count ?? 0) == 0
|
||||
&& (rules.Locations?.Count ?? 0) == 0
|
||||
&& (rules.Types?.Count ?? 0) == 0
|
||||
&& rules.Prices is null
|
||||
&& rules.Budget is null;
|
||||
return isEmpty ? null : rules;
|
||||
}
|
||||
|
||||
// Применяет не-null поля патча к текущему контейнеру.
|
||||
// container: Текущий контейнер.
|
||||
// patch: Патч.
|
||||
// Возвращает: Контейнер с применёнными изменениями.
|
||||
private static ContainerDto ApplyPatch(ContainerDto container, ContainerPatchDto patch)
|
||||
{
|
||||
return container with
|
||||
{
|
||||
Name = patch.Name ?? container.Name,
|
||||
Description = patch.Description ?? container.Description,
|
||||
Color = patch.Color ?? container.Color,
|
||||
Collapsed = patch.Collapsed ?? container.Collapsed,
|
||||
Suggested = patch.Suggested ?? container.Suggested,
|
||||
Note = patch.Note ?? container.Note,
|
||||
Rules = patch.Rules is null ? container.Rules : NormalizeRules(patch.Rules),
|
||||
Policy = patch.Policy ?? container.Policy,
|
||||
};
|
||||
}
|
||||
|
||||
// Читает весь объект colState из KV; повреждённый JSON трактуется как пустое состояние.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Словарь контейнер → состояние (Ordinal-ключи).
|
||||
private async Task<Dictionary<string, ColumnStateDto>> ReadColStateAsync(CancellationToken ct)
|
||||
{
|
||||
SettingValue? row = await settings.GetAsync(SettingsKeys.ColState, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return new Dictionary<string, ColumnStateDto>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Dictionary<string, ColumnStateDto>? state =
|
||||
JsonSerializer.Deserialize<Dictionary<string, ColumnStateDto>>(row.ValueJson, JsonOptions);
|
||||
return state ?? new Dictionary<string, ColumnStateDto>(StringComparer.Ordinal);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Битое значение не должно ронять GET /state и последующие PATCH (перезапишут при записи).
|
||||
return new Dictionary<string, ColumnStateDto>(StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Settings.Application;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Пересчёт конверсий бюджетов карточек при смене курсов/целевой валюты (Ruling 7, план Task 12).
|
||||
/// Реализация порта модуля Settings <see cref="IRatesChangedListener"/> (регистрация — AddKanbanModule).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Чистый сервис модуля (без EF/HTTP), повторяет <c>rates.py recompute_conversions</c> (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,126 @@
|
||||
namespace Deal.Modules.Kanban.Application;
|
||||
|
||||
/// <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,402 @@
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Единый порт хранилища карточек и контейнеров (таблицы Cards/Containers/LeadComments/CardMoves тенанта;
|
||||
/// жёсткое удаление карточки дополнительно чистит строки DedupEntries — Ruling 3), Ruling 1.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Объявлен в модуле Kanban — владельце единой сущности карточки (этап 9); реализация — EF-адаптер
|
||||
/// <c>KanbanStore</c> в Deal.Infrastructure (регистрация в AddDealPersistence). Порт покрывает оба
|
||||
/// пространства одной таблицы Cards: дашборд (служебные зоны/доски) и «Выбранные» (контейнеры-стадии),
|
||||
/// поэтому отдельный порт карточек «Выбранных» упразднён. Порт оперирует DTO модуля; маппинг
|
||||
/// DTO ↔ строки (включая JSON-поля и human-метки времени, Ruling 10) выполняет адаптер вручную
|
||||
/// (эталон SettingsStore.cs). Набор методов — ровно тот, что нужен сервису карточек (YAGNI):
|
||||
/// контейнеры, карточки и переносы, комментарии, журнал CardMoves, кандидаты правил хранения (Ruling 8),
|
||||
/// пересчёт конверсий (Ruling 7) и выборка «Неразобранного» для эвристики (Ruling 3).
|
||||
/// Id записей генерирует модуль (Ruling 12: короткие префиксные id через KanbanIdPrefixes) и передаёт
|
||||
/// готовыми — хранилище id не создаёт. Чтения — AsNoTracking; сортировки (received_at DESC и т.п.) —
|
||||
/// обязанность адаптера. Удаление карточки навсегда (DeleteForeverAsync/ClearColAsync/PurgeAsync)
|
||||
/// снимает и «мягкие» dedup-ссылки (Ruling 3): строки таблицы DedupEntries чужого модуля адаптер удаляет
|
||||
/// напрямую тем же TenantDbContext, цикла Kanban → Pipeline не возникает (Kanban о Pipeline не знает).
|
||||
/// </remarks>
|
||||
public interface ICardStore
|
||||
{
|
||||
// ── Контейнеры (колонки/стадии/зоны) ─────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Контейнеры пространства в порядке показа: ORDER BY space, position (этап 9, T4).
|
||||
/// </summary>
|
||||
/// <param name="space">Пространство (dashboard/selected) либо null — все контейнеры.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Контейнеры (DTO, счётчики заполняет сервис); пусто — контейнеров нет.</returns>
|
||||
public Task<IReadOnlyList<ContainerDto>> ListContainersAsync(string? space, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Один контейнер по id (PATCH 404-семантика, переносы и валидация колонок).
|
||||
/// </summary>
|
||||
/// <param name="containerId">Id контейнера (inbox/archive/trash/стадия/<c>b_...</c>).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Контейнер или null, если строки нет.</returns>
|
||||
public Task<ContainerDto?> GetContainerAsync(string containerId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт контейнер (id/позицию/цвет/дефолты вычисляет ContainersService).
|
||||
/// </summary>
|
||||
/// <param name="container">Полный контейнер для вставки, включая <see cref="ContainerDto.Order"/>.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
public Task CreateContainerAsync(ContainerDto container, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Обновляет контейнер целиком (сервис читает Get + применяет ContainerPatchDto).
|
||||
/// </summary>
|
||||
/// <param name="container">Контейнер с изменёнными полями (идентифицируется по Id).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
public Task UpdateContainerAsync(ContainerDto container, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Удаляет контейнер, предварительно перенося его карточки в «Неразобранное».
|
||||
/// </summary>
|
||||
/// <param name="containerId">Id удаляемого контейнера.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Сколько карточек перенесено в inbox (0 — контейнера/карточек не было; ответ {ok, movedToInbox}).</returns>
|
||||
public Task<int> DeleteContainerAsync(string containerId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Переставляет контейнеры пространства: позиции 0..N-1 в порядке списка.
|
||||
/// </summary>
|
||||
/// <param name="space">Пространство переставляемых контейнеров.</param>
|
||||
/// <param name="containerIds">Id контейнеров в новом порядке.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
public Task ReorderContainersAsync(string space, IReadOnlyList<string> containerIds, CancellationToken ct);
|
||||
|
||||
// ── Карточки ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Карточки колонки или всех колонок дашборда, ORDER BY received_at DESC (list_leads L151–156).
|
||||
/// </summary>
|
||||
/// <param name="query">Фильтр: Col — конкретная колонка либо null (все колонки дашборда).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Полные карточки (DTO): JSON-поля разобраны, комментарии приложены, time посчитан.</returns>
|
||||
public Task<IReadOnlyList<CardDto>> ListCardsAsync(CardsQuery query, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Карточки пространства «Выбранные», ORDER BY updated_at DESC (list_cards projects.py L58–63).
|
||||
/// </summary>
|
||||
/// <param name="containerId">Фильтр по контейнеру-стадии (id каталога <see cref="Deal.Modules.Cards.Application.CardsDefaultContainers"/>); null — все стадии.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Полные карточки (DTO): JSON-поля разобраны, комментарии приложены, time посчитан.</returns>
|
||||
public Task<IReadOnlyList<CardDto>> ListSelectedCardsAsync(string? containerId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Полнотекстовый поиск карточек (FTS + LIKE-дополнение; leads.py search L509–551, Ruling 6/Task 12).
|
||||
/// </summary>
|
||||
/// <param name="q">Поисковый запрос (уже Trim+lowercase, как в отсев-поиске; короче 2 символов сервис не пропускает).</param>
|
||||
/// <param name="limit">Ограничение результата (эндпоинт шлёт 12, Ruling 6).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Полные карточки (комментарии приложены, time посчитан): FTS-кандидаты по
|
||||
/// убыванию ts_rank (SearchTsv @@ plainto_tsquery), затем LIKE-дополнение, внутри — ReceivedAt DESC.</returns>
|
||||
public Task<IReadOnlyList<CardDto>> SearchCardsAsync(string q, int limit, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Одна карточка по id (GET /api/cards/{id}, а также перечитывание после переноса).
|
||||
/// </summary>
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Карточка или null, если строки нет.</returns>
|
||||
public Task<CardDto?> GetCardAsync(string cardId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Карточка по исходному сообщению (диалог + id сообщения) — ручная разметка ML (§8).
|
||||
/// </summary>
|
||||
/// <remarks>Если сообщение давало несколько карточек (повторная разметка/пересоздание), берётся самая
|
||||
/// свежая по ReceivedAt. Пустой dialogId — null (нечем искать).</remarks>
|
||||
/// <param name="dialogId">Id диалога-источника сообщения.</param>
|
||||
/// <param name="msgId">Id исходного сообщения в Telegram.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Карточка или null, если сообщение не становилось карточкой.</returns>
|
||||
public Task<CardDto?> GetCardBySourceAsync(string dialogId, long msgId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт карточку из готового снимка (пайплайн этапа 4, ручное создание; CreatedAt — UTC-now).
|
||||
/// </summary>
|
||||
/// <param name="snapshot">Полное состояние новой карточки (id сгенерирован модулем, см. CardSnapshot).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
public Task AddCardAsync(CardSnapshot snapshot, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Точечная правка полей по присутствующим в патче + bump UpdatedAt (patch_card projects.py L159–187).
|
||||
/// </summary>
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <param name="patch">Изменяемые поля (null — поле не меняется; JSON-поля — полная замена).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>True — строка обновлена; false — карточки нет (404-семантика сервиса).</returns>
|
||||
public Task<bool> PatchCardAsync(string cardId, CardPatch patch, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Атомарно дописывает ссылку в JSON-массив links ОДНИМ UPDATE (jsonb-append) + bump UpdatedAt.
|
||||
/// </summary>
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <param name="link">Готовая ссылка {id,name,url} (id сгенерирован модулем).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>True — строка обновлена; false — карточки нет (404-семантика сервиса).</returns>
|
||||
public Task<bool> AddLinkAsync(string cardId, CardLinkDto link, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Атомарно убирает из JSON-массива links элемент с указанным id ОДНИМ UPDATE (jsonb-фильтрация) + bump UpdatedAt.
|
||||
/// </summary>
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <param name="linkId">Id удаляемой ссылки (<c>pl_...</c>).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>True — строка обновлена; false — карточки нет (404-семантика сервиса).</returns>
|
||||
public Task<bool> RemoveLinkAsync(string cardId, string linkId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Атомарно дописывает метаданные файла в JSON-массив files ОДНИМ UPDATE (jsonb-append) + bump UpdatedAt.
|
||||
/// </summary>
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <param name="file">Готовые метаданные {id,name,size,kind,label,objectKey} (id сгенерирован модулем).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>True — строка обновлена; false — карточки нет (404-семантика сервиса).</returns>
|
||||
public Task<bool> AddFileAsync(string cardId, CardFileDto file, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Атомарно убирает из JSON-массива files элемент с указанным id ОДНИМ UPDATE (jsonb-фильтрация) + bump UpdatedAt.
|
||||
/// </summary>
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <param name="fileId">Id удаляемой записи файла (<c>pf_...</c>).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>True — строка обновлена; false — карточки нет (404-семантика сервиса).</returns>
|
||||
public Task<bool> RemoveFileAsync(string cardId, string fileId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Смена контейнера-стадии: один UPDATE (col, reminder_at=NULL, reminder_fired=false, updated_at=atMs)
|
||||
/// + перезапись history-массива с добавленной записью (move_stage projects.py L202–216; Ruling 7).
|
||||
/// </summary>
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <param name="containerId">Новый контейнер-стадия (валидирует сервис каталогом <see cref="Deal.Modules.Cards.Application.CardsDefaultContainers"/>).</param>
|
||||
/// <param name="historyEntry">Готовая запись истории {id,at,stage} (id сгенерирован модулем).</param>
|
||||
/// <param name="atMs">Время переноса, epoch-ms (пишется в updated_at и в запись истории).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>True — строка обновлена; false — карточки нет (404-семантика сервиса).</returns>
|
||||
public Task<bool> MoveCardStageAsync(string cardId, string containerId, CardHistoryDto historyEntry, long atMs, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Устанавливает напоминание: reminder_at, reminder_fired=false + bump UpdatedAt (set_reminder projects.py L236–243).
|
||||
/// </summary>
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <param name="atMs">Время напоминания, epoch-ms.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
public Task SetReminderAsync(string cardId, long atMs, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Снимает напоминание: reminder_at=NULL, reminder_fired=false (clear_reminder projects.py L246–247).
|
||||
/// </summary>
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
public Task ClearReminderAsync(string cardId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Полная ручная очистка контейнера-стадии: DELETE строк (clear_stage projects.py L223–231).
|
||||
/// </summary>
|
||||
/// <param name="containerId">Очищаемая стадия (допустимость — только <c>rejected</c> — валидирует сервис).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Сколько карточек удалено (0 — стадия пуста).</returns>
|
||||
public Task<int> ClearStageAsync(string containerId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Наступившие напоминания стадии hold: ReminderAt ≤ now, ReminderFired=false, ORDER BY ReminderAt (check_reminders projects.py L270–275).
|
||||
/// </summary>
|
||||
/// <param name="now">Текущий момент (UTC) для сравнения с ReminderAt.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Due-строки {id,title,containerId} в порядке наступления; пусто — сработавших нет.</returns>
|
||||
public Task<IReadOnlyList<CardReminderDueDto>> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Помечает due-карточки сработавшими: ReminderFired=true по списку id (check_reminders projects.py L277–278).
|
||||
/// </summary>
|
||||
/// <param name="cardIds">Id карточек, чьи напоминания «выстрелили».</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
public Task MarkRemindersFiredAsync(IReadOnlyList<string> cardIds, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Очищает протухшие напоминания при выключенной настройке: ReminderAt=NULL WHERE ReminderAt ≤ now (check_reminders L266–269).
|
||||
/// </summary>
|
||||
/// <param name="now">Текущий момент (UTC).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Сколько строк очищено.</returns>
|
||||
public Task<int> ClearExpiredRemindersAsync(DateTimeOffset now, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Меняет колонку/состояние карточки (move/trash/restore/автоархив) — см. CardColumnUpdateDto.
|
||||
/// </summary>
|
||||
/// <param name="update">Новое состояние колонки карточки (matchHits пересчитан в модуле, Ruling 2).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
public Task UpdateColumnAsync(CardColumnUpdateDto update, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Применяет результат ручной переклассификации к существующей карточке ОДНИМ обновлением
|
||||
/// (leads.py reclassify_lead L346–367): тип/заголовок/суть/стек/бюджет/конверсия/контакты/matchHits + колонка.
|
||||
/// </summary>
|
||||
/// <param name="update">Поля классификации (полная замена; см. CardReclassificationDto).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>True — строка обновлена; false — карточки нет (404-семантика сервиса).</returns>
|
||||
public Task<bool> ApplyReclassificationAsync(CardReclassificationDto update, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Снимает флаг «новое»: с одной карточки (cardId), с колонки (col) или со всех (оба null) — leads.py mark_seen L250–256.
|
||||
/// </summary>
|
||||
/// <param name="cardId">Id карточки либо null.</param>
|
||||
/// <param name="col">Колонка либо null.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
public Task UpdateSeenAsync(string? cardId, string? col, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Удаляет карточку навсегда: Cards + комментарии (FK cascade) + строки дедупа карточки
|
||||
/// (DedupEntries WHERE LeadId=?, Ruling 3); журнал/outbox не трогает (leads.py _hard_delete L225–234).
|
||||
/// </summary>
|
||||
/// <param name="cardId">Id карточки.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
public Task DeleteForeverAsync(string cardId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Полная очистка служебной колонки (только trash|archive — валидирует сервис), leads.py clear_col L237–247;
|
||||
/// строки дедупа удаляемых карточек чистятся вместе с ними (Ruling 3).
|
||||
/// </summary>
|
||||
/// <param name="col">Очищаемая колонка (trash/archive).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Сколько карточек удалено (0 — колонка пуста).</returns>
|
||||
public Task<int> ClearColAsync(string col, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Счётчики карточек по колонкам (count + new) для GET /api/cards/counts (leads.py counts L268–279).
|
||||
/// </summary>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Словарь col → {count, new}; ключи — только колонки с карточками.</returns>
|
||||
public Task<IReadOnlyDictionary<string, CardColumnCountDto>> CountCardsByColAsync(CancellationToken ct);
|
||||
|
||||
// ── Комментарии (LeadComments) ────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Комментарии карточки (для ответа add-comment и карточки; сортировка по времени добавления).
|
||||
/// </summary>
|
||||
/// <param name="cardId">Id карточки.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Комментарии (time — human-метка от CreatedAt); пусто — комментариев нет.</returns>
|
||||
public Task<IReadOnlyList<CardCommentDto>> ListCommentsAsync(string cardId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Добавляет комментарий (leads.py add_comment L259–265; CreatedAt — UTC-now).
|
||||
/// </summary>
|
||||
/// <param name="commentId">Готовый id (<c>cm_...</c>, генерирует модуль).</param>
|
||||
/// <param name="cardId">Id карточки.</param>
|
||||
/// <param name="by">Автор («Вы» — свои комментарии).</param>
|
||||
/// <param name="text">Текст (непустой — валидирует сервис, 400 «Пустой комментарий»).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
public Task AddCommentAsync(string commentId, string cardId, string by, string text, CancellationToken ct);
|
||||
|
||||
// ── Журнал действий (CardMoves = learning_log) ────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Пишет строку журнала действия пользователя (move/trash/restore/comment) — leads.py _log_learning L40–44.
|
||||
/// </summary>
|
||||
/// <param name="move">Запись журнала (id <c>lm_...</c> сгенерирован модулем).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
public Task AddMoveAsync(CardMoveDto move, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Число записей журнала = счётчик learning (Ruling 4, Task 5 StatusAsync).
|
||||
/// </summary>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Количество строк CardMoves.</returns>
|
||||
public Task<int> CountMovesAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Свежие примеры разметки пользователя для few-shot ИИ-классификации (pipeline.py _learning_examples L201–215).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Join журнала CardMoves с карточками (Cards.SourceMsg): действия move/restore, цель не служебная
|
||||
/// (trash/archive), исходный текст непустой; ORDER BY created_at DESC, ≤ limit записей. Используется
|
||||
/// контекст-билдером классификации (план Task 15, Ruling 5) — текст примера дополнительно режется
|
||||
/// вызывающим до 500 кодовых точек (python L214).
|
||||
/// </remarks>
|
||||
/// <param name="limit">Максимум примеров (python L201: 8).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Примеры «текст → колонка», свежие первыми; пусто — истории разметки нет.</returns>
|
||||
public Task<IReadOnlyList<AiMarkupExampleDto>> GetAiMarkupExamplesAsync(int limit, CancellationToken ct);
|
||||
|
||||
// ── Правила хранения (тик, Ruling 8) ──────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Кандидаты на автоархив: карточки досок и «Неразобранного» со ReceivedAt старше срока (tick_storage L462–467).
|
||||
/// </summary>
|
||||
/// <param name="receivedBeforeUtc">Граница: received_at < now − archiveAfterDays.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Id карточек-кандидатов (колонка при архивации пишется archive через UpdateColumnAsync).</returns>
|
||||
public Task<IReadOnlyList<string>> ListArchiveCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Архивирует пачку карточек ОДНИМ UPDATE: col=archive, is_new=false, archived_at=archivedAt,
|
||||
/// matchHits — пустой массив (тик tick_storage L462–473; batch-замена по-карточных UpdateColumnAsync,
|
||||
/// PrevCol при архивации не трогается — Ruling 8).
|
||||
/// </summary>
|
||||
/// <param name="cardIds">Id карточек-кандидатов (список из ListArchiveCandidatesAsync).</param>
|
||||
/// <param name="archivedAt">Момент архивации (один «now» тика).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Сколько карточек реально архивировано (0 — кандидатов не было/уже не в рабочих колонках).</returns>
|
||||
public Task<int> ArchiveAsync(IReadOnlyList<string> cardIds, DateTimeOffset archivedAt, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Кандидаты на очистку архива: col='archive' и ArchivedAt старше срока (tick_storage L475–478).
|
||||
/// </summary>
|
||||
/// <param name="archivedBeforeUtc">Граница: archived_at < now − archiveClearDays.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Id карточек для жёсткого удаления.</returns>
|
||||
public Task<IReadOnlyList<string>> ListExpiredArchiveCandidatesAsync(DateTimeOffset archivedBeforeUtc, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Кандидаты на очистку корзины: col='trash' и ReceivedAt старше срока (tick_storage L480–483).
|
||||
/// </summary>
|
||||
/// <param name="receivedBeforeUtc">Граница: received_at < now − trashClearDays.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Id карточек для жёсткого удаления.</returns>
|
||||
public Task<IReadOnlyList<string>> ListTrashCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Жёстко удаляет пачку карточек: Cards + комментарии (FK cascade) + строки дедупа карточек
|
||||
/// (DedupEntries WHERE LeadId IN …, Ruling 3) — для очисток тика и clear-col (Ruling 8).
|
||||
/// </summary>
|
||||
/// <param name="cardIds">Id карточек на удаление.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Сколько карточек удалено.</returns>
|
||||
public Task<int> PurgeAsync(IReadOnlyList<string> cardIds, CancellationToken ct);
|
||||
|
||||
// ── Пересчёт конверсий (Ruling 7, Task 12) ─────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Карточки для пересчёта ConvFrom/ConvTo/ConvCur: budgetCur непуст и col NOT IN (archive, trash) — recompute_conversions L106–130.
|
||||
/// </summary>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Карточки с бюджетом (используются Budget/бюджетные поля; служебные колонки исключены).</returns>
|
||||
public Task<IReadOnlyList<CardDto>> ListCardsForConversionAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Записывает пересчитанную конверсию бюджета карточки (обновляет только conv-поля).
|
||||
/// </summary>
|
||||
/// <param name="cardId">Id карточки.</param>
|
||||
/// <param name="convFrom">Сконвертированная нижняя граница, либо null.</param>
|
||||
/// <param name="convTo">Сконвертированная верхняя граница, либо null.</param>
|
||||
/// <param name="convCur">Валюта конверсии (целевая валюта тенанта); пусто — конверсия снята.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
public Task UpdateConversionAsync(string cardId, double? convFrom, double? convTo, string convCur, CancellationToken ct);
|
||||
|
||||
// ── Эвристика ИИ-предложений (Ruling 3, Task 14) ───────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Карточки «Неразобранного» с исходным текстом — вход эвристики suggest (suggest.py, Ruling 3).
|
||||
/// </summary>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Карточки col='inbox' с непустым SourceMsg (частотные темы считаются по source_msg).</returns>
|
||||
public Task<IReadOnlyList<CardDto>> ListInboxWithSourceAsync(CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Порт хранилища обучения ML: очередь MlOutbox + счётчик журнала CardMoves (Ruling 4, Task 5).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Объявлен в модуле Kanban (чистый: без EF) и потребляется интеграционным адаптером
|
||||
/// <c>LocalMlClient</c> (Deal.Infrastructure), чтобы тот оставался unit-чистым (план Task 5:
|
||||
/// «подсчёты вынести за чистый порт IMlLearningCounters (модуль Kanban)»; состав порта — счётчики
|
||||
/// learning/outbox плюс запись/очистка очереди — финальное решение исполнителя). Реализация —
|
||||
/// EF-адаптер над таблицами CardMoves/MlOutbox схемы тенанта, регистрируется в AddDealPersistence.
|
||||
/// Id строки очереди (<c>mle_...</c>) генерирует вызывающий (Ruling 12); CreatedAt проставляет
|
||||
/// хранилище (UTC-now, как у CardMoves/комментариев).
|
||||
/// </remarks>
|
||||
public interface IMlLearningStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Число записей журнала обучения = счётчик learning: count(CardMoves) (ml_client.snapshot L144).
|
||||
/// </summary>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Количество строк журнала CardMoves.</returns>
|
||||
public Task<int> CountLearningAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Число строк очереди обучения = счётчик outbox: count(MlOutbox) (ml_client.outbox_len L52–53).
|
||||
/// </summary>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Количество строк очереди MlOutbox.</returns>
|
||||
public Task<int> CountOutboxAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Пишет строку очереди обучения (ml_client.push L40–49): текст уже trim-нут и обрезан до 6000 вызывающим.
|
||||
/// </summary>
|
||||
/// <param name="id">Готовый id строки (<c>mle_...</c>, генерирует вызывающий).</param>
|
||||
/// <param name="text">Текст обучающего примера (непустой, ≤6000 символов).</param>
|
||||
/// <param name="label">Метка обучения: id доски, <c>spam</c> либо <c>t:hire|t:order</c>.</param>
|
||||
/// <param name="delta">Вес сигнала (1.0 — учить, −1.0 — снять метку).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
public Task AddOutboxAsync(string id, string text, string label, double delta, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Полная очистка очереди обучения: DELETE FROM MlOutbox (ml_client.reset_model L122). Журнал CardMoves не трогается.
|
||||
/// </summary>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
public Task ClearOutboxAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Забирает следующую порцию очереди обучения: ORDER BY created_at LIMIT N (ml_client.flush_outbox L66–69).
|
||||
/// </summary>
|
||||
/// <remarks>Строки НЕ удаляются — выборка и удаление разделены: вызывающий (MlOutboxFlushScheduler, план
|
||||
/// Task 16) отправляет батч в ml-service и удаляет строки только после успеха (Ruling 6).</remarks>
|
||||
/// <param name="limit">Размер порции (флашер шлёт по 10 строк за батч).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>До <paramref name="limit"/> строк очереди в порядке created_at.</returns>
|
||||
public Task<IReadOnlyList<MlOutboxEntryDto>> TakeOutboxBatchAsync(int limit, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Удаляет отправленные строки очереди по id (ml_client.flush_outbox L79 — DELETE после успеха батча).
|
||||
/// </summary>
|
||||
/// <param name="ids">Id строк, успешно отправленных в ML-сервис.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Задача завершается после удаления строк.</returns>
|
||||
public Task DeleteOutboxAsync(IReadOnlyCollection<string> ids, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace Deal.Modules.Kanban.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Реестр служебных (не-доски) колонок канбана (Ruling 1, constants.py SERVICE_COLS).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Значения — фиксированные строки JSON/колонки Cards.Col: <c>inbox|archive|trash</c>.
|
||||
/// Доски (<c>b_...</c>) в реестр не входят: их id хранятся в таблице Containers, а существование
|
||||
/// колонки-доски приложение валидирует по хранилищу (KanbanStore.GetContainerAsync).
|
||||
/// Прототип: <c>backend/app/constants.py</c> — SERVICE_COLS; api-map §4.4 L304.
|
||||
/// </remarks>
|
||||
public static class KanbanColumns
|
||||
{
|
||||
/// <summary>
|
||||
/// «Неразобранное» — приёмная колонка: карточки без доски и канбан-возвраты.
|
||||
/// </summary>
|
||||
public const string Inbox = "inbox";
|
||||
|
||||
/// <summary>
|
||||
/// Архив: карточки, ушедшие по правилам хранения (тик) или вручную.
|
||||
/// </summary>
|
||||
public const string Archive = "archive";
|
||||
|
||||
/// <summary>
|
||||
/// Корзина: удалённые пользователем карточки (до очистки по сроку).
|
||||
/// </summary>
|
||||
public const string Trash = "trash";
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Deal.Modules.Cards.Application;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Реестр префиксов коротких id модуля Kanban (Ruling 12, прототип store.uid).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Id-генерация — в модуле (не GUID: прототип и фронт требуют коротких ключей в JSON);
|
||||
/// хранилище (Task 4) получает уже готовые id и только сохраняет их.
|
||||
/// Префиксы соответствуют владельцам данных карточки: Contеinerы=<c>b_</c>, Cards=<c>c_</c>,
|
||||
/// LeadComments=<c>cm_</c>, CardMoves=<c>lm_</c>, MlOutbox=<c>mle_</c>; элементы JSON-массивов
|
||||
/// карточки — ссылки <c>pl_</c>, файлы <c>pf_</c>, записи истории <c>h_</c>.
|
||||
/// Генератор (<c>PrefixId</c> + случайный hex) добавляется задачей 7.
|
||||
/// </remarks>
|
||||
public static class KanbanIdPrefixes
|
||||
{
|
||||
/// <summary>
|
||||
/// Префикс id контейнера-доски (kind=board единого реестра Containers).
|
||||
/// </summary>
|
||||
public const string Board = "b_";
|
||||
|
||||
/// <summary>
|
||||
/// Префикс id карточки (единый для всех дашбордов; таблица Cards).
|
||||
/// </summary>
|
||||
public const string Card = CardIds.CardPrefix;
|
||||
|
||||
/// <summary>
|
||||
/// Префикс id комментария карточки (таблица LeadComments).
|
||||
/// </summary>
|
||||
public const string Comment = "cm_";
|
||||
|
||||
/// <summary>
|
||||
/// Префикс id записи журнала действий (таблица CardMoves = learning_log).
|
||||
/// </summary>
|
||||
public const string CardMove = "lm_";
|
||||
|
||||
/// <summary>
|
||||
/// Префикс id ссылки карточки (элемент массива links).
|
||||
/// </summary>
|
||||
public const string Link = "pl_";
|
||||
|
||||
/// <summary>
|
||||
/// Префикс id файла карточки (элемент массива files).
|
||||
/// </summary>
|
||||
public const string File = "pf_";
|
||||
|
||||
/// <summary>
|
||||
/// Префикс id записи истории движения (элемент массива history; projects.py store.uid("h_")).
|
||||
/// </summary>
|
||||
public const string History = "h_";
|
||||
|
||||
/// <summary>
|
||||
/// Префикс id строки очереди обучения (таблица MlOutbox).
|
||||
/// </summary>
|
||||
public const string MlOutbox = "mle_";
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Deal.Modules.Settings.Application;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application;
|
||||
|
||||
/// <summary>
|
||||
/// DI-регистрация модуля Kanban. Паттерн «port & adapter» (Ruling 12).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Регистрируются только сервисы модуля. Порт-адаптеры (ICardStore → KanbanStore) реализованы в
|
||||
/// Deal.Infrastructure и регистрируются там (AddDealPersistence); IColumnSuggester → LocalColumnSuggester —
|
||||
/// в AddDealIntegrations (Ruling 12) — модуль не знает про EF и HTTP. Зависимости модуля —
|
||||
/// Deal.Modules.Settings (порт ISettingsStore: настройки хранения/colState/курсы) и Deal.Contracts
|
||||
/// (IMlClient: обучающие сигналы ML, Task 5); реверс-зависимостей нет (Global Constraints).
|
||||
/// </remarks>
|
||||
public static class KanbanModuleRegistrar
|
||||
{
|
||||
/// <summary>
|
||||
/// Регистрирует сервисы модуля Kanban в контейнере.
|
||||
/// </summary>
|
||||
/// <param name="services">Коллекция сервисов.</param>
|
||||
/// <returns>Коллекция сервисов для цепочки вызовов.</returns>
|
||||
/// <remarks>
|
||||
/// Здесь появляются scoped-сервисы модуля по мере их создания: ContainersService (Task 6), CardsService
|
||||
/// (Task 7), StorageTickService (Task 10) и далее ConversionRecomputer (Task 12) с
|
||||
/// <c>AddScoped<IRatesChangedListener, ConversionRecomputer>()</c> (Ruling 7). Вызывается из
|
||||
/// Program.cs (AddKanbanModule, Task 4) после AddDealPersistence. Сервисы scoped, потому что их
|
||||
/// зависимости (ICardStore/ISettingsStore) живут в рамках tenant-запроса (EF-контекст).
|
||||
/// </remarks>
|
||||
public static IServiceCollection AddKanbanModule(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<ContainersService>();
|
||||
services.AddScoped<CardsService>();
|
||||
services.AddScoped<StorageTickService>();
|
||||
|
||||
// Порт модуля Settings реализует ConversionRecomputer (Ruling 7, Task 12): RatesService/SettingsService
|
||||
// оповещают его после записи кэша курсов / смены targetCurrency|conversionOn (список может быть пуст).
|
||||
services.AddScoped<IRatesChangedListener, ConversionRecomputer>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Результат добавления комментария — CardsService.AddCommentAsync (add_comment L259–265, dashboard_routes L238–242).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Три исхода для эндпоинта (Task 8): <see cref="Error"/> — текст 400 «Пустой комментарий»;
|
||||
/// <see cref="Comments"/> == null при <see cref="Error"/> == null — карточки нет (эндпоинт отвечает 404
|
||||
/// «Карточка не найдена»); иначе <see cref="Comments"/> — полный список комментариев карточки после
|
||||
/// добавления (ответ {comments: [...]} — фронт затирает массив карточки ответом, store.js addComment
|
||||
/// L924–933). Автор нового комментария — «Вы» (by), текст — после Trim, метка времени — «только что».
|
||||
/// </remarks>
|
||||
/// <param name="Error">Текст 400 (пустой комментарий) либо null.</param>
|
||||
/// <param name="Comments">Список комментариев после добавления либо null (400/карточки нет).</param>
|
||||
public sealed record AddCommentResultDto(string? Error, IReadOnlyList<CardCommentDto>? Comments);
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Пример разметки пользователя для few-shot ИИ-классификации — результат ICardStore.GetAiMarkupExamplesAsync
|
||||
/// (pipeline.py _learning_examples L201–215).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Источник — журнал CardMoves (learning_log прототипа): действия move/restore над карточками с исходным
|
||||
/// текстом (Cards.SourceMsg), кроме переносов в служебные колонки trash/archive; свежие первыми (ORDER BY
|
||||
/// created_at DESC), максимум <paramref name="Text"/> режется до 500 кодовых точек вызывающим
|
||||
/// (контекст-билдер классификации). Форма «текст → колонка» 1:1 с python: примеры подставляются в
|
||||
/// user-контекст Classify как «текст: … → колонка: …».
|
||||
/// </remarks>
|
||||
/// <param name="Text">Исходный текст карточки (source_msg сообщения-источника).</param>
|
||||
/// <param name="Board">Колонка-назначение переноса (id доски, не служебная).</param>
|
||||
public sealed record AiMarkupExampleDto(string Text, string Board);
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Бюджетный диапазон в правилах доски — поле <c>budget</c> объекта rules (§4.2 L274).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Границы могут отсутствовать по отдельности («до X» → from=null, «от X» → to=null), валюта — код
|
||||
/// (USD/RUB/…) либо символ исходного сообщения. Наружу сериализуется в camelCase: from/to/cur.
|
||||
/// Используется правилами колонок (Task 3, BudgetInRange) — конвертация валюты при сравнении.
|
||||
/// </remarks>
|
||||
public sealed record BudgetRangeDto(double? From, double? To, string Cur);
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Бюджет карточки — поля <c>budget</c>/<c>converted</c> карточки (§4.1 L240–241, pipeline.py lead_to_dict L565–574).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>budget</c> — бюджет как в исходном сообщении (валюта — BudgetCur карточки), <c>converted</c> —
|
||||
/// пересчитанный в целевую валюту (Ruling 7, ConversionRecomputer). Пустой BudgetCur («суммы нет»)
|
||||
/// представляется null-объектом budget на карточке. Границы могут отсутствовать по отдельности.
|
||||
/// Наружу сериализуется в camelCase: from/to/cur.
|
||||
/// </remarks>
|
||||
public sealed record CardBudgetDto(double? From, double? To, string Cur);
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Канал-источник карточки — объект <c>ch</c> (§4.1 L244, pipeline.py L577).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Имя/хендл/цвет канала или диалога, из которого пришло исходное сообщение. Наружу сериализуется
|
||||
/// в camelCase под ключом <c>ch</c> (JsonPropertyName — свойство называется Channel): ch.name/ch.handle/ch.hue.
|
||||
/// </remarks>
|
||||
public sealed record CardChannelDto(
|
||||
string Name,
|
||||
string Handle,
|
||||
string Hue);
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Счётчик колонки в ответе counts — значение словаря «col → {count, new}» (§4.1 L257, leads.py counts L268–279).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Строка GROUP BY по Cards (col, is_new): count — всего карточек в колонке, new — из них «новых».
|
||||
/// Наружу сериализуется в camelCase: {count, new}.
|
||||
/// </remarks>
|
||||
public sealed record CardColumnCountDto(int Count, int New);
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Перенос карточки в другую колонку/состояние — параметр ICardStore.UpdateColumnAsync (leads.py _move L163–174, restore L204–222).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Единая операция для move/trash/restore/автоархива: обновляет col, is_new, prev_col, archived_at,
|
||||
/// match_hits у карточки. <see cref="PrevCol"/>: null — НЕ менять (автоархив тика не трогает prev_col,
|
||||
/// Ruling 8); иначе записывается значение (при move — прежняя колонка, при restore — «inbox», Ruling 10).
|
||||
/// <see cref="ArchivedAt"/>: значение пишется как есть, null — обнуляется (возврат из архива/корзины).
|
||||
/// <see cref="MatchHits"/> всегда записывается целиком: для inbox/archive/trash и досок без правил —
|
||||
/// пустой список (Ruling 2), пересчёт — в модуле (ColumnRules, Task 3) до вызова.
|
||||
/// </remarks>
|
||||
public sealed record CardColumnUpdateDto(
|
||||
string CardId,
|
||||
string Col,
|
||||
bool IsNew,
|
||||
string? PrevCol,
|
||||
DateTimeOffset? ArchivedAt,
|
||||
IReadOnlyList<MatchHitDto> MatchHits);
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Комментарий карточки — элемент массива <c>comments</c> (§4.1 L252, lead_to_dict).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Нормализация комментария таблицы LeadComments: <c>by</c> — автор («Вы» — свои комментарии),
|
||||
/// <c>time</c> — человеческая метка, вычисляемая от CreatedAt при маппинге (Ruling 10, как
|
||||
/// <c>time</c> карточки). Сразу после добавления комментарий отдаётся с меткой «только что»
|
||||
/// (leads.py add_comment L259–265). Наружу сериализуется в camelCase: id/by/text/time.
|
||||
/// </remarks>
|
||||
public sealed record CardCommentDto(
|
||||
string Id,
|
||||
string By,
|
||||
string Text,
|
||||
string Time);
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Контакт карточки — элемент массива <c>contacts</c> (§4.1 L243).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Квалифицированный контакт (прототип <c>qualify_contact</c>/<c>build_contacts</c>): type —
|
||||
/// tg|phone|whatsapp|email|linkedin|site|other, value — нормализованное значение. Строковый
|
||||
/// <c>contact</c> карточки — «быстрый» контакт = value основного (primary_contact, pipeline.py L424–430).
|
||||
/// Наружу сериализуется в camelCase: type/value.
|
||||
/// </remarks>
|
||||
public sealed record CardContactDto(string Type, string Value);
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Ответ GET /api/cards/counts (§4.1 L257, leads.py counts L268–279).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="New"/> — сумма «новых» по всем колонкам (top-level <c>new</c>); <see cref="Columns"/> —
|
||||
/// счётчики по каждой колонке с карточками: ключ — col (inbox/archive/trash/b_...). learning/ml/ai
|
||||
/// наполняет CardsService из IMlClient.StatusAsync (Task 5) — фронт читает именно их (store.js L586–592).
|
||||
/// Сериализация: new/learning/ml/ai — фиксированные поля, per-колоночные счётчики живут в словаре
|
||||
/// (плоская wire-форма «{new, <col>: {…}, learning, ml, ai}» собирается на уровне эндпоинта/сервиса
|
||||
/// из этих частей — см. Task 7/8).
|
||||
/// </remarks>
|
||||
public sealed record CardCountsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Суммарное число «новых» карточек по всем колонкам.
|
||||
/// </summary>
|
||||
public int New { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Счётчики колонок: col → {count, new} (только колонки с карточками).
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, CardColumnCountDto> Columns { get; init; } =
|
||||
new Dictionary<string, CardColumnCountDto>();
|
||||
|
||||
/// <summary>
|
||||
/// Число обучающих действий (записей CardMoves), счётчик learning.
|
||||
/// </summary>
|
||||
public int Learning { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Обработано ML-решений (KV mlDecisions; на этапе 3 — 0, Ruling 4).
|
||||
/// </summary>
|
||||
public int Ml { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Обработано ИИ-решений (KV aiDecisions; на этапе 3 — 0, Ruling 4).
|
||||
/// </summary>
|
||||
public int Ai { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Карточка — единая сущность всех дашбордов: элемент GET /api/cards, ответ мутаций и payload SSE new_card.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Единая сущность карточки (этап 9): карточка — один агрегат. Поля wire: id/containerId/col/
|
||||
/// isNew/local/title/summary/source/sourceMsg/sourceDialogId/sourceMsgId/stack/budget/converted/contact/
|
||||
/// contacts/channel/matchHits/comments/links/files/history/tzText/reminder/prevCol/isVacancy/isVacancyKnown/
|
||||
/// time/receivedAt/createdAt/updatedAt. <see cref="Time"/> — human-метка от ReceivedAt; времена — epoch-ms.
|
||||
/// <see cref="Col"/> — внутреннее имя колонки карточки, наружу отдаётся и как алиас <see cref="ContainerId"/>.
|
||||
/// </remarks>
|
||||
public sealed record CardDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Короткий id карточки (префикс <c>c_</c>).
|
||||
/// </summary>
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Колонка карточки (служебная зона/стадия/доска <c>b_...</c>).
|
||||
/// </summary>
|
||||
public string Col { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Id контейнера (алиас <see cref="Col" />; каноничное имя wire).
|
||||
/// </summary>
|
||||
public string ContainerId => Col;
|
||||
|
||||
/// <summary>
|
||||
/// Флаг «новое» (точка на карточке; снимается mark-seen/переносом).
|
||||
/// </summary>
|
||||
public bool IsNew { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Признак «карточка создана локально» (без внешнего первоисточника).
|
||||
/// </summary>
|
||||
public bool Local { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Найм (вакансия) vs разовая сделка (маркерная гипотеза, не ИИ).
|
||||
/// </summary>
|
||||
public bool IsVacancy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Тип «найм/разовое» подтверждён ИИ по контексту сообщения.
|
||||
/// </summary>
|
||||
public bool IsVacancyKnown { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Заголовок карточки (очищенный, до 140 символов).
|
||||
/// </summary>
|
||||
public string Title { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Блок «О заявке» (до 2000 символов).
|
||||
/// </summary>
|
||||
public string Summary { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Источник (производная проекция): kind/displayName/originRef/receivedAt.
|
||||
/// </summary>
|
||||
public CardSourceDto Source { get; init; } = new(string.Empty, string.Empty, string.Empty, 0);
|
||||
|
||||
/// <summary>
|
||||
/// Стек/направления (JSON-массив строк).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Stack { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Бюджет по исходному сообщению; null — сумма не указана (BudgetCur пуст).
|
||||
/// </summary>
|
||||
public CardBudgetDto? Budget { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Бюджет, пересчитанный в целевую валюту тенанта; null — конверсия не сделана.
|
||||
/// </summary>
|
||||
public CardBudgetDto? Converted { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// «Быстрый» контакт: значение основного контакта.
|
||||
/// </summary>
|
||||
public string Contact { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Квалифицированные контакты.
|
||||
/// </summary>
|
||||
public IReadOnlyList<CardContactDto> Contacts { get; init; } = Array.Empty<CardContactDto>();
|
||||
|
||||
/// <summary>
|
||||
/// Канал-источник (name/handle/hue).
|
||||
/// </summary>
|
||||
public CardChannelDto Channel { get; init; } = new(string.Empty, string.Empty, string.Empty);
|
||||
|
||||
/// <summary>
|
||||
/// Человеческая метка возраста: «только что»/«N мин»/«N ч»/«N дн».
|
||||
/// </summary>
|
||||
public string Time { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Время получения исходного сообщения, epoch-ms (выходит под ключом <c>receivedAt</c>).
|
||||
/// </summary>
|
||||
[property: JsonPropertyName("receivedAt")]
|
||||
public long ReceivedAtMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Исходное сообщение (для переобучения ML и поиска, до 4000 символов).
|
||||
/// </summary>
|
||||
public string SourceMsg { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Id диалога исходного сообщения (для «открыть исходник»).
|
||||
/// </summary>
|
||||
public string SourceDialogId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Id исходного сообщения в Telegram, либо null.
|
||||
/// </summary>
|
||||
public long? SourceMsgId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Предыдущая колонка (для возврата из архива/корзины).
|
||||
/// </summary>
|
||||
public string PrevCol { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Совпавшие критерии правил — почему карточка в этой колонке.
|
||||
/// </summary>
|
||||
public IReadOnlyList<MatchHitDto> MatchHits { get; init; } = Array.Empty<MatchHitDto>();
|
||||
|
||||
/// <summary>
|
||||
/// Комментарии карточки (таблица LeadComments).
|
||||
/// </summary>
|
||||
public IReadOnlyList<CardCommentDto> Comments { get; init; } = Array.Empty<CardCommentDto>();
|
||||
|
||||
/// <summary>
|
||||
/// Ссылки карточки.
|
||||
/// </summary>
|
||||
public IReadOnlyList<CardLinkDto> Links { get; init; } = Array.Empty<CardLinkDto>();
|
||||
|
||||
/// <summary>
|
||||
/// Файлы карточки.
|
||||
/// </summary>
|
||||
public IReadOnlyList<CardFileDto> Files { get; init; } = Array.Empty<CardFileDto>();
|
||||
|
||||
/// <summary>
|
||||
/// История движения карточки.
|
||||
/// </summary>
|
||||
public IReadOnlyList<CardHistoryDto> History { get; init; } = Array.Empty<CardHistoryDto>();
|
||||
|
||||
/// <summary>
|
||||
/// Текст технического задания.
|
||||
/// </summary>
|
||||
public string TzText { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Напоминание ({at}) либо null — напоминания нет.
|
||||
/// </summary>
|
||||
public CardReminderDto? Reminder { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Время создания, epoch-ms (выходит под ключом <c>createdAt</c>).
|
||||
/// </summary>
|
||||
[property: JsonPropertyName("createdAt")]
|
||||
public long CreatedAtMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Время последнего изменения, epoch-ms (выходит под ключом <c>updatedAt</c>).
|
||||
/// </summary>
|
||||
[property: JsonPropertyName("updatedAt")]
|
||||
public long UpdatedAtMs { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Файл карточки — элемент массива <c>files</c> (этап 9, T6; форма 1:1 с прежней проектной карточкой).
|
||||
/// </summary>
|
||||
/// <param name="Id">Короткий id записи файла (<c>pf_...</c>).</param>
|
||||
/// <param name="Name">Имя файла.</param>
|
||||
/// <param name="Size">Размер в байтах.</param>
|
||||
/// <param name="Kind">Тип: image/video/audio/archive/document/other.</param>
|
||||
/// <param name="Label">Человекочитаемая метка типа.</param>
|
||||
/// <param name="ObjectKey">Ключ объекта в файловом хранилище.</param>
|
||||
public sealed record CardFileDto(
|
||||
string Id,
|
||||
string Name,
|
||||
long Size,
|
||||
string Kind,
|
||||
string Label,
|
||||
string ObjectKey);
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Запись истории движения карточки — элемент массива <c>history</c> (этап 9, T6).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Ровно один из ключей type/stage: второй опускается сериализацией. Type — создание
|
||||
/// (<c>created</c>/<c>createdLocal</c>), Stage — новая стадия при переносе. At — epoch-ms.
|
||||
/// </remarks>
|
||||
/// <param name="Id">Короткий id записи (<c>h_...</c>).</param>
|
||||
/// <param name="At">Время события, epoch-ms.</param>
|
||||
/// <param name="Type">Тип создания; null у move-записей.</param>
|
||||
/// <param name="Stage">Новая стадия; null у записей создания.</param>
|
||||
public sealed record CardHistoryDto(
|
||||
string Id,
|
||||
long At,
|
||||
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Type,
|
||||
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Stage);
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Ссылка карточки — элемент массива <c>links</c> (этап 9, T6; форма 1:1 с прежней проектной карточкой).
|
||||
/// </summary>
|
||||
/// <param name="Id">Короткий id ссылки (<c>pl_...</c>).</param>
|
||||
/// <param name="Name">Название ссылки.</param>
|
||||
/// <param name="Url">URL ссылки.</param>
|
||||
public sealed record CardLinkDto(
|
||||
string Id,
|
||||
string Name,
|
||||
string Url);
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Начальные поля ручного («локального») создания карточки — тело POST /api/cards (Ruling 6).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-форма тела: {title, summary, stack?, budget?, contact, tzText?, containerId?/stage?} — подмножество
|
||||
/// <see cref="CardPatch"/> плюс <see cref="ContainerId"/>. Дефолты — как в прототипе (pydantic): пустые
|
||||
/// строки; отсутствующие stack/containerId — null (стек пуст, контейнер — planned). Наружу — camelCase.
|
||||
/// </remarks>
|
||||
/// <param name="Title">Заголовок карточки (при создании Trim(); пустой допустим — 1:1 прототип).</param>
|
||||
/// <param name="Summary">Краткое содержание карточки.</param>
|
||||
/// <param name="Stack">Стек/направления (null — пусто).</param>
|
||||
/// <param name="Budget">Бюджет (from/to/cur); null — бюджета нет.</param>
|
||||
/// <param name="Contact">Контактная строка карточки.</param>
|
||||
/// <param name="TzText">Текст технического задания.</param>
|
||||
/// <param name="ContainerId">Желаемый контейнер-стадия (id каталога <see cref="Deal.Modules.Cards.Application.CardsDefaultContainers"/>);
|
||||
/// null или неизвестная — карточка создаётся в <c>planned</c> (Ruling 6).</param>
|
||||
public sealed record CardLocalCreateDto(
|
||||
string Title = "",
|
||||
string Summary = "",
|
||||
IReadOnlyList<string>? Stack = null,
|
||||
CardBudgetDto? Budget = null,
|
||||
string Contact = "",
|
||||
string TzText = "",
|
||||
string? ContainerId = null);
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Запись журнала действий над карточкой — параметр ICardStore.AddMoveAsync (таблица CardMoves, leads.py _log_learning L40–44).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Каждое действие пользователя (move/trash/restore/comment) пишет строку журнала; счётчик learning =
|
||||
/// число записей CardMoves (Ruling 4, Task 5). Id (<c>lm_...</c>) генерирует модуль (Ruling 12);
|
||||
/// CreatedAt проставляет хранилище (UTC-now). Журнал живёт дольше карточки — FK нет (Ruling 1).
|
||||
/// </remarks>
|
||||
public sealed record CardMoveDto(
|
||||
string Id,
|
||||
string LeadId,
|
||||
string Action,
|
||||
string? FromCol,
|
||||
string? ToCol);
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Частичная правка карточки (ICardStore.PatchCardAsync; 1:1 patch_card projects.py L159–187).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Значение null у поля означает «поле не меняется» (конвенция ContainerPatchDto); пустая строка или
|
||||
/// пустой массив — осмысленное значение и применяется. JSON-поля (stack/comments/links/files) заменяются
|
||||
/// ЦЕЛИКОМ, не сливаясь с текущими значениями. budget — объект {from,to,cur} либо null = «не менять»;
|
||||
/// явная очистка бюджета телом PATCH передаётся объектом с пустой Cur (BudgetCur пуст = «бюджета нет»;
|
||||
/// хранилище пишет from/to=null и cur="", наружу бюджет снова null). Наружу сериализуется в camelCase.
|
||||
/// </remarks>
|
||||
/// <param name="Title">Новый заголовок.</param>
|
||||
/// <param name="Summary">Новое краткое содержание.</param>
|
||||
/// <param name="Contact">Новая контактная строка.</param>
|
||||
/// <param name="TzText">Новый текст технического задания.</param>
|
||||
/// <param name="Stack">Новый стек (полная замена массива).</param>
|
||||
/// <param name="Budget">Новый бюджет (полная замена from/to/cur).</param>
|
||||
/// <param name="Comments">Новый массив комментариев (полная замена).</param>
|
||||
/// <param name="Links">Новый массив ссылок (полная замена).</param>
|
||||
/// <param name="Files">Новый массив файлов (полная замена).</param>
|
||||
public sealed record CardPatch(
|
||||
string? Title,
|
||||
string? Summary,
|
||||
string? Contact,
|
||||
string? TzText,
|
||||
IReadOnlyList<string>? Stack,
|
||||
CardBudgetDto? Budget,
|
||||
IReadOnlyList<CardCommentDto>? Comments,
|
||||
IReadOnlyList<CardLinkDto>? Links,
|
||||
IReadOnlyList<CardFileDto>? Files);
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Результат ручной переклассификации карточки — параметр ICardStore.ApplyReclassificationAsync.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Одно обновление полей классификации (leads.py reclassify_lead L346–367): колонка, тип (найм/заказ),
|
||||
/// заголовок/суть/стек, бюджет и его конверсия, контакты и matchHits. Пишется как есть (полная замена):
|
||||
/// бюджет без валюты (<see cref="Budget"/> = null) очищает поля, как и отсутствие конверсии.
|
||||
/// <see cref="IsVacancyKnown"/> на ИИ-пути = true (тип подтверждён по контексту), на локальном — маркерная
|
||||
/// гипотеза. PrevCol/ArchivedAt не трогаются — переклассификация не меняет историю возврата.
|
||||
/// JSON-поля (Stack/Contacts/MatchHits) сериализует адаптер при записи.
|
||||
/// </remarks>
|
||||
/// <param name="CardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <param name="Col">Новая колонка (доска либо inbox — страховку ContainerAccepts уже применил вызывающий).</param>
|
||||
/// <param name="IsNew">Флаг «новое» (переклассификация подсвечивает карточку — python is_new = TRUE).</param>
|
||||
/// <param name="IsVacancy">Признак найма/заказа после переклассификации.</param>
|
||||
/// <param name="IsVacancyKnown">Тип подтверждён классификатором (иначе — маркерная гипотеза).</param>
|
||||
/// <param name="Title">Новый заголовок (очищенный, ≤140).</param>
|
||||
/// <param name="Summary">Новый блок «О заявке» (≤2000).</param>
|
||||
/// <param name="Stack">Новый стек (полная замена массива).</param>
|
||||
/// <param name="Budget">Бюджет по исходнику; null — суммы нет (поля очищаются).</param>
|
||||
/// <param name="Converted">Бюджет в целевой валюте; null — конверсии нет (поля очищаются).</param>
|
||||
/// <param name="Contact">«Быстрый» контакт (значение основного контакта).</param>
|
||||
/// <param name="Contacts">Квалифицированные контакты (полная замена).</param>
|
||||
/// <param name="MatchHits">Совпавшие критерии правил доски (для inbox — пусто).</param>
|
||||
public sealed record CardReclassificationDto(
|
||||
string CardId,
|
||||
string Col,
|
||||
bool IsNew,
|
||||
bool IsVacancy,
|
||||
bool IsVacancyKnown,
|
||||
string Title,
|
||||
string Summary,
|
||||
IReadOnlyList<string> Stack,
|
||||
CardBudgetDto? Budget,
|
||||
CardBudgetDto? Converted,
|
||||
string Contact,
|
||||
IReadOnlyList<CardContactDto> Contacts,
|
||||
IReadOnlyList<MatchHitDto> MatchHits);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Напоминание карточки — объект <c>reminder</c> (этап 9, T6).
|
||||
/// </summary>
|
||||
/// <param name="At">Время напоминания, epoch-ms.</param>
|
||||
public sealed record CardReminderDto(long At);
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Строка «выстрелившего» напоминания — мини-DTO выборки ICardStore.ListDueRemindersAsync (Ruling 3).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Возвращается проверкой напоминаний (CardsService.CheckDueRemindersAsync), публикуется в SSE-событии
|
||||
/// <c>reminder_due</c> ({id,title,containerId}, api-map §2) и в ответе POST /api/admin/tick (<c>reminders</c>).
|
||||
/// id — карточки, containerId всегда <c>hold</c> на момент срабатывания. Сериализуется в camelCase.
|
||||
/// </remarks>
|
||||
/// <param name="Id">Id карточки (<c>c_...</c>).</param>
|
||||
/// <param name="Title">Заголовок карточки (для уведомления).</param>
|
||||
/// <param name="ContainerId">Контейнер карточки (всегда <c>hold</c> на момент срабатывания).</param>
|
||||
public sealed record CardReminderDueDto(
|
||||
string Id,
|
||||
string Title,
|
||||
string ContainerId);
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Результат мутации карточки, отвечающей карточкой: тонкий record-результат (перенос, ссылки, напоминания).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="Error"/> непуст — 400-текст фиксированной строки; <see cref="Card"/> == null при Error == null —
|
||||
/// карточки нет (404-семантику даёт null, эндпоинт отвечает «Карточка не найдена»); Card непуст — успех.
|
||||
/// Единая форма результатов мутаций карточки (перенос, ссылки, напоминания).
|
||||
/// </remarks>
|
||||
/// <param name="Error">Текст 400 (фиксированная строка) либо null.</param>
|
||||
/// <param name="Card">Карточка после мутации либо null (400/карточки нет).</param>
|
||||
public sealed record CardResultDto(string? Error, CardDto? Card);
|
||||
@@ -0,0 +1,164 @@
|
||||
namespace Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// «Сырая» запись для создания карточки (ICardStore.AddCardAsync) — пайплайн и ручное создание.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Write-модель: содержит полное состояние новой карточки (псевдоним строки Cards, Ruling 1), включая
|
||||
/// готовый id (<c>c_...</c>, генерирует модуль) и служебные значения, вычисленные до записи:
|
||||
/// matchHits (Ruling 2), conv-поля (BudgetNormalizer), prevCol=inbox, историю/локальный признак.
|
||||
/// CreatedAt проставляет хранилище (UTC-now), а human-метку time/маппинг — слой чтения (CardDto).
|
||||
/// JSON-массивы (Stack/Contacts/MatchHits/History) сериализует адаптер при записи.
|
||||
/// </remarks>
|
||||
public sealed record CardSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// Готовый id карточки (префикс <c>c_</c>), сгенерированный модулем.
|
||||
/// </summary>
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Контейнер размещения: inbox (пайплайн) либо стадия/доска (ручное создание).
|
||||
/// </summary>
|
||||
public string Col { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Новая карточка (точка «новое»); при ручном создании — false.
|
||||
/// </summary>
|
||||
public bool IsNew { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Признак «карточка создана локально» (без внешнего первоисточника).
|
||||
/// </summary>
|
||||
public bool Local { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Найм (вакансия) vs разовая сделка (маркерная гипотеза).
|
||||
/// </summary>
|
||||
public bool IsVacancy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Тип подтверждён ИИ (на этапе 3/демо — как в исходных данных).
|
||||
/// </summary>
|
||||
public bool IsVacancyKnown { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Заголовок карточки (очищенный, ≤140).
|
||||
/// </summary>
|
||||
public string Title { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Блок «О заявке» (очищенный, ≤2000).
|
||||
/// </summary>
|
||||
public string Summary { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Стек/направления.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Stack { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Нижняя граница бюджета (валюта — <see cref="BudgetCur"/>), либо null.
|
||||
/// </summary>
|
||||
public double? BudgetFrom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Верхняя граница бюджета (валюта — <see cref="BudgetCur"/>), либо null.
|
||||
/// </summary>
|
||||
public double? BudgetTo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Валюта бюджета; пусто — бюджет не задан.
|
||||
/// </summary>
|
||||
public string BudgetCur { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Сконвертированная нижняя граница (целевая валюта — <see cref="ConvCur"/>), либо null.
|
||||
/// </summary>
|
||||
public double? ConvFrom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Сконвертированная верхняя граница (целевая валюта — <see cref="ConvCur"/>), либо null.
|
||||
/// </summary>
|
||||
public double? ConvTo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Валюта сконвертированного бюджета; пусто — конверсия не сделана.
|
||||
/// </summary>
|
||||
public string ConvCur { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// «Быстрый» контакт (value основного контакта, primary_contact).
|
||||
/// </summary>
|
||||
public string Contact { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Квалифицированные контакты.
|
||||
/// </summary>
|
||||
public IReadOnlyList<CardContactDto> Contacts { get; init; } = Array.Empty<CardContactDto>();
|
||||
|
||||
/// <summary>
|
||||
/// Имя канала/диалога-источника.
|
||||
/// </summary>
|
||||
public string ChannelName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Handle канала-источника.
|
||||
/// </summary>
|
||||
public string ChannelHandle { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Цвет канала-источника (hex).
|
||||
/// </summary>
|
||||
public string ChannelHue { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Время получения исходного сообщения (сортировка DESC, автоархив).
|
||||
/// </summary>
|
||||
public DateTimeOffset ReceivedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Исходное сообщение (≤4000; для ML-обучения и поиска).
|
||||
/// </summary>
|
||||
public string SourceMsg { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Id диалога исходного сообщения.
|
||||
/// </summary>
|
||||
public string SourceDialogId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Id исходного сообщения в Telegram, либо null.
|
||||
/// </summary>
|
||||
public long? SourceMsgId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Предыдущая колонка (при создании — inbox).
|
||||
/// </summary>
|
||||
public string PrevCol { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Время помещения в архив (для правил очистки), либо null.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ArchivedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Совпавшие критерии правил (для inbox/досок без правил — пусто).
|
||||
/// </summary>
|
||||
public IReadOnlyList<MatchHitDto> MatchHits { get; init; } = Array.Empty<MatchHitDto>();
|
||||
|
||||
/// <summary>
|
||||
/// Текст технического задания (при создании может быть пустым).
|
||||
/// </summary>
|
||||
public string TzText { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Стартовые комментарии карточки (ручное создание — пусто; пишутся в таблицу LeadComments).
|
||||
/// </summary>
|
||||
public IReadOnlyList<CardCommentDto> Comments { get; init; } = Array.Empty<CardCommentDto>();
|
||||
|
||||
/// <summary>
|
||||
/// История движения: при ручном создании — одна запись (createdLocal); пайплайн — пусто.
|
||||
/// </summary>
|
||||
public IReadOnlyList<CardHistoryDto> History { get; init; } = Array.Empty<CardHistoryDto>();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Источник карточки на wire — объект <c>source</c> карточки (этап 9, T6).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Производная проекция от строки карточки (точная полиморфная иерархия <c>ISource</c> — домен):
|
||||
/// kind — вид источника, displayName — имя канала/источника, originRef — ссылка на первоисточник
|
||||
/// (id диалога/файла), receivedAt — момент получения (epoch-ms).
|
||||
/// </remarks>
|
||||
/// <param name="Kind">Вид источника: local/telegram/web/file/row/api/ai/composite/other.</param>
|
||||
/// <param name="DisplayName">Имя источника (канал/диалог).</param>
|
||||
/// <param name="OriginRef">Ссылка на первоисточник (id диалога/файла).</param>
|
||||
/// <param name="ReceivedAt">Момент получения, epoch-ms.</param>
|
||||
public sealed record CardSourceDto(
|
||||
string Kind,
|
||||
string DisplayName,
|
||||
string OriginRef,
|
||||
long ReceivedAt);
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Запрос списка карточек — параметр ICardStore.ListCardsAsync (фильтр по колонке, leads.py list_leads L151–156).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="Col"/> — null означает «все колонки дашборда» (без контейнеров-стадий «Выбранных»);
|
||||
/// иначе — карточки одной колонки. Сортировка всегда received_at DESC (задача адаптера).
|
||||
/// Валидацию значения колонки (inbox/archive/trash/существующая доска) выполняет сервис до вызова.
|
||||
/// </remarks>
|
||||
public sealed record CardsQuery(string? Col);
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Результат очистки служебной колонки — CardsService.ClearColAsync (clear_col L237–247, dashboard_routes L228–235).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Колонка не trash/archive → <see cref="Error"/> = текст 400 «Очищать можно только корзину или архив»;
|
||||
/// иначе <see cref="Cleared"/> — сколько карточек удалено навсегда (0 — колонка пуста). Ответ эндпоинта
|
||||
/// — {ok: true, cleared}; при ошибке — {detail} с текстом Error.
|
||||
/// </remarks>
|
||||
/// <param name="Error">Текст 400 (колонка не служебная trash/archive) либо null.</param>
|
||||
/// <param name="Cleared">Число удалённых карточек (валидная колонка); 0 при ошибке.</param>
|
||||
public sealed record ClearColResultDto(string? Error, int Cleared);
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Состояние одной колонки в KV-настройке <c>colState</c> — значение словаря «col → состояние» (leads.py L138–146).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire-форма значения — JSON-объект `{"collapsed": bool}` (и опционально `{"width": "sm"|"md"|"lg"}`,
|
||||
/// api-map §3.2 L76–77): в <c>colState</c> фронт хранит только свёрнутость служебных колонок
|
||||
/// (inbox/archive/trash) — ширину/свёрнутость ДОСОК держат поля Boards (Ruling 10). null у поля —
|
||||
/// «значение не задано»: при PATCH не меняет текущее, при записи не сериализуется
|
||||
/// (DefaultIgnoreCondition.WhenWritingNull, как прототип model_dump(exclude_none=True)).
|
||||
/// Неизвестные ключи внутри значения колонки типизированная модель не сохраняет (в реальных
|
||||
/// потоках фронта их нет — колонки пишутся только этим PATCH).
|
||||
/// </remarks>
|
||||
/// <param name="Collapsed">Свёрнута ли колонка в виджет (null — не задано).</param>
|
||||
/// <param name="Width">Ширина колонки sm|md|lg (null — не задано).</param>
|
||||
public sealed record ColumnStateDto(bool? Collapsed, string? Width);
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Счётчики карточек контейнера — поле <c>counts</c> ответа /api/containers (этап 9, T4).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Вычисляется чтением: строка GROUP BY по Cards (container → count, new). В БД не хранится.
|
||||
/// Наружу сериализуется в camelCase: {total, new}.
|
||||
/// </remarks>
|
||||
/// <param name="Total">Всего карточек в контейнере.</param>
|
||||
/// <param name="New">Из них «новых» (is_new = true).</param>
|
||||
public sealed record ContainerCountsDto(int Total, int New);
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Вход создания контейнера — параметр ContainersService.CreateAsync (POST /api/containers).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="Name"/> — обязательный (пробельное/пустое значение сервис заменяет на «Новая колонка»);
|
||||
/// <see cref="Color"/> — null/пустая строка означают «взять цвет палитры по позиции».
|
||||
/// <see cref="Space"/>/<see cref="Kind"/> задают пространство и вид контейнера (по умолчанию —
|
||||
/// пользовательская колонка дашборда <c>board</c>/<c>dashboard</c>). Правила — необязательны;
|
||||
/// <see cref="Suggested"/>/<see cref="Note"/> использует эвристика ИИ-предложений. Wire — camelCase.
|
||||
/// </remarks>
|
||||
/// <param name="Name">Имя колонки (пустое → «Новая колонка»).</param>
|
||||
/// <param name="Description">Описание колонки.</param>
|
||||
/// <param name="Color">Цвет (hex) либо null — из палитры.</param>
|
||||
/// <param name="Space">Пространство (по умолчанию dashboard).</param>
|
||||
/// <param name="Kind">Вид контейнера (по умолчанию board).</param>
|
||||
/// <param name="Suggested">Признак ИИ-предложения.</param>
|
||||
/// <param name="Rules">Правила попадания.</param>
|
||||
/// <param name="Note">Заметка/обоснование.</param>
|
||||
public sealed record ContainerCreateDto(
|
||||
string Name,
|
||||
string Description = "",
|
||||
string? Color = null,
|
||||
string Space = "dashboard",
|
||||
string Kind = "board",
|
||||
bool Suggested = false,
|
||||
ContainerRulesDto? Rules = null,
|
||||
string Note = "");
|
||||
@@ -0,0 +1,78 @@
|
||||
namespace Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Единый контейнер карточек: колонка дашборда, стадия «Выбранных» или служебная зона.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Приходит на смену BoardDto: таблица Containers — единственный реестр колонок/стадий/зон (этап 9, T4).
|
||||
/// Поля wire (camelCase): id/name/description/color/order/space/kind/collapsed/suggested/note/rules/policy/counts.
|
||||
/// <see cref="Rules"/> — null означает «правил нет»; <see cref="Policy"/> описывает поведение зоны;
|
||||
/// <see cref="Counts"/> заполняется при чтении (счётчики карточек контейнера) и не хранится в БД.
|
||||
/// </remarks>
|
||||
public sealed record ContainerDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Короткий id контейнера (доски <c>b_…</c>, стадии <c>planned…</c>, служебные inbox/archive/trash).
|
||||
/// </summary>
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Имя для отображения («WPF», «В работе», «Архив»).
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Описание контейнера (пользователю и для подсказки ИИ/ML).
|
||||
/// </summary>
|
||||
public string Description { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Цвет контейнера (hex).
|
||||
/// </summary>
|
||||
public string Color { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Позиция в пространстве (порядок показа; wire-имя — <c>order</c>).
|
||||
/// </summary>
|
||||
public int Order { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Id пространства: <c>dashboard</c> | <c>selected</c>.
|
||||
/// </summary>
|
||||
public string Space { get; init; } = ContainerSpaces.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Вид контейнера: <c>board</c> | <c>stage</c> | <c>service</c> | <c>terminal</c>.
|
||||
/// </summary>
|
||||
public string Kind { get; init; } = ContainerKinds.Board;
|
||||
|
||||
/// <summary>
|
||||
/// Свёрнутость колонки на дашборде (состояние UI).
|
||||
/// </summary>
|
||||
public bool Collapsed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Признак ИИ-предложения: контейнер ждёт решения пользователя.
|
||||
/// </summary>
|
||||
public bool Suggested { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Заметка контейнера (например, сгенерированное обоснование ИИ).
|
||||
/// </summary>
|
||||
public string Note { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Правила попадания карточки; null — фильтра нет (карточки кладутся вручную/ИИ).
|
||||
/// </summary>
|
||||
public ContainerRulesDto? Rules { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Политика контейнера: возврат, терминальность, автоочистка.
|
||||
/// </summary>
|
||||
public ContainerPolicyDto Policy { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Счётчики карточек контейнера (total/new); заполняются чтением, в БД не хранятся.
|
||||
/// </summary>
|
||||
public ContainerCountsDto Counts { get; init; } = new(0, 0);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Патч контейнера — допустимые изменения PATCH /api/containers/{id} (этап 9, T4).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Значение null у поля означает «поле не меняется» (в PATCH-теле отсутствует либо явно null).
|
||||
/// JSON-объекты rules/policy заменяются целиком, не сливаясь с текущими.
|
||||
/// Наружу и из wire сериализуется в camelCase.
|
||||
/// </remarks>
|
||||
/// <param name="Name">Новое имя (null — не менять).</param>
|
||||
/// <param name="Description">Новое описание (null — не менять).</param>
|
||||
/// <param name="Color">Новый цвет (null — не менять).</param>
|
||||
/// <param name="Collapsed">Новая свёрнутость (null — не менять).</param>
|
||||
/// <param name="Suggested">Признак ИИ-предложения (null — не менять).</param>
|
||||
/// <param name="Note">Новая заметка (null — не менять).</param>
|
||||
/// <param name="Rules">Новые правила (null — не менять).</param>
|
||||
/// <param name="Policy">Новая политика (null — не менять).</param>
|
||||
public sealed record ContainerPatchDto(
|
||||
string? Name,
|
||||
string? Description,
|
||||
string? Color,
|
||||
bool? Collapsed,
|
||||
bool? Suggested,
|
||||
string? Note,
|
||||
ContainerRulesDto? Rules,
|
||||
ContainerPolicyDto? Policy);
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Политика контейнера: правила жизненного цикла зоны (этап 9, T4).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Роль контейнера (не enum): можно ли вернуть карточку на доску пространства, терминальна ли зона,
|
||||
/// задана ли автоочистка. Хранится JSON-объектом в Containers.PolicyJson (camelCase); значения по
|
||||
/// умолчанию — «обычная колонка» (возврат разрешён, не терминальна, без автоочистки).
|
||||
/// </remarks>
|
||||
public sealed record ContainerPolicyDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Можно ли вернуть карточку из контейнера на доску пространства (архив/корзина — да).
|
||||
/// </summary>
|
||||
public bool CanRestore { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Терминальная зона: завершение жизненного пути, только ручная очистка без возврата.
|
||||
/// </summary>
|
||||
public bool IsTerminal { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Срок автоочистки карточек контейнера в днях; null — автоочистки нет.
|
||||
/// </summary>
|
||||
public int? RetentionDays { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Правила фильтра колонки — объект <c>rules</c> доски (§4.2 L270–275, §6.3, BoardRulesDialog.vue).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Форма совпадает с формой диалога правил фронта: <c>mode</c> — «all» (все группы обязательны) либо
|
||||
/// «any» (любая из групп); direction/keywords/stack/grade/levels/locations/types/exclude — группы термов;
|
||||
/// budget/prices — опциональные диапазоны. Правила маршрутизируют входящие карточки (Ruling 2, Task 3).
|
||||
/// Пустые списки и отсутствующие ключи в JSON (хранится как text, формат <c>{}</c> — нет правил) приводит
|
||||
/// к маппингу. Наружу сериализуется в camelCase:
|
||||
/// mode/direction/keywords/stack/grade/exclude/budget + levels/locations/types/prices (этап 12, §6.3).
|
||||
/// Новые группы добавлены в конец с дефолтами — существующие позиционные вызовы и сохранённый RulesJson
|
||||
/// обратно совместимы.
|
||||
/// </remarks>
|
||||
public sealed record ContainerRulesDto(
|
||||
string Mode,
|
||||
IReadOnlyList<string> Direction,
|
||||
IReadOnlyList<string> Keywords,
|
||||
IReadOnlyList<string> Stack,
|
||||
IReadOnlyList<string> Grade,
|
||||
IReadOnlyList<string> Exclude,
|
||||
BudgetRangeDto? Budget,
|
||||
IReadOnlyList<string>? Levels = null,
|
||||
IReadOnlyList<string>? Locations = null,
|
||||
IReadOnlyList<string>? Types = null,
|
||||
BudgetRangeDto? Prices = null);
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Совпадение критерия правил — элемент массива <c>matchHits</c> (§4.1 L251, rules.py hits L271–296).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// «Почему карточка в колонке»: label — группа критерия («Направление»/«Слова»/«Стек»/«Грейд/уровень»/
|
||||
/// «Бюджет», Ruling 2), term — совпавший терм, word — опциональное слово (для грейдов/уровней).
|
||||
/// Для служебных колонок (inbox/archive/trash) и досок без активных правил — пустой список.
|
||||
/// Наружу сериализуется в camelCase: label/term/word.
|
||||
/// </remarks>
|
||||
public sealed record MatchHitDto(
|
||||
string Label,
|
||||
string Term,
|
||||
string? Word);
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Строка очереди обучения ML (таблица MlOutbox) — срез для выгрузки батча (план Task 16, Ruling 6).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Возвращается хранилищем через <see cref="IMlLearningStore.TakeOutboxBatchAsync"/> в порядке
|
||||
/// <c>created_at</c> (1:1 с выборкой python flush_outbox L66–69) и уходит в ml-service батчем
|
||||
/// TrainBatch (поля text/label/delta — ровно колонки ml_outbox; id нужен вызывающему для удаления
|
||||
/// строк только после успешной отправки).
|
||||
/// </remarks>
|
||||
/// <param name="Id">Id строки очереди (<c>mle_...</c>).</param>
|
||||
/// <param name="Text">Текст обучающего примера (trim-нут, ≤6000 символов).</param>
|
||||
/// <param name="Label">Метка обучения: id доски, <c>spam</c> либо <c>t:hire|t:order</c>.</param>
|
||||
/// <param name="Delta">Вес сигнала (1.0 — учить, −1.0 — снять метку).</param>
|
||||
public sealed record MlOutboxEntryDto(
|
||||
string Id,
|
||||
string Text,
|
||||
string Label,
|
||||
double Delta);
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Статистика тика правил хранения — поле <c>storage</c> ответа POST /api/admin/tick (Ruling 8, leads.py tick_storage L454–493).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// archived — карточки, ушедшие в архив по autoArchive; purgedArchive — очищено из архива по сроку
|
||||
/// (archiveClearDays); purgedTrash — очищено из корзины (trashClearDays); purgedRejected — отсев
|
||||
/// пайплайна (этап 4; в этапе 3 всегда 0). Тексты SSE-тостов по статистике — 1:1 (Ruling 8).
|
||||
/// Наружу сериализуется в camelCase: archived/purgedArchive/purgedTrash/purgedRejected.
|
||||
/// </remarks>
|
||||
public sealed record StorageTickStatsDto(
|
||||
int Archived,
|
||||
int PurgedArchive,
|
||||
int PurgedTrash,
|
||||
int PurgedRejected);
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
/// <summary>
|
||||
/// План одной колонки-предложения — элемент выхода <c>SuggestHeuristics</c> (Ruling 3, Task 14).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Чистая структура модуля Kanban: тема-слово в нижнем регистре (<see cref="Word"/>), готовое имя
|
||||
/// колонки (<see cref="Name"/> — Word с заглавной буквы), id карточек «Неразобранного», собранных
|
||||
/// в группу (≥2, в порядке выдачи окна анализа), и note-обоснование (<see cref="Note"/> —
|
||||
/// «Эвристика (этап 3): …; реальные предложения ИИ — этап 6»). Адаптер
|
||||
/// <c>LocalColumnSuggester</c> (Infrastructure) превращает план в доску suggested=true
|
||||
/// (RulesJson {mode:"any", keywords:[Word]}) и раскладывает карточки. Правила с одним ключевым словом
|
||||
/// достаточны: группа собирается именно по этому слову, и оно же маршрутизирует будущие входящие
|
||||
/// (после принятия колонки пользователем).
|
||||
/// </remarks>
|
||||
/// <param name="Word">Тема-слово группы (нижний регистр; станет keywords-правилом колонки).</param>
|
||||
/// <param name="Name">Имя колонки-предложения (Word с заглавной буквы, ≤40 символов).</param>
|
||||
/// <param name="CardIds">Id карточек группы (в порядке выдачи окна, ≥2, без пересечений между планами).</param>
|
||||
/// <param name="Note">Обоснование колонки (сколько карточек и по какому слову собраны).</param>
|
||||
public sealed record SuggestedColumnPlan(
|
||||
string Word,
|
||||
string Name,
|
||||
IReadOnlyList<string> CardIds,
|
||||
string Note);
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Генератор коротких префиксных id модуля Kanban (Ruling 12, прототип store.uid = prefix + uuid4().hex[:12]).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Не GUID: прототип и фронт требуют коротких ключей в JSON (id досок/карточек попадают в wire как
|
||||
/// ключи). Случайная часть — 12 hex-символов (6 байт CSPRNG), 1:1 с <c>uuid4().hex[:12]</c> прототипа.
|
||||
/// Префиксы — <see cref="KanbanIdPrefixes"/> (b_/c_/cm_/lm_/mle_/pl_/pf_/h_): модуль генерирует id и передаёт в
|
||||
/// хранилище готовыми (порт id не создаёт). Использование: CardsService, эвристика suggest.
|
||||
/// </remarks>
|
||||
public static class PrefixId
|
||||
{
|
||||
// Размер случайной части в байтах: 6 байт → 12 hex-символов (uuid4().hex[:12]).
|
||||
private const int RandomHexBytes = 6;
|
||||
|
||||
/// <summary>
|
||||
/// Новый id: префикс + 12 случайных hex-символов в нижнем регистре.
|
||||
/// </summary>
|
||||
/// <param name="prefix">Префикс типа записи (см. <see cref="KanbanIdPrefixes"/>).</param>
|
||||
/// <returns>Короткий id записи (например, <c>b_1a2b3c4d5e6f</c>).</returns>
|
||||
public static string New(string prefix)
|
||||
{
|
||||
return prefix + Convert.ToHexString(RandomNumberGenerator.GetBytes(RandomHexBytes)).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Settings.Application;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Сервис правил хранения: автоархив и очистка архива/корзины по срокам — Ruling 8, план Task 10.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Чистый сервис модуля (без EF/HTTP), повторяет <c>leads.py tick_storage</c> (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,294 @@
|
||||
using System.Text;
|
||||
using Deal.Modules.Cards.Application;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application;
|
||||
|
||||
/// <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..];
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Deal.SharedKernel\Deal.SharedKernel.csproj" />
|
||||
<ProjectReference Include="..\Deal.Contracts\Deal.Contracts.csproj" />
|
||||
<ProjectReference Include="..\Deal.Modules.Settings\Deal.Modules.Settings.csproj" />
|
||||
<ProjectReference Include="..\Deal.Modules.Cards\Deal.Modules.Cards.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.11" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Deal.Modules.Kanban;
|
||||
|
||||
/// <summary>
|
||||
/// Маркер модуля Kanban: используется для DI-сканирования и тестов.
|
||||
/// </summary>
|
||||
public sealed class KanbanModuleMarker
|
||||
{
|
||||
}
|
||||
Reference in New Issue
Block a user