Разбить модули Deal.Modules.* по назначению
Application проектов Discovery, Kanban, Pipeline, Settings, Tenants разделён на Abstractions/Exceptions/Extensions/Models/Registrars/Services; namespace приведён к путям, using потребителей мигрированы и дедуплицированы (169 файлов), cref/FQN обновлены.
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Modules.Settings.Application.Registrars;
|
||||
using Deal.Modules.Settings.Application.Services;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Extensions;
|
||||
using Deal.Modules.Kanban.Application.Registrars;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Нормализация бюджета «при поступлении»: приведение к форме хранения и пересчёт в целевую валюту
|
||||
/// (ai.py clean_budget L316–326, budget_to_target L342–352; Ruling 7 — чистый BudgetNormalizer).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Чистый класс без хранилища: <see cref="Normalize"/> приводит произвольный бюджет (из ИИ/локального
|
||||
/// разбора) к соглашению хранения — одна сумма X → from=to=X, «до X» → from=null, from=0 → null
|
||||
/// («от 0 до X» == «до X»); валюта нормализуется алиасами к коду (ai.py _norm_currency L279–291).
|
||||
/// <see cref="ToTarget"/> пересчитывает нормализованный бюджет в целевую валюту тенанта по курсам —
|
||||
/// «интерфейс курсов» это словарь «код → курс к рублю» + чистая <see cref="RatesService.ConvertAmount"/>
|
||||
/// (USDT=USD, rates.py L86–103); чтение настроек conversionOn/targetCurrency и кэша ratesCache остаётся
|
||||
/// за вызывающим (демо Task 13, пайплайн этапа 4, ConversionRecomputer Task 12).
|
||||
/// </remarks>
|
||||
public static class BudgetNormalizer
|
||||
{
|
||||
// Дефолтная целевая валюта (ai.py budget_to_target L347: targetCurrency or "RUB").
|
||||
private const string DefaultTargetCurrency = "RUB";
|
||||
|
||||
// Синонимы валют из ответов ИИ → коды хранения (ai.py _CUR_ALIASES L271–276, согласовано с rules._CUR_*).
|
||||
private static readonly IReadOnlyDictionary<string, string> CurrencyAliases = new Dictionary<string, string>
|
||||
{
|
||||
["USD"] = "USD", ["$"] = "USD", ["US$"] = "USD", ["ДОЛЛАР"] = "USD", ["ДОЛЛАРОВ"] = "USD",
|
||||
["ДОЛЛ"] = "USD", ["БАКС"] = "USD", ["БАКСОВ"] = "USD",
|
||||
["EUR"] = "EUR", ["€"] = "EUR", ["ЕВРО"] = "EUR",
|
||||
["RUB"] = "RUB", ["RUR"] = "RUB", ["₽"] = "RUB", ["РУБ"] = "RUB", ["РУБЛЕЙ"] = "RUB",
|
||||
["РУБЛИ"] = "RUB", ["РУБЛЬ"] = "RUB", ["РУБЛЯ"] = "RUB",
|
||||
["GBP"] = "GBP", ["£"] = "GBP", ["CNY"] = "CNY", ["¥"] = "CNY", ["USDT"] = "USDT", ["₮"] = "USDT",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Нормализует бюджет к форме хранения (ai.py clean_budget L316–326).
|
||||
/// </summary>
|
||||
/// <param name="budget">Бюджет из ИИ/локального разбора (null — бюджета нет).</param>
|
||||
/// <returns>
|
||||
/// Нормализованный бюджет CardBudgetDto(from/to/cur) либо null: бюджет отсутствует, валюта не
|
||||
/// распознана или обе границы отсутствуют/равны нулю (чистый dict → None в прототипе).
|
||||
/// from=0 трактуется как отсутствие нижней границы; to без from у «одной суммы» приравнивается к from.
|
||||
/// </returns>
|
||||
public static CardBudgetDto? Normalize(BudgetRangeDto? budget)
|
||||
{
|
||||
if (budget is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? cur = NormalizeCurrency(budget.Cur);
|
||||
if (cur is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
double? from = NormalizeBound(budget.From);
|
||||
double? to = NormalizeBound(budget.To);
|
||||
if (from is null && to is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (to is null)
|
||||
{
|
||||
to = from; // одна сумма или «от X» без верхней границы (ai.py L337–338)
|
||||
}
|
||||
|
||||
return new CardBudgetDto(from, to, cur);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Пересчёт бюджета в целевую валюту «один раз при поступлении» (ai.py budget_to_target L342–352).
|
||||
/// </summary>
|
||||
/// <param name="budget">Нормализованный бюджет (валюта — код, см. <see cref="Normalize"/>).</param>
|
||||
/// <param name="conversionOn">Настройка conversionOn (Ruling 7); false → конверсия снята.</param>
|
||||
/// <param name="targetCurrency">Настройка targetCurrency (пустая → RUB, как в прототипе).</param>
|
||||
/// <param name="rates">Курсы к рублю «код → курс» (кэш ratesCache либо мок-курсы).</param>
|
||||
/// <returns>
|
||||
/// Сконвертированный бюджет CardBudgetDto(convFrom, convTo, targetCurrency), либо null — конверсия
|
||||
/// выключена/бюджета нет/валюта не задана (в прототипе это convCur=""). При валюте, отсутствующей в
|
||||
/// курсах, границы null, но целевая валюта сохраняется (как budget_to_target L351–357).
|
||||
/// </returns>
|
||||
public static CardBudgetDto? ToTarget(
|
||||
CardBudgetDto? budget,
|
||||
bool conversionOn,
|
||||
string? targetCurrency,
|
||||
IReadOnlyDictionary<string, double>? rates)
|
||||
{
|
||||
if (budget is null || string.IsNullOrWhiteSpace(budget.Cur))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string target = string.IsNullOrWhiteSpace(targetCurrency)
|
||||
? DefaultTargetCurrency
|
||||
: targetCurrency.ToUpperInvariant();
|
||||
if (!conversionOn)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
double? convFrom = null;
|
||||
double? convTo = null;
|
||||
if (budget.From is not null)
|
||||
{
|
||||
convFrom = Convert(budget.From.Value, budget.Cur, target, rates);
|
||||
}
|
||||
|
||||
if (budget.To is not null)
|
||||
{
|
||||
convTo = Convert(budget.To.Value, budget.Cur, target, rates);
|
||||
}
|
||||
else if (budget.From is not null)
|
||||
{
|
||||
convTo = convFrom;
|
||||
}
|
||||
|
||||
return new CardBudgetDto(convFrom, convTo, target);
|
||||
}
|
||||
|
||||
// Число границы: 0 → null (отсутствие границы), иначе значение (ai.py _budget_num L294–313: x==0 → None).
|
||||
// value: Значение границы из бюджета.
|
||||
// Возвращает: Значение или null при 0.
|
||||
private static double? NormalizeBound(double? value)
|
||||
{
|
||||
if (value is null || value.Value == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.Value;
|
||||
}
|
||||
|
||||
// Приводит название/символ валюты из ИИ к коду (ai.py _norm_currency L279–291).
|
||||
// raw: Валюта как пришла («рублей», «$», «usd», …).
|
||||
// Возвращает: Код валюты (USD/RUB/…) или null, если не распознана.
|
||||
private static string? NormalizeCurrency(string? raw)
|
||||
{
|
||||
string s = (raw ?? string.Empty).Trim().ToUpperInvariant();
|
||||
if (s.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (CurrencyAliases.TryGetValue(s, out string? direct))
|
||||
{
|
||||
return direct;
|
||||
}
|
||||
|
||||
string letters = new string(s.Where(c => c.IsCurrencyLetter()).ToArray());
|
||||
if (CurrencyAliases.TryGetValue(letters, out string? fromLetters))
|
||||
{
|
||||
return fromLetters;
|
||||
}
|
||||
|
||||
if (s.Length == 3 && s.All(c => c.IsCurrencyLetter()))
|
||||
{
|
||||
return s;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Конвертация суммы через курсы к рублю; null rates → null (курсов нет — граница не конвертируется).
|
||||
// amount: Сумма.
|
||||
// fromCurrency: Исходная валюта (код).
|
||||
// toCurrency: Целевая валюта (код).
|
||||
// rates: Курсы к рублю либо null.
|
||||
// Возвращает: Сумма в целевой валюте или null.
|
||||
private static double? Convert(double amount, string fromCurrency, string toCurrency, IReadOnlyDictionary<string, double>? rates)
|
||||
{
|
||||
return rates is null ? null : RatesService.ConvertAmount(amount, fromCurrency, toCurrency, rates);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using Deal.Contracts.Integrations;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Extensions;
|
||||
using Deal.Modules.Kanban.Application.Registrars;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Файлы карточки — partial-часть <see cref="CardsService"/> (этап 9: тот же домен карточки):
|
||||
/// оркестрация файлового хранилища и метаданных FilesJson (files.py L57–94).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Вложения живут в двух местах — объект файла в файловом хранилище (порт <see cref="IFileStorage"/>) и
|
||||
/// метаданные JSON-массивом <c>FilesJson</c> карточки (запись {id,name,size,kind,label,objectKey}).
|
||||
/// add_file — объект сохраняется первым, затем метаданные дописываются атомарным jsonb-append; get_file_entry —
|
||||
/// запись по id для download; remove_file — объект удаляется из хранилища (при непустом objectKey), запись
|
||||
/// убирается из массива атомарной jsonb-фильтрацией. 404-семантика — null-результатом методов. Id записей —
|
||||
/// PrefixId с префиксом <c>pf_</c>; objectKey строит сервис.
|
||||
/// </remarks>
|
||||
public sealed partial class CardsService
|
||||
{
|
||||
// Первый сегмент objectKey — каталог вложений карточек (object_store.py L65: «projects/{card}/{ms}_{name}»).
|
||||
private const string ObjectRootSegment = "projects";
|
||||
|
||||
// Замена недопустимых символов имени в objectKey (path-разделители/кавычки заменяются).
|
||||
private const char KeyNameReplacement = '_';
|
||||
|
||||
// Символы имени, заменяемые в objectKey: path-разделители («/», «\») и кавычки «"».
|
||||
private const string KeyNameUnsafeCharacters = "/\\\"";
|
||||
|
||||
// Имя файла по умолчанию при пустом имени из multipart (прототип: «f.filename or "file"»).
|
||||
private const string DefaultAttachmentName = "file";
|
||||
|
||||
/// <summary>
|
||||
/// Добавляет файл карточке: объект в хранилище + метаданные в конец массива files (files.py add_file L57–75).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Порядок 1:1 с прототипом: карточки нет → null (404) ДО записи объекта. Kind — через
|
||||
/// <see cref="FileKindDetector.Detect"/> по contentType и расширению; id записи (pf_) генерируется ДО
|
||||
/// ключа и входит в него: objectKey = projects/{cardId}/{fileId}_{unixMs}_{safeName} — две загрузки в одну
|
||||
/// миллисекунду не перезапишут объект. Имя в ключе санитизируется, в метаданных остаётся как прислано.
|
||||
/// Карточка исчезла между чтением и записью — объект удаляется (сирота не нужен) и возвращается null (404).
|
||||
/// </remarks>
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <param name="fileName">Имя файла как прислано (в objectKey санитизируется); пустое → «file».</param>
|
||||
/// <param name="contentType">MIME-тип загрузки (может быть null/пустым — детект по расширению).</param>
|
||||
/// <param name="content">Поток содержимого файла (читается хранилищем с позиции 0).</param>
|
||||
/// <param name="size">Длина содержимого в байтах (пишется в метаданные записи).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Метаданные добавленного файла или null — карточки нет (404).</returns>
|
||||
public async Task<CardFileDto?> AddFileAsync(
|
||||
string cardId,
|
||||
string fileName,
|
||||
string? contentType,
|
||||
Stream content,
|
||||
long size,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
|
||||
CardDto? card = await _store.GetCardAsync(cardId, ct);
|
||||
if (card is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string name = string.IsNullOrWhiteSpace(fileName) ? DefaultAttachmentName : fileName;
|
||||
CardFileKind kind = FileKindDetector.Detect(name, contentType);
|
||||
string fileId = PrefixId.New(KanbanIdPrefixes.File);
|
||||
string objectKey = BuildObjectKey(cardId, fileId, name);
|
||||
await _storage.PutAsync(objectKey, content, contentType ?? string.Empty, ct);
|
||||
|
||||
var entry = new CardFileDto(
|
||||
fileId,
|
||||
name,
|
||||
size,
|
||||
kind.Kind,
|
||||
kind.Label,
|
||||
objectKey);
|
||||
bool updated = await _store.AddFileAsync(cardId, entry, ct);
|
||||
if (!updated)
|
||||
{
|
||||
// Карточка исчезла между чтением и записью (гонка): объект-сирота в хранилище не нужен —
|
||||
// удаляем и отвечаем 404-семантикой (DeleteAsync сбои не бросает).
|
||||
await _storage.DeleteAsync(objectKey, ct);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Запись файла по id для download-эндпоинта (files.py get_file_entry L78–83).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Возвращает ТОЛЬКО метаданные (включая objectKey/name): поток объекта для ответа резолвит эндпоинт через
|
||||
/// <see cref="IFileStorage.GetAsync"/>. Карточки нет или записи с таким id нет → null (оба случая — 404).
|
||||
/// </remarks>
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <param name="fileId">Id записи файла (<c>pf_...</c>).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Метаданные записи файла либо null (карточка/запись не найдены).</returns>
|
||||
public async Task<CardFileDto?> GetFileEntryAsync(string cardId, string fileId, CancellationToken ct)
|
||||
{
|
||||
CardDto? card = await _store.GetCardAsync(cardId, ct);
|
||||
if (card is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return card.Files.FirstOrDefault(file => file.Id == fileId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Удаляет файл карточки: объект из хранилища + запись из массива files (files.py remove_file L86–94).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Карточки нет → null (404). Объект удаляется только когда запись найдена и у неё непустой objectKey.
|
||||
/// Запись убирается из FilesJson атомарной jsonb-фильтрацией; записи с указанным id нет — список остаётся
|
||||
/// прежним, ошибки НЕТ.
|
||||
/// </remarks>
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <param name="fileId">Id удаляемой записи файла (<c>pf_...</c>).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Карточка после удаления (без записи) либо null — карточки нет (404-семантика).</returns>
|
||||
public async Task<CardDto?> RemoveFileAsync(string cardId, string fileId, CancellationToken ct)
|
||||
{
|
||||
CardDto? card = await _store.GetCardAsync(cardId, ct);
|
||||
if (card is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
CardFileDto? entry = card.Files.FirstOrDefault(file => file.Id == fileId);
|
||||
if (entry is not null && !string.IsNullOrWhiteSpace(entry.ObjectKey))
|
||||
{
|
||||
await _storage.DeleteAsync(entry.ObjectKey, ct);
|
||||
}
|
||||
|
||||
if (!await _store.RemoveFileAsync(cardId, fileId, ct))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await _store.GetCardAsync(cardId, ct)
|
||||
?? throw new InvalidOperationException("Карточка не прочиталась после удаления файла: " + cardId);
|
||||
}
|
||||
|
||||
// Строит objectKey файла: projects/{cardId}/{fileId}_{unixMs}_{safeName} (object_store.py put L65).
|
||||
// cardId: Id карточки (c_...).
|
||||
// fileId: Id записи файла (pf_...; уникальный суффикс ключа).
|
||||
// name: Имя файла как прислано (в ключ идёт санитизированная часть).
|
||||
// Возвращает: Ключ объекта (opaque для хранилища).
|
||||
private static string BuildObjectKey(string cardId, string fileId, string name)
|
||||
{
|
||||
return $"{ObjectRootSegment}/{cardId}/{fileId}_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}_{SanitizeKeyName(name)}";
|
||||
}
|
||||
|
||||
// Санитизирует имя файла для objectKey: path-разделители и кавычки заменяются «_».
|
||||
// name: Имя файла как прислано.
|
||||
// Возвращает: Безопасная часть имени для ключа.
|
||||
private static string SanitizeKeyName(string name)
|
||||
{
|
||||
foreach (char character in KeyNameUnsafeCharacters)
|
||||
{
|
||||
name = name.Replace(character, KeyNameReplacement);
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using Deal.Contracts.Integrations;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Cards.Application.Abstractions;
|
||||
using Deal.Modules.Cards.Application.Dtos;
|
||||
using Deal.Modules.Cards.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.ColumnRules;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Modules.Settings.Application.Registrars;
|
||||
using Deal.Modules.Settings.Application.Services;
|
||||
// Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён — внутри пространства имён
|
||||
// Deal.Modules.Kanban.Application имя ColumnRules резолвится в пространство (CS0234), нужен явный алиас.
|
||||
using KanbanColumnRules = Deal.Modules.Kanban.Application.ColumnRules.ColumnRules;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Extensions;
|
||||
using Deal.Modules.Kanban.Application.Registrars;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Приватные помощники <see cref="CardsService"/> — partial-часть (C32: выделено из общего файла,
|
||||
/// поведение не менялось): текст обучающего примера, перенос колонки с журналом, hits правил доски и
|
||||
/// колонка возврата (leads.py L163–174, L209, L311–319; Ruling 2/4).
|
||||
/// </summary>
|
||||
public sealed partial class CardsService
|
||||
{
|
||||
// ── Внутреннее ─────────────────────────────────────────────────────────
|
||||
|
||||
// Текст обучающего примера: source_msg (после Trim) или title (leads.py L167, L189–190, L199–200).
|
||||
// card: Карточка.
|
||||
// Возвращает: source_msg без краевых пробелов; пустой — title как сохранён (1:1 с (x or "").strip() or (y or "")).
|
||||
private static string LearningText(CardDto card)
|
||||
{
|
||||
string source = card.SourceMsg.Trim();
|
||||
return source.Length > 0 ? source : card.Title;
|
||||
}
|
||||
|
||||
// Меняет колонку карточки и пишет строку журнала CardMoves (leads.py _move L170–174).
|
||||
// card: Карточка ДО переноса (для prev_col/from_col журнала).
|
||||
// toCol: Новая колонка.
|
||||
// hits: matchHits для новой колонки (пересчитаны вызывающим, Ruling 2).
|
||||
// action: Действие журнала: move/trash.
|
||||
// ct: Токен отмены.
|
||||
private async Task MoveToColumnAsync(CardDto card, string toCol, IReadOnlyList<MatchHitDto> hits, string action, CancellationToken ct)
|
||||
{
|
||||
await _store.UpdateColumnAsync(new CardColumnUpdateDto(
|
||||
CardId: card.Id,
|
||||
Col: toCol,
|
||||
IsNew: false,
|
||||
PrevCol: card.Col,
|
||||
ArchivedAt: null,
|
||||
MatchHits: hits), ct);
|
||||
await LogMoveAsync(card.Id, action, card.Col, toCol, ct);
|
||||
}
|
||||
|
||||
// Пишет строку журнала действия (leads.py _log_learning L40–44): id lm_ генерирует модуль.
|
||||
// cardId: Id карточки.
|
||||
// action: Действие: move/trash/restore/comment.
|
||||
// fromCol: Прежняя колонка (для comment — null).
|
||||
// toCol: Новая колонка (для comment — null).
|
||||
// ct: Токен отмены.
|
||||
private async Task LogMoveAsync(string cardId, string action, string? fromCol, string? toCol, CancellationToken ct)
|
||||
{
|
||||
await _store.AddMoveAsync(new CardMoveDto(
|
||||
PrefixId.New(KanbanIdPrefixes.CardMove),
|
||||
cardId,
|
||||
action,
|
||||
fromCol,
|
||||
toCol), ct);
|
||||
}
|
||||
|
||||
// Совпавшие критерии правил доски (hits_for_board L311–319 через ColumnRules, Ruling 2).
|
||||
// boardId: Id доски (b_...).
|
||||
// text: Текст карточки для правил (source_msg или title).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Список совпавших критериев; доски нет/правил нет → пусто.
|
||||
private async Task<IReadOnlyList<MatchHitDto>> ComputeHitsForBoardAsync(string boardId, string text, CancellationToken ct)
|
||||
{
|
||||
ContainerDto? board = await _store.GetContainerAsync(boardId, ct);
|
||||
return board is null
|
||||
? Array.Empty<MatchHitDto>()
|
||||
: await ComputeHitsAsync(board.Rules, text, ct);
|
||||
}
|
||||
|
||||
// Совпавшие критерии правил колонки-доски (ColumnRules.ComputeHits с курсами для бюджета).
|
||||
// Резолв имени — через алиас KanbanColumnRules: одноимённые класс и namespace ColumnRules в одном модуле.
|
||||
// rules: Правила доски; null («правил нет») → пусто (Ruling 2).
|
||||
// text: Текст карточки для правил.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Совпавшие критерии (label/term[/word]); нет активных правил → пусто.
|
||||
private async Task<IReadOnlyList<MatchHitDto>> ComputeHitsAsync(ContainerRulesDto? rules, string text, CancellationToken ct)
|
||||
{
|
||||
// Кэш курсов из типизированного снимка настроек (C30): null → мок-курсы (дефолт RatesService).
|
||||
IReadOnlyDictionary<string, double> rates =
|
||||
(await TenantSettingsSnapshot.LoadAsync(_settings, ct)).TryGetRatesCache()?.Rates ?? MockRates.Values;
|
||||
return KanbanColumnRules.ComputeHits(rules, text, rates);
|
||||
}
|
||||
|
||||
// Колонка возврата карточки: prev_col, если inbox или существующая доска, иначе inbox (restore_lead L209).
|
||||
// prevCol: Сохранённая prev_col карточки.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Колонка возврата (inbox/доска).
|
||||
private async Task<string> ResolveReturnColAsync(string prevCol, CancellationToken ct)
|
||||
{
|
||||
if (prevCol == CardIds.Inbox)
|
||||
{
|
||||
return prevCol;
|
||||
}
|
||||
|
||||
ContainerDto? board = await _store.GetContainerAsync(prevCol, ct);
|
||||
return board is null ? CardIds.Inbox : prevCol;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
using Deal.Contracts.Integrations;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Cards.Application.Abstractions;
|
||||
using Deal.Modules.Cards.Application.Dtos;
|
||||
using Deal.Modules.Cards.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.ColumnRules;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Modules.Settings.Application.Registrars;
|
||||
using Deal.Modules.Settings.Application.Services;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Extensions;
|
||||
using Deal.Modules.Kanban.Application.Registrars;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Публичные операции карточек — partial-часть <see cref="CardsService"/> (C32: выделено из общего
|
||||
/// файла по темам, поведение не менялось): чтение списка/карточки, переносы/корзина/возврат/удаление/
|
||||
/// очистка колонки, комментарии, mark-seen, счётчики и поиск (leads.py L151–279, L509–551).
|
||||
/// </summary>
|
||||
public sealed partial class CardsService
|
||||
{
|
||||
// ── Чтение (list_leads/get_lead L151–160) ───────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Карточки колонки или всех колонок дашборда, received_at DESC (list_leads L151–156).
|
||||
/// </summary>
|
||||
/// <param name="col">Колонка-фильтр (inbox/archive/trash/доска); null — все колонки дашборда.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Полные карточки (маппинг/комментарии/time — адаптер); пусто — карточек нет.</returns>
|
||||
public Task<IReadOnlyList<CardDto>> ListCardsAsync(string? col, CancellationToken ct)
|
||||
{
|
||||
return _store.ListCardsAsync(new CardsQuery(col), ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Одна карточка по id (get_lead L159–160; GET /api/cards/{id}).
|
||||
/// </summary>
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Карточка или null — строки нет (эндпоинт отвечает 404 «Карточка не найдена»).</returns>
|
||||
public Task<CardDto?> GetCardAsync(string cardId, CancellationToken ct)
|
||||
{
|
||||
return _store.GetCardAsync(cardId, ct);
|
||||
}
|
||||
|
||||
// ── Переносы / архив / корзина (L163–247) ───────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Перенос карточки на доску или в «Неразобранное» (move_lead L177–191 + _move L163–174).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Цель валидируется до чтения карточки: не inbox и не существующая доска → 400
|
||||
/// <see cref="MoveTargetInvalidDetail"/>. Исходная колонка archive/trash для MoveLeadAsync недоступна
|
||||
/// → 400 <see cref="MoveSourceRestrictedDetail"/>: вывод из них — только restore_lead (иначе перенос минует
|
||||
/// снятие метки «спам» возврата из корзины, Ruling 4). Перенос «в ту же колонку» — no-op (карточка
|
||||
/// возвращается без изменений; журнал и обучение не пишутся). При реальном переносе: колонка меняется
|
||||
/// (is_new=FALSE, prev_col = прежняя колонка), matchHits пересчитываются для доски через
|
||||
/// <see cref="ColumnRules.ComputeHits"/> (Ruling 2; для inbox — пусто), пишется строка журнала action=move,
|
||||
/// а при to≠inbox — обучающий сигнал PushAsync(text, id доски, 1.0) (Ruling 4; текст = source_msg или title;
|
||||
/// пустой текст не учим — L188–191).
|
||||
/// </remarks>
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <param name="toCol">Цель: <c>inbox</c> либо id доски (<c>b_...</c>).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Результат: Error (400) | Card=null (карточки нет, 404) | Card — карточка после переноса.</returns>
|
||||
public async Task<CardResultDto> MoveDashboardCardAsync(string cardId, string toCol, CancellationToken ct)
|
||||
{
|
||||
ContainerDto? board = null;
|
||||
if (toCol != CardIds.Inbox)
|
||||
{
|
||||
board = await _store.GetContainerAsync(toCol, ct);
|
||||
if (board is null)
|
||||
{
|
||||
return new CardResultDto(MoveTargetInvalidDetail, null);
|
||||
}
|
||||
}
|
||||
|
||||
CardDto? card = await _store.GetCardAsync(cardId, ct);
|
||||
if (card is null)
|
||||
{
|
||||
return new CardResultDto(null, null);
|
||||
}
|
||||
|
||||
// Из archive/trash карточку выводит только restore (L204–222): прямой move в доску
|
||||
// прошёл бы мимо снятия у ML веса «спама» возврата из корзины (Ruling 4, L218–221).
|
||||
if (card.Col == CardIds.Archive || card.Col == CardIds.Trash)
|
||||
{
|
||||
return new CardResultDto(MoveSourceRestrictedDetail, null);
|
||||
}
|
||||
|
||||
if (card.Col == toCol)
|
||||
{
|
||||
return new CardResultDto(null, card);
|
||||
}
|
||||
|
||||
string text = LearningText(card);
|
||||
IReadOnlyList<MatchHitDto> hits = toCol == CardIds.Inbox
|
||||
? Array.Empty<MatchHitDto>()
|
||||
: await ComputeHitsAsync(board!.Rules, text, ct);
|
||||
|
||||
await MoveToColumnAsync(card, toCol, hits, ActionMove, ct);
|
||||
if (toCol != CardIds.Inbox && text.Length > 0)
|
||||
{
|
||||
await _mlClient.PushAsync(text, toCol, PushWeightUser, ct);
|
||||
}
|
||||
|
||||
return new CardResultDto(null, await _store.GetCardAsync(cardId, ct));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Перенос карточки в корзину (trash_lead L194–201): col=trash, is_new=FALSE, matchHits пусто.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Карточка уже в корзине — no-op (как в _move L165–166). Журнал action=trash пишется при реальном
|
||||
/// переносе; обучающий сигнал «спам» 1.0 — только если карточка была НЕ в trash/archive (L198–201,
|
||||
/// Ruling 4). Исключение archive: перенос архива в корзину не «переучивает» на спам.
|
||||
/// </remarks>
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Карточка после переноса (при no-op — как была) либо null — карточки нет (404).</returns>
|
||||
public Task<CardDto?> TrashCardAsync(string cardId, CancellationToken ct)
|
||||
{
|
||||
return TrashCardAsync(cardId, teach: true, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Перенос карточки в корзину с управлением обучением ML (trash_lead L194–201).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <paramref name="teach"/> = false — «тихое» перемещение без сигнала «спам»: используется ручной
|
||||
/// переклассификацией (leads.py reclassify_lead L311/L316), где обучение кладётся ЯВНО одним сигналом
|
||||
/// с весом гипотезы ИИ (0.4), а не весом действия пользователя (1.0). Журнал action=trash пишется всегда.
|
||||
/// </remarks>
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <param name="teach">True — писать сигнал «спам» (действие пользователя); false — не писать.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Карточка после переноса (при no-op — как была) либо null — карточки нет (404).</returns>
|
||||
public async Task<CardDto?> TrashCardAsync(string cardId, bool teach, CancellationToken ct)
|
||||
{
|
||||
CardDto? card = await _store.GetCardAsync(cardId, ct);
|
||||
if (card is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (card.Col == CardIds.Trash)
|
||||
{
|
||||
return card;
|
||||
}
|
||||
|
||||
string text = LearningText(card);
|
||||
await MoveToColumnAsync(card, CardIds.Trash, Array.Empty<MatchHitDto>(), ActionTrash, ct);
|
||||
if (teach && card.Col != CardIds.Archive && text.Length > 0)
|
||||
{
|
||||
await _mlClient.PushAsync(text, MlLearningLabels.Spam, PushWeightUser, ct);
|
||||
}
|
||||
|
||||
return await _store.GetCardAsync(cardId, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Возврат карточки из архива/корзины на канбан (restore_lead L204–222).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Куда возвращаем: prev_col, если это «Неразобранное» или существующая доска, иначе inbox (L209).
|
||||
/// При возврате is_new=TRUE, prev_col='inbox', archived_at=NULL (Ruling 10), matchHits пересчитаны для
|
||||
/// доски (Ruling 2), журнал action=restore. Возврат ИЗ корзины снимает метку спам:
|
||||
/// PushAsync(text, "spam", −1.0) (L218–221, Ruling 4); из архива сигнал не шлётся.
|
||||
/// </remarks>
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Колонка возврата (inbox/доска) либо null — карточки нет (404).</returns>
|
||||
public async Task<string?> RestoreCardAsync(string cardId, CancellationToken ct)
|
||||
{
|
||||
CardDto? card = await _store.GetCardAsync(cardId, ct);
|
||||
if (card is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string back = await ResolveReturnColAsync(card.PrevCol, ct);
|
||||
string text = LearningText(card);
|
||||
IReadOnlyList<MatchHitDto> hits = back == CardIds.Inbox
|
||||
? Array.Empty<MatchHitDto>()
|
||||
: await ComputeHitsForBoardAsync(back, text, ct);
|
||||
|
||||
await _store.UpdateColumnAsync(new CardColumnUpdateDto(
|
||||
CardId: cardId,
|
||||
Col: back,
|
||||
IsNew: true,
|
||||
PrevCol: CardIds.Inbox,
|
||||
ArchivedAt: null,
|
||||
MatchHits: hits), ct);
|
||||
await LogMoveAsync(cardId, ActionRestore, card.Col, back, ct);
|
||||
|
||||
if (card.Col == CardIds.Trash && text.Length > 0)
|
||||
{
|
||||
await _mlClient.PushAsync(text, MlLearningLabels.Spam, PushWeightUnlearn, ct);
|
||||
}
|
||||
|
||||
return back;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Полное удаление карточки (delete_forever L225–234): Cards + комментарии (FK cascade),
|
||||
/// журнал CardMoves/MlOutbox не трогаются (Ruling 10).
|
||||
/// </summary>
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>False — карточки нет (404 «Карточка не найдена»); True — удалена.</returns>
|
||||
public async Task<bool> DeleteForeverAsync(string cardId, CancellationToken ct)
|
||||
{
|
||||
if (await _store.GetCardAsync(cardId, ct) is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await _store.DeleteForeverAsync(cardId, ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Полная ручная очистка служебной колонки trash/archive (clear_col L237–247).
|
||||
/// </summary>
|
||||
/// <remarks>Другая колонка (inbox/доска/…) → 400 <see cref="ClearColInvalidDetail"/> (как ValueError L239–240).</remarks>
|
||||
/// <param name="col">Очищаемая колонка: <c>trash</c> | <c>archive</c>.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Результат: Error (400) либо Cleared — сколько карточек удалено навсегда.</returns>
|
||||
public async Task<ClearColResultDto> ClearColAsync(string col, CancellationToken ct)
|
||||
{
|
||||
if (col != CardIds.Trash && col != CardIds.Archive)
|
||||
{
|
||||
return new ClearColResultDto(ClearColInvalidDetail, 0);
|
||||
}
|
||||
|
||||
int cleared = await _store.ClearColAsync(col, ct);
|
||||
return new ClearColResultDto(null, cleared);
|
||||
}
|
||||
|
||||
// ── Комментарии (add_comment L259–265) ──────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Добавляет комментарий к карточке: строка LeadComments (id <c>cm_</c>) + журнал action=comment.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Текст Trim'ится (пустой после Trim → 400 <see cref="EmptyCommentDetail"/>, как dashboard_routes L240–241);
|
||||
/// автор — «Вы»; ответ — полный список комментариев (свежий — «только что», маппинг адаптера). Карточки
|
||||
/// нет → Comments=null, Error=null (404 «Карточка не найдена» — сервис читает карточку до записи, порт
|
||||
/// LeadComments ссылается FK, Task 4).
|
||||
/// </remarks>
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <param name="text">Текст комментария (непустой после Trim).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Результат: Error (400) | Comments=null (404) | Comments — список после добавления.</returns>
|
||||
public async Task<AddCommentResultDto> AddCommentAsync(string cardId, string text, CancellationToken ct)
|
||||
{
|
||||
string trimmed = (text ?? string.Empty).Trim();
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
return new AddCommentResultDto(EmptyCommentDetail, null);
|
||||
}
|
||||
|
||||
if (await _store.GetCardAsync(cardId, ct) is null)
|
||||
{
|
||||
return new AddCommentResultDto(null, null);
|
||||
}
|
||||
|
||||
await _store.AddCommentAsync(PrefixId.New(KanbanIdPrefixes.Comment), cardId, CommentAuthor, trimmed, ct);
|
||||
await LogMoveAsync(cardId, ActionComment, null, null, ct);
|
||||
IReadOnlyList<CardCommentDto> comments = await _store.ListCommentsAsync(cardId, ct);
|
||||
return new AddCommentResultDto(null, comments);
|
||||
}
|
||||
|
||||
// ── Пометить прочитанным (mark_seen L250–256) ───────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Снимает флаг «новое»: с одной карточки (cardId), колонки (col) или всех (оба null/пустые).
|
||||
/// </summary>
|
||||
/// <remarks>Семантика 1:1 с mark_seen L250–256 (проверка на truthiness: пустая строка = параметр не задан).
|
||||
/// Эндпоинты этапа: mark-col-seen {col}, mark-all-seen (Ruling 11); /leads/{id}/seen фронтом не вызывается.</remarks>
|
||||
/// <param name="cardId">Id карточки либо null/пусто.</param>
|
||||
/// <param name="col">Колонка либо null/пусто (используется, когда cardId не задан).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
public Task MarkSeenAsync(string? cardId, string? col, CancellationToken ct)
|
||||
{
|
||||
return _store.UpdateSeenAsync(
|
||||
string.IsNullOrEmpty(cardId) ? null : cardId,
|
||||
string.IsNullOrEmpty(col) ? null : col,
|
||||
ct);
|
||||
}
|
||||
|
||||
// ── Счётчики (counts L268–279) ──────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Счётчики колонок (count+new по Cards) + статистика обучения/решений ML (learning/ml/ai).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Форма CardCountsDto: Columns — только колонки с карточками; New — сумма «новых» по колонкам
|
||||
/// (counts L270–274). learning/ml/ai — из IMlClient.StatusAsync (L275–278, план Task 7 L321): learning =
|
||||
/// count(CardMoves), ml/ai — KV-счётчики решений пайплайна (на этапе 3 — 0, Ruling 4). Плоскую wire-форму
|
||||
/// «{new, <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,151 @@
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Modules.Settings.Application.Registrars;
|
||||
using Deal.Modules.Settings.Application.Services;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Extensions;
|
||||
using Deal.Modules.Kanban.Application.Registrars;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Напоминания «Отложено» — partial-часть <see cref="CardsService"/> (этап 9: тот же домен карточки):
|
||||
/// set/clear/snooze/check (projects.py L236–282), Ruling 3.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Семантика 1:1 с прототипом (Ruling 3): set проверяет выключатель (400-результат «Напоминания об отложенных
|
||||
/// выключены в настройках») и НЕ проверяет ни стадию карточки (фронт шлёт напоминание только для hold), ни
|
||||
/// время at (прошлое допустимо — «выстреливает» таким at на ближайшей проверке); clear и snooze выключатель НЕ
|
||||
/// проверяют; snooze = now + 24 ч. CheckDueRemindersAsync (check_reminders L264–282): выключено → только
|
||||
/// очистка протухших и пустой список; включено → due-строки hold-карточек помечаются fired и возвращаются
|
||||
/// списком {id,title,containerId} — SSE-события по ним публикует Api-слой, не сервис. Порядок проверок set —
|
||||
/// карточка раньше выключателя (404 раньше 400).
|
||||
/// </remarks>
|
||||
public sealed partial class CardsService
|
||||
{
|
||||
/// <summary>
|
||||
/// 400 set: напоминания выключены в настройках (set_reminder projects.py L237–238; Ruling 3).
|
||||
/// </summary>
|
||||
public const string RemindersDisabledDetail = "Напоминания об отложенных выключены в настройках";
|
||||
|
||||
// Ключ публичной настройки-выключателя напоминаний (Ruling 3).
|
||||
private const string RemindersEnabledKey = SettingsKeys.RemindersEnabled;
|
||||
|
||||
// Шаг «напомнить позже» (snooze): +24 часа в epoch-мс (snooze projects.py L257–261).
|
||||
private const long ReminderSnoozeMs = 86_400_000;
|
||||
|
||||
/// <summary>
|
||||
/// Устанавливает напоминание карточке (POST /api/cards/{cardId}/reminder; set_reminder L236–243).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Порядок 1:1 с прототипом: карточки нет → 404-результат ДО проверки выключателя; напоминания выключены →
|
||||
/// 400 <see cref="RemindersDisabledDetail"/>. Стадия карточки НЕ проверяется, at НЕ валидируется. Хранилище
|
||||
/// пишет reminder_at + reminder_fired=false + bump UpdatedAt; ответ — полная карточка после записи.
|
||||
/// </remarks>
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <param name="atMs">Время напоминания, epoch-ms.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Результат: Error <see cref="RemindersDisabledDetail"/> (400) | Card=null без Error (404) |
|
||||
/// Card — карточка с напоминанием.</returns>
|
||||
public async Task<CardResultDto> SetReminderAsync(string cardId, long atMs, CancellationToken ct)
|
||||
{
|
||||
CardDto? card = await _store.GetCardAsync(cardId, ct);
|
||||
if (card is null)
|
||||
{
|
||||
return new CardResultDto(null, null);
|
||||
}
|
||||
|
||||
if (!await ReadRemindersEnabledAsync(ct))
|
||||
{
|
||||
return new CardResultDto(RemindersDisabledDetail, null);
|
||||
}
|
||||
|
||||
await _store.SetReminderAsync(cardId, atMs, ct);
|
||||
CardDto saved = await _store.GetCardAsync(cardId, ct)
|
||||
?? throw new InvalidOperationException("Карточка не прочиталась после установки напоминания: " + cardId);
|
||||
return new CardResultDto(null, saved);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Снимает напоминание карточки (DELETE /api/cards/{cardId}/reminder; clear_reminder L246–247).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Выключатель НЕ проверяется. Карточки нет → false (404); напоминания у карточки нет — успех без изменений.
|
||||
/// Хранилище пишет reminder_at=NULL + reminder_fired=false, UpdatedAt НЕ бампит.
|
||||
/// </remarks>
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>True — карточка есть и напоминание снято; false — карточки нет (404).</returns>
|
||||
public async Task<bool> ClearReminderAsync(string cardId, CancellationToken ct)
|
||||
{
|
||||
CardDto? card = await _store.GetCardAsync(cardId, ct);
|
||||
if (card is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await _store.ClearReminderAsync(cardId, ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// «Напомнить позже»: перенос напоминания на now + 24 ч (POST /api/cards/{cardId}/reminder/snooze).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Выключатель НЕ проверяется (snooze зовётся из баннера reminder_due независимо от настройки). Карточки
|
||||
/// нет → false (404). Хранилище пишет reminder_at=now+24ч, reminder_fired=false, UpdatedAt бампит.
|
||||
/// </remarks>
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>True — карточка есть и напоминание отложено; false — карточки нет (404).</returns>
|
||||
public async Task<bool> SnoozeReminderAsync(string cardId, CancellationToken ct)
|
||||
{
|
||||
CardDto? card = await _store.GetCardAsync(cardId, ct);
|
||||
if (card is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
long snoozedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + ReminderSnoozeMs;
|
||||
await _store.SetReminderAsync(cardId, snoozedAtMs, ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Проверка наступивших напоминаний «Отложено» (check_reminders projects.py L264–282).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Выключено → ТОЛЬКО очистка протухших напоминаний (reminder_at ≤ now, без учёта stage/fired) и пустой
|
||||
/// результат; включено → строки stage='hold' AND reminder_at ≤ now AND reminder_fired=false помечаются
|
||||
/// fired и возвращаются списком {id,title,containerId}. Публикацию SSE reminder_due выполняет Api-слой.
|
||||
/// </remarks>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>«Выстрелившие» напоминания (после пометки fired); пусто — сработавших нет/напоминания выключены.</returns>
|
||||
public async Task<IReadOnlyList<CardReminderDueDto>> CheckDueRemindersAsync(CancellationToken ct)
|
||||
{
|
||||
if (!await ReadRemindersEnabledAsync(ct))
|
||||
{
|
||||
await _store.ClearExpiredRemindersAsync(DateTimeOffset.UtcNow, ct);
|
||||
return Array.Empty<CardReminderDueDto>();
|
||||
}
|
||||
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
IReadOnlyList<CardReminderDueDto> due = await _store.ListDueRemindersAsync(now, ct);
|
||||
if (due.Count > 0)
|
||||
{
|
||||
await _store.MarkRemindersFiredAsync(due.Select(item => item.Id).ToList(), ct);
|
||||
}
|
||||
|
||||
return due;
|
||||
}
|
||||
|
||||
// Читает выключатель напоминаний «remindersEnabled» (типизированный снимок настроек, Ruling 3).
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Значение настройки; отсутствие/повреждённый JSON → дефолт SettingsDefaults.RemindersEnabled.
|
||||
private async Task<bool> ReadRemindersEnabledAsync(CancellationToken ct)
|
||||
{
|
||||
return (await TenantSettingsSnapshot.LoadAsync(_settings, ct))
|
||||
.GetBool(RemindersEnabledKey, SettingsDefaults.RemindersEnabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Modules.Cards.Application.Abstractions;
|
||||
using Deal.Modules.Cards.Application.Dtos;
|
||||
using Deal.Modules.Cards.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Extensions;
|
||||
using Deal.Modules.Kanban.Application.Registrars;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Операции пространства «Выбранные» — partial-часть <see cref="CardsService"/> (этап 9: тот же домен
|
||||
/// карточки): ручное создание, патч полей тела, ссылки, перенос по контейнерам-стадиям с историей и сбросом
|
||||
/// напоминания, «взять в работу», очистка «Отклонено» (projects.py L103–231).
|
||||
/// </summary>
|
||||
public sealed partial class CardsService
|
||||
{
|
||||
/// <summary>
|
||||
/// 400 перенос по стадии: стадии нет в каталоге (move_stage projects.py L203–204).
|
||||
/// </summary>
|
||||
public const string UnknownStageDetail = "Неизвестная стадия";
|
||||
|
||||
// Стадия размещения новой карточки и fallback ручного создания (Rulings 5/6).
|
||||
private const string PlannedStage = CardsDefaultContainers.Planned;
|
||||
|
||||
// Очищаемая терминальная стадия (clear_stage projects.py L225).
|
||||
private const string RejectedStage = CardsDefaultContainers.Rejected;
|
||||
|
||||
// Текст комментария-«взял в работу» на карточке (take L149).
|
||||
private const string TakenCommentText = "Взял в работу.";
|
||||
|
||||
/// <summary>
|
||||
/// 400 ссылка: пустой url после Trim (projects_routes.py L137–138).
|
||||
/// </summary>
|
||||
public const string EmptyLinkDetail = "Пустая ссылка";
|
||||
|
||||
// Схема по умолчанию ссылки, присланной без схемы (add_link L139–140: «https://» + url).
|
||||
private const string HttpsUrlScheme = "https://";
|
||||
|
||||
// Схема http: ссылки с ней оставляются как есть (add_link L139 — проверка префикса http/https).
|
||||
private const string HttpUrlScheme = "http://";
|
||||
|
||||
// Ключ тела PATCH: заголовок (JSON-строка).
|
||||
private const string PatchKeyTitle = "title";
|
||||
|
||||
// Ключ тела PATCH: краткое содержание (JSON-строка).
|
||||
private const string PatchKeySummary = "summary";
|
||||
|
||||
// Ключ тела PATCH: контактная строка (JSON-строка).
|
||||
private const string PatchKeyContact = "contact";
|
||||
|
||||
// Ключ тела PATCH: текст технического задания (JSON-строка).
|
||||
private const string PatchKeyTzText = "tzText";
|
||||
|
||||
// Ключ тела PATCH: стек (JSON-массив строк либо null — очистка).
|
||||
private const string PatchKeyStack = "stack";
|
||||
|
||||
// Ключ тела PATCH: бюджет (JSON-объект {from,to,cur} либо null/не-объект — очистка).
|
||||
private const string PatchKeyBudget = "budget";
|
||||
|
||||
// Представление «бюджета нет» для патча: пустая Cur = очистка (patch_card L174–179).
|
||||
private static readonly CardBudgetDto ClearedBudget = new(From: null, To: null, Cur: string.Empty);
|
||||
|
||||
/// <summary>
|
||||
/// Карточки пространства «Выбранные»: список ORDER BY updated_at DESC с фильтром контейнера-стадии.
|
||||
/// </summary>
|
||||
/// <param name="containerId">Фильтр по контейнеру-стадии; null — все стадии «Выбранных».</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Полные карточки в порядке UpdatedAt DESC; пусто — карточек нет.</returns>
|
||||
public Task<IReadOnlyList<CardDto>> ListSelectedCardsAsync(string? containerId, CancellationToken ct)
|
||||
{
|
||||
return _store.ListSelectedCardsAsync(containerId, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ручное создание «локальной» карточки без внешнего источника (POST /api/cards; create_local_card L103–124).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// local=true («создано локально»); title — Trim(); контейнер — переданный, если есть в каталоге
|
||||
/// <see cref="CardsDefaultContainers"/>, иначе <c>planned</c>; история — одна запись type="createdLocal".
|
||||
/// Пустой заголовок допустим (фронт шлёт {title:''}, 1:1 прототип).
|
||||
/// </remarks>
|
||||
/// <param name="draft">Начальные поля карточки (тело POST /api/cards, см. <see cref="CardLocalCreateDto"/>).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Созданная карточка (полное чтение после записи).</returns>
|
||||
public async Task<CardDto> CreateLocalCardAsync(CardLocalCreateDto draft, CancellationToken ct)
|
||||
{
|
||||
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
string containerId = draft.ContainerId is not null && CardsDefaultContainers.Contains(draft.ContainerId)
|
||||
? draft.ContainerId
|
||||
: PlannedStage;
|
||||
var snapshot = new CardSnapshot
|
||||
{
|
||||
Id = PrefixId.New(KanbanIdPrefixes.Card),
|
||||
Col = containerId,
|
||||
IsNew = false,
|
||||
Local = true,
|
||||
Title = draft.Title.Trim(),
|
||||
Summary = draft.Summary,
|
||||
Stack = draft.Stack ?? Array.Empty<string>(),
|
||||
BudgetFrom = draft.Budget?.From,
|
||||
BudgetTo = draft.Budget?.To,
|
||||
BudgetCur = draft.Budget?.Cur ?? string.Empty,
|
||||
Contact = draft.Contact,
|
||||
TzText = draft.TzText,
|
||||
History = new[] { new CardHistoryDto(PrefixId.New(KanbanIdPrefixes.History), nowMs, "createdLocal", null) },
|
||||
};
|
||||
await _store.AddCardAsync(snapshot, ct);
|
||||
return await _store.GetCardAsync(snapshot.Id, ct)
|
||||
?? throw new InvalidOperationException("Карточка не прочиталась после создания: " + snapshot.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// «Взять в работу»: карточка переходит в контейнер-стадию planned, сохраняя свой id (POST
|
||||
/// /api/cards/take; Rulings 4/5 этапа 9 — никакого клонирования во вторую сущность).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Поток: (1) карточка читается (<see cref="ICardStore.GetCardAsync"/>) — null → null-результат (404);
|
||||
/// (2) если карточка уже в контейнере-стадии — возвращается без изменений (идемпотентность Ruling 5);
|
||||
/// (3) иначе карточка переносится в стадию planned той же строкой (<see cref="ICardStore.MoveCardStageAsync"/>:
|
||||
/// контейнер, запись истории, сброс напоминания), дописывается комментарий «Взял в работу.».
|
||||
/// </remarks>
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Карточка в стадии planned; null — карточки нет (404).</returns>
|
||||
public async Task<CardDto?> TakeCardAsync(string cardId, CancellationToken ct)
|
||||
{
|
||||
CardDto? card = await _store.GetCardAsync(cardId, ct);
|
||||
if (card is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (CardsDefaultContainers.Contains(card.Col))
|
||||
{
|
||||
// Карточка уже в пространстве «Выбранные» — повторный take идемпотентен (Ruling 5).
|
||||
return card;
|
||||
}
|
||||
|
||||
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
var entry = new CardHistoryDto(PrefixId.New(KanbanIdPrefixes.History), nowMs, null, PlannedStage);
|
||||
if (!await _store.MoveCardStageAsync(cardId, PlannedStage, entry, nowMs, ct))
|
||||
{
|
||||
// Карточка исчезла между чтением и переносом (гонка с удалением).
|
||||
return null;
|
||||
}
|
||||
|
||||
await _store.AddCommentAsync(
|
||||
PrefixId.New(KanbanIdPrefixes.Comment), cardId, CommentAuthor, TakenCommentText, ct);
|
||||
|
||||
return await _store.GetCardAsync(cardId, ct)
|
||||
?? throw new InvalidOperationException("Карточка не прочиталась после take: " + cardId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Точечная правка полей карточки по телу PATCH — presence-aware (PATCH /api/cards/{cardId};
|
||||
/// 1:1 с patch_card projects.py L159–187).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Тело — произвольный JSON-объект: учитывается ПРИСУТСТВИЕ ключа, а не только значение, поэтому явный
|
||||
/// null очищаемых полей не теряется типизированным биндингом. Патчатся ключи title/summary/contact/
|
||||
/// tzText (JSON-строка, пустая строка — очистка текста), stack (JSON-массив строк; null/не-массив →
|
||||
/// пустой стек) и budget (JSON-объект {from,to,cur}; null/не-объект → очистка бюджета). Неизвестные
|
||||
/// ключи игнорируются. Правки в историю НЕ пишутся (Ruling 7); хранилище бампает UpdatedAt.
|
||||
/// </remarks>
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <param name="body">Тело PATCH: ключ → JSON-значение (наличие ключа = поле меняется).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Обновлённая карточка или null — карточки нет (404).</returns>
|
||||
public async Task<CardDto?> PatchCardAsync(string cardId, IReadOnlyDictionary<string, JsonElement> body, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(body);
|
||||
|
||||
bool updated = await _store.PatchCardAsync(cardId, ResolvePatch(body), ct);
|
||||
return updated ? await _store.GetCardAsync(cardId, ct) : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Добавляет ссылку карточке: append в JSON-массив links (POST /api/cards/{cardId}/links; add_link L133–143).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Порядок 1:1 с прототипом: карточки нет → 404-результат ДО валидации url. url Trim'ится; пустой → 400
|
||||
/// <see cref="EmptyLinkDetail"/>. Без схемы http:// или https:// → префикс https://. Новая запись:
|
||||
/// {id <c>pl_</c>, name: name.Trim() или url — пустое имя → ссылка называется url, url}. Запись — атомарным
|
||||
/// jsonb-append хранилища.
|
||||
/// </remarks>
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <param name="name">Название ссылки; пустое после Trim → name = url.</param>
|
||||
/// <param name="url">URL ссылки (без схемы — добавится https://).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Результат: Error (400 «Пустая ссылка») | Card=null без Error (404) | Card — карточка со ссылкой.</returns>
|
||||
public async Task<CardResultDto> AddLinkAsync(string cardId, string name, string url, CancellationToken ct)
|
||||
{
|
||||
CardDto? card = await _store.GetCardAsync(cardId, ct);
|
||||
if (card is null)
|
||||
{
|
||||
return new CardResultDto(null, null);
|
||||
}
|
||||
|
||||
string normalizedUrl = (url ?? string.Empty).Trim();
|
||||
if (normalizedUrl.Length == 0)
|
||||
{
|
||||
return new CardResultDto(EmptyLinkDetail, null);
|
||||
}
|
||||
|
||||
if (!normalizedUrl.StartsWith(HttpsUrlScheme, StringComparison.Ordinal)
|
||||
&& !normalizedUrl.StartsWith(HttpUrlScheme, StringComparison.Ordinal))
|
||||
{
|
||||
normalizedUrl = HttpsUrlScheme + normalizedUrl;
|
||||
}
|
||||
|
||||
string trimmedName = (name ?? string.Empty).Trim();
|
||||
var link = new CardLinkDto(
|
||||
PrefixId.New(KanbanIdPrefixes.Link),
|
||||
trimmedName.Length == 0 ? normalizedUrl : trimmedName,
|
||||
normalizedUrl);
|
||||
if (!await _store.AddLinkAsync(cardId, link, ct))
|
||||
{
|
||||
return new CardResultDto(null, null);
|
||||
}
|
||||
|
||||
CardDto saved = await _store.GetCardAsync(cardId, ct)
|
||||
?? throw new InvalidOperationException("Карточка не прочиталась после добавления ссылки: " + cardId);
|
||||
return new CardResultDto(null, saved);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Удаляет ссылку карточки по id: фильтрация JSON-массива links (DELETE /api/cards/{cardId}/links/{linkId}).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Карточки нет → 404-результат. Ссылка с указанным id не найдена — список остаётся прежним, ошибки НЕТ.
|
||||
/// Запись — атомарной jsonb-фильтрацией хранилища.
|
||||
/// </remarks>
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <param name="linkId">Id удаляемой ссылки (<c>pl_...</c>).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Результат: Card=null без Error (404) | Card — карточка без ссылки.</returns>
|
||||
public async Task<CardResultDto> RemoveLinkAsync(string cardId, string linkId, CancellationToken ct)
|
||||
{
|
||||
if (!await _store.RemoveLinkAsync(cardId, linkId, ct))
|
||||
{
|
||||
return new CardResultDto(null, null);
|
||||
}
|
||||
|
||||
CardDto saved = await _store.GetCardAsync(cardId, ct)
|
||||
?? throw new InvalidOperationException("Карточка не прочиталась после удаления ссылки: " + cardId);
|
||||
return new CardResultDto(null, saved);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Перенос карточки по контейнерам-стадиям «Выбранных»: запись истории + сброс напоминания
|
||||
/// (1:1 move_stage projects.py L202–216, Rulings 3/7).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Контейнер валидируется каталогом <see cref="CardsDefaultContainers"/> (400 «Неизвестная стадия»).
|
||||
/// При успехе хранилище пишет одним UPDATE: col, reminder_at=NULL/reminder_fired=false, updated_at=время
|
||||
/// переноса, history + запись {id h_, at, stage:<новый>}.
|
||||
/// </remarks>
|
||||
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
|
||||
/// <param name="containerId">Новый контейнер-стадия — id каталога <see cref="CardsDefaultContainers"/>.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Результат: Error «Неизвестная стадия» (400) | Card=null без Error (карточки нет, 404) |
|
||||
/// Card — карточка после переноса.</returns>
|
||||
public async Task<CardResultDto> MoveStageCardAsync(string cardId, string containerId, CancellationToken ct)
|
||||
{
|
||||
if (!CardsDefaultContainers.Contains(containerId))
|
||||
{
|
||||
return new CardResultDto(UnknownStageDetail, null);
|
||||
}
|
||||
|
||||
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
var entry = new CardHistoryDto(PrefixId.New(KanbanIdPrefixes.History), nowMs, null, containerId);
|
||||
bool moved = await _store.MoveCardStageAsync(cardId, containerId, entry, nowMs, ct);
|
||||
if (!moved)
|
||||
{
|
||||
return new CardResultDto(null, null);
|
||||
}
|
||||
|
||||
CardDto card = await _store.GetCardAsync(cardId, ct)
|
||||
?? throw new InvalidOperationException("Карточка не прочиталась после move: " + cardId);
|
||||
return new CardResultDto(null, card);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Полная ручная очистка терминальной стадии «Отклонено» — hard-delete строк (POST /api/cards/clear-rejected).
|
||||
/// </summary>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Сколько карточек удалено (0 — стадия пуста).</returns>
|
||||
public Task<int> ClearRejectedAsync(CancellationToken ct)
|
||||
{
|
||||
return _store.ClearStageAsync(RejectedStage, ct);
|
||||
}
|
||||
|
||||
// Переводит тело PATCH в точечный патч хранилища: ключ присутствует → поле меняется;
|
||||
// неизвестные ключи отбрасываются (1:1 pydantic PatchBody + patch_card L159–187).
|
||||
// body: Тело PATCH (ключ → значение).
|
||||
// Возвращает: Патч со значениями присутствующих ключей; остальные поля null («не менять»).
|
||||
private static CardPatch ResolvePatch(IReadOnlyDictionary<string, JsonElement> body)
|
||||
{
|
||||
string? title = null;
|
||||
string? summary = null;
|
||||
string? contact = null;
|
||||
string? tzText = null;
|
||||
IReadOnlyList<string>? stack = null;
|
||||
CardBudgetDto? budget = null;
|
||||
foreach ((string key, JsonElement value) in body)
|
||||
{
|
||||
switch (key)
|
||||
{
|
||||
case PatchKeyTitle:
|
||||
if (ReadTextValue(value, out string parsedTitle))
|
||||
{
|
||||
title = parsedTitle;
|
||||
}
|
||||
|
||||
break;
|
||||
case PatchKeySummary:
|
||||
if (ReadTextValue(value, out string parsedSummary))
|
||||
{
|
||||
summary = parsedSummary;
|
||||
}
|
||||
|
||||
break;
|
||||
case PatchKeyContact:
|
||||
if (ReadTextValue(value, out string parsedContact))
|
||||
{
|
||||
contact = parsedContact;
|
||||
}
|
||||
|
||||
break;
|
||||
case PatchKeyTzText:
|
||||
if (ReadTextValue(value, out string parsedTzText))
|
||||
{
|
||||
tzText = parsedTzText;
|
||||
}
|
||||
|
||||
break;
|
||||
case PatchKeyStack:
|
||||
stack = ReadStack(value);
|
||||
break;
|
||||
case PatchKeyBudget:
|
||||
budget = ReadBudget(value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new CardPatch(title, summary, contact, tzText, stack, budget, null, null, null);
|
||||
}
|
||||
|
||||
// Читает JSON-строку текстового поля; null/не-строка → ключ не применяется.
|
||||
// element: Значение ключа тела.
|
||||
// text: Прочитанная строка (при успехе).
|
||||
// Возвращает: True — значение строковое (в т.ч. пустое — очистка текста).
|
||||
private static bool ReadTextValue(JsonElement element, out string text)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
text = element.GetString() ?? string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
text = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Читает стек: массив строк (не-строки отбрасываются); null/не-массив → пустой стек.
|
||||
// element: Значение ключа stack.
|
||||
// Возвращает: Новый стек (полная замена массива).
|
||||
private static IReadOnlyList<string> ReadStack(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var items = new List<string>();
|
||||
foreach (JsonElement item in element.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
items.Add(item.GetString() ?? string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
// Читает бюджет: объект {from,to,cur} → пара границ + валюта; null/не-объект → очистка.
|
||||
// element: Значение ключа budget.
|
||||
// Возвращает: Бюджет патча: валюта пуста — «бюджета нет» (очистка).
|
||||
private static CardBudgetDto ReadBudget(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return ClearedBudget;
|
||||
}
|
||||
|
||||
double? from = ReadNumber(element, "from");
|
||||
double? to = ReadNumber(element, "to");
|
||||
string cur = ReadText(element, "cur");
|
||||
return new CardBudgetDto(from, to, cur);
|
||||
}
|
||||
|
||||
// Читает число поля объекта бюджета: число → значение; иное — пусто.
|
||||
// obj: Объект бюджета.
|
||||
// fieldName: Имя поля (from/to).
|
||||
// Возвращает: Граница либо null.
|
||||
private static double? ReadNumber(JsonElement obj, string fieldName)
|
||||
{
|
||||
return obj.TryGetProperty(fieldName, out JsonElement element) && element.ValueKind == JsonValueKind.Number
|
||||
? element.GetDouble()
|
||||
: null;
|
||||
}
|
||||
|
||||
// Читает строку поля объекта бюджета: строка → значение; отсутствие/иное → пусто.
|
||||
// obj: Объект бюджета.
|
||||
// fieldName: Имя поля (cur).
|
||||
// Возвращает: Код валюты либо пустая строка.
|
||||
private static string ReadText(JsonElement obj, string fieldName)
|
||||
{
|
||||
return obj.TryGetProperty(fieldName, out JsonElement element) && element.ValueKind == JsonValueKind.String
|
||||
? element.GetString() ?? string.Empty
|
||||
: string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using Deal.Contracts.Integrations;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Kanban.Application.ColumnRules;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Modules.Settings.Application.Registrars;
|
||||
using Deal.Modules.Settings.Application.Services;
|
||||
// Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён — внутри пространства имён
|
||||
// Deal.Modules.Kanban.Application имя ColumnRules резолвится в пространство (CS0234), нужен явный алиас.
|
||||
using KanbanColumnRules = Deal.Modules.Kanban.Application.ColumnRules.ColumnRules;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Extensions;
|
||||
using Deal.Modules.Kanban.Application.Registrars;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Сервис карточек — единый домен карточки (дашборд и «Выбранные»): leads.py L151–279 + L509–551
|
||||
/// и projects.py L103–282 (этап 9).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Чистый сервис модуля (без EF/HTTP): оркестрирует <see cref="ICardStore"/> (карточки/контейнеры/
|
||||
/// комментарии/журнал), <see cref="ISettingsStore"/> (кэш курсов <c>ratesCache</c>, настройка напоминаний,
|
||||
/// Ruling 3/7), <see cref="IMlClient"/> (обучающие сигналы move/trash/restore и счётчики counts) и
|
||||
/// <see cref="IFileStorage"/> (объекты вложений карточки).
|
||||
/// Полные карточки (JSON-поля, comments, human-метка time, matchHits) собирает адаптер (маппинг
|
||||
/// строки → CardDto в хранилище); сервис добавляет поведение: валидации с фиксированными текстами прототипа,
|
||||
/// пересчёт matchHits при размещении в доску (Ruling 2, ColumnRules), журнал CardMoves и push-сигналы ML,
|
||||
/// операции пространства «Выбранные» (создание, патч, ссылки, файлы, перенос по стадии с историей,
|
||||
/// напоминания) на той же сущности карточки.
|
||||
/// Обучение ML идёт ВСЕГДА и синхронно — выключатель <c>mlEnabled</c> управляет только использованием ML в
|
||||
/// пайплайне, а не записью действий пользователя (ml_client.py L6–7: «обучение идёт всегда»; Ruling 4).
|
||||
/// 404-семантика «Карточка не найдена» выражается null-результатом методов; тексты 400 — константы класса.
|
||||
/// C32: класс разделён на partial-файлы по темам (Operations/Helpers/Selected/Files/Reminders).
|
||||
/// </remarks>
|
||||
/// <param name="store">Единый порт хранилища карточек/контейнеров/журнала тенанта.</param>
|
||||
/// <param name="settings">KV-хранилище настроек тенанта (курсы, напоминания).</param>
|
||||
/// <param name="mlClient">Клиент ML: PushAsync — обучающий сигнал действия, StatusAsync — счётчики counts.</param>
|
||||
/// <param name="storage">Файловое хранилище вложений карточки (объекты файлов).</param>
|
||||
public sealed partial class CardsService
|
||||
{
|
||||
private readonly ICardStore _store;
|
||||
private readonly ISettingsStore _settings;
|
||||
private readonly IMlClient _mlClient;
|
||||
private readonly IFileStorage _storage;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт сервис карточек над портами модуля (поле-захват DI-зависимостей).
|
||||
/// </summary>
|
||||
/// <param name="store">Единый порт хранилища карточек/контейнеров/журнала тенанта.</param>
|
||||
/// <param name="settings">KV-хранилище настроек тенанта (курсы, напоминания).</param>
|
||||
/// <param name="mlClient">Клиент ML: PushAsync — обучающий сигнал действия, StatusAsync — счётчики counts.</param>
|
||||
/// <param name="storage">Файловое хранилище вложений карточки (объекты файлов).</param>
|
||||
public CardsService(ICardStore store, ISettingsStore settings, IMlClient mlClient, IFileStorage storage)
|
||||
{
|
||||
_store = store;
|
||||
_settings = settings;
|
||||
_mlClient = mlClient;
|
||||
_storage = storage;
|
||||
}
|
||||
|
||||
// ── Фиксированные строки прототипа (400-детали; leads.py L183–184, L239–240, dashboard_routes L241) ──
|
||||
|
||||
/// <summary>
|
||||
/// 400 move: целевой контейнер не существует (или это «Неразобранное» — оно допустимо).
|
||||
/// </summary>
|
||||
public const string MoveTargetInvalidDetail = "Переносить можно только в существующий контейнер или в «Неразобранное»";
|
||||
|
||||
/// <summary>
|
||||
/// 400 move: исходная колонка archive/trash — из них карточку выводит только restore_lead
|
||||
/// (кнопка «Вернуть»): прямой перенос в доску миновал бы снятие метки «спам» при возврате из корзины
|
||||
/// (Ruling 4, PushAsync(spam, −1.0) только в RestoreLeadAsync) и журнал restore.
|
||||
/// </summary>
|
||||
public const string MoveSourceRestrictedDetail = "Переносить из корзины, архива или «взятых в работу» нельзя — верните карточку на канбан";
|
||||
|
||||
/// <summary>
|
||||
/// 400 clear-col: колонка не trash/archive (clear_col L239–240).
|
||||
/// </summary>
|
||||
public const string ClearColInvalidDetail = "Очищать можно только корзину или архив";
|
||||
|
||||
/// <summary>
|
||||
/// 400 комментарий: пустой текст после Trim (dashboard_routes L240–241).
|
||||
/// </summary>
|
||||
public const string EmptyCommentDetail = "Пустой комментарий";
|
||||
|
||||
// ── Журнал CardMoves: действия (leads.py _log_learning L40–44) ──────────
|
||||
|
||||
// Действие журнала: перенос на доску/в «Неразобранное» (_move L174).
|
||||
private const string ActionMove = "move";
|
||||
|
||||
// Действие журнала: в корзину (_move action='trash' L196).
|
||||
private const string ActionTrash = "trash";
|
||||
|
||||
// Действие журнала: возврат из архива/корзины (restore_lead L216).
|
||||
private const string ActionRestore = "restore";
|
||||
|
||||
// Действие журнала: добавлен комментарий (add_comment L264).
|
||||
private const string ActionComment = "comment";
|
||||
|
||||
/// <summary>
|
||||
/// Автор комментария — «Вы» (свои комментарии, add_comment L262). Единственный источник строки
|
||||
/// для комментариев карточки.
|
||||
/// </summary>
|
||||
public const string CommentAuthor = "Вы";
|
||||
|
||||
/// <summary>
|
||||
/// Human-метка времени свежего комментария (add_comment L259–265; «только что» = возраст < 1 мин).
|
||||
/// Единственный источник строки: адаптер KanbanStore считает её в HumanAge.
|
||||
/// </summary>
|
||||
public const string JustNowLabel = "только что";
|
||||
|
||||
// Минимальная длина поискового запроса после Trim: q короче → пустой ответ (search L511–512, Ruling 6).
|
||||
private const int MinSearchQueryLength = 2;
|
||||
|
||||
// Ограничение результатов поиска: не больше 12 карточек (search L509, Ruling 6).
|
||||
private const int SearchLimit = 12;
|
||||
|
||||
// Вес сигнала пользователя: действие = истина (ml_client.py USER_WEIGHT L26, Ruling 4).
|
||||
private const double PushWeightUser = 1.0;
|
||||
|
||||
// Вес снятия метки: возврат из корзины (restore_lead L221, delta=-1.0).
|
||||
private const double PushWeightUnlearn = -1.0;
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Modules.Settings.Application.Registrars;
|
||||
using Deal.Modules.Settings.Application.Services;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Extensions;
|
||||
using Deal.Modules.Kanban.Application.Registrars;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Сервис контейнеров (колонок/стадий/зон) и состояния колонок (colState) — этап 9, T4.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Приходит на смену BoardsService: Containers — единственный реестр колонок; сервис выполняет CRUD
|
||||
/// (список по пространству, создание, патч, удаление с переносом карточек в inbox, reorder, приём
|
||||
/// ИИ-предложений) и держит colState (KV-ключ <see cref="SettingsKeys.ColState"/>). Счётчики карточек
|
||||
/// контейнера заполняются при чтении из реестра Cards. Чистый сервис модуля (без EF/HTTP).
|
||||
/// </remarks>
|
||||
/// <param name="store">Порт хранилища (контейнеры/карточки тенанта).</param>
|
||||
/// <param name="settings">KV-хранилище настроек тенанта (ключ colState).</param>
|
||||
public sealed class ContainersService(ICardStore store, ISettingsStore settings)
|
||||
{
|
||||
/// <summary>
|
||||
/// Имя по умолчанию: пустое имя → «Новая колонка».
|
||||
/// </summary>
|
||||
public const string DefaultContainerName = "Новая колонка";
|
||||
|
||||
// Палитра колонок по умолчанию: цвет = Palette[order % 8], если цвет не задан.
|
||||
private static readonly string[] Palette =
|
||||
["#818cf8", "#fbbf24", "#22d3ee", "#e879f9", "#34d399", "#fb7185", "#a78bfa", "#f97316"];
|
||||
|
||||
// Опции JSON colState: camelCase при записи, терпимость регистра при чтении; null-поля не пишутся.
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
// ── Контейнеры ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Контейнеры пространства в порядке показа, со счётчиками карточек (этап 9, T4).
|
||||
/// </summary>
|
||||
/// <param name="space">Пространство (dashboard/selected) либо null — все контейнеры.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Контейнеры с заполненными counts; пусто — контейнеров нет.</returns>
|
||||
public async Task<IReadOnlyList<ContainerDto>> ListAsync(string? space, CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<ContainerDto> containers = await store.ListContainersAsync(space, ct);
|
||||
IReadOnlyDictionary<string, CardColumnCountDto> counts = await store.CountCardsByColAsync(ct);
|
||||
return containers
|
||||
.Select(container => container with
|
||||
{
|
||||
Counts = counts.TryGetValue(container.Id, out CardColumnCountDto? count)
|
||||
? new ContainerCountsDto(count.Count, count.New)
|
||||
: new ContainerCountsDto(0, 0),
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Один контейнер со счётчиками; null — контейнера нет.
|
||||
/// </summary>
|
||||
/// <param name="containerId">Id контейнера.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Контейнер со счётчиками либо null.</returns>
|
||||
public async Task<ContainerDto?> GetAsync(string containerId, CancellationToken ct)
|
||||
{
|
||||
ContainerDto? container = await store.GetContainerAsync(containerId, ct);
|
||||
if (container is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
IReadOnlyDictionary<string, CardColumnCountDto> counts = await store.CountCardsByColAsync(ct);
|
||||
ContainerCountsDto containerCounts = counts.TryGetValue(container.Id, out CardColumnCountDto? count)
|
||||
? new ContainerCountsDto(count.Count, count.New)
|
||||
: new ContainerCountsDto(0, 0);
|
||||
return container with { Counts = containerCounts };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт контейнер с дефолтами: order = MAX+1, цвет палитры, имя «Новая колонка» при пустом.
|
||||
/// </summary>
|
||||
/// <param name="create">Вход создания (имя обязательно; цвет/правила/пространство/вид — опциональны).</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Созданный контейнер (id <c>b_</c> + 12 hex).</returns>
|
||||
public async Task<ContainerDto> CreateAsync(ContainerCreateDto create, CancellationToken ct)
|
||||
{
|
||||
string space = string.IsNullOrWhiteSpace(create.Space) ? ContainerSpaces.Dashboard : create.Space;
|
||||
IReadOnlyList<ContainerDto> existing = await store.ListContainersAsync(space, ct);
|
||||
int order = existing.Count == 0 ? 0 : existing.Max(container => container.Order) + 1;
|
||||
|
||||
var container = new ContainerDto
|
||||
{
|
||||
Id = PrefixId.New(KanbanIdPrefixes.Board),
|
||||
Name = BuildName(create.Name),
|
||||
Description = create.Description.Trim(),
|
||||
Color = string.IsNullOrEmpty(create.Color) ? Palette[order % Palette.Length] : create.Color,
|
||||
Order = order,
|
||||
Space = space,
|
||||
Kind = string.IsNullOrWhiteSpace(create.Kind) ? ContainerKinds.Board : create.Kind,
|
||||
Suggested = create.Suggested,
|
||||
Note = create.Note ?? string.Empty,
|
||||
Rules = NormalizeRules(create.Rules),
|
||||
Policy = new ContainerPolicyDto(),
|
||||
};
|
||||
|
||||
await store.CreateContainerAsync(container, ct);
|
||||
return container;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Частичное обновление контейнера: меняются только не-null поля патча; rules/policy заменяются целиком.
|
||||
/// </summary>
|
||||
/// <param name="containerId">Id контейнера.</param>
|
||||
/// <param name="patch">Изменения; null-поле означает «не менять».</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Контейнер после патча; null — контейнера нет (404 «Контейнер не найден»).</returns>
|
||||
public async Task<ContainerDto?> PatchAsync(string containerId, ContainerPatchDto patch, CancellationToken ct)
|
||||
{
|
||||
ContainerDto? current = await store.GetContainerAsync(containerId, ct);
|
||||
if (current is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
ContainerDto updated = ApplyPatch(current, patch);
|
||||
await store.UpdateContainerAsync(updated, ct);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Принимает ИИ-предложение: снимает флаг suggested у контейнера.
|
||||
/// </summary>
|
||||
/// <param name="containerId">Id контейнера-предложения.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Контейнер после принятия; null — контейнера нет (404).</returns>
|
||||
public Task<ContainerDto?> AcceptSuggestedAsync(string containerId, CancellationToken ct)
|
||||
{
|
||||
return PatchAsync(containerId, new ContainerPatchDto(
|
||||
Name: null,
|
||||
Description: null,
|
||||
Color: null,
|
||||
Collapsed: null,
|
||||
Suggested: false,
|
||||
Note: null,
|
||||
Rules: null,
|
||||
Policy: null), ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Удаляет контейнер, перенося его карточки в «Неразобранное» новыми.
|
||||
/// </summary>
|
||||
/// <param name="containerId">Id удаляемого контейнера.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Сколько карточек перенесено в inbox (ответ {ok, movedToInbox}).</returns>
|
||||
public Task<int> DeleteAsync(string containerId, CancellationToken ct)
|
||||
{
|
||||
return store.DeleteContainerAsync(containerId, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Переставляет контейнеры пространства: позиции 0..N-1 в порядке списка.
|
||||
/// </summary>
|
||||
/// <param name="space">Пространство переставляемых контейнеров.</param>
|
||||
/// <param name="containerIds">Id контейнеров в новом порядке.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
public Task ReorderAsync(string space, IReadOnlyList<string> containerIds, CancellationToken ct)
|
||||
{
|
||||
return store.ReorderContainersAsync(space, containerIds, ct);
|
||||
}
|
||||
|
||||
// ── Состояние колонок (colState, KV через ISettingsStore) ────────────
|
||||
|
||||
/// <summary>
|
||||
/// Весь объект colState: словарь «контейнер → состояние».
|
||||
/// </summary>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Состояния всех колонок; пусто — настройка не сохранена/повреждена.</returns>
|
||||
public async Task<IReadOnlyDictionary<string, ColumnStateDto>> GetColStateAsync(CancellationToken ct)
|
||||
{
|
||||
return await ReadColStateAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PATCH состояния одной колонки: merge патча в текущее значение и запись всего объекта.
|
||||
/// </summary>
|
||||
/// <param name="colId">Id контейнера.</param>
|
||||
/// <param name="patch">Изменяемые поля состояния.</param>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Состояние колонки после merge.</returns>
|
||||
public async Task<ColumnStateDto> PatchColStateAsync(string colId, ColumnStateDto patch, CancellationToken ct)
|
||||
{
|
||||
Dictionary<string, ColumnStateDto> state = await ReadColStateAsync(ct);
|
||||
state.TryGetValue(colId, out ColumnStateDto? current);
|
||||
|
||||
var merged = new ColumnStateDto(
|
||||
patch.Collapsed ?? current?.Collapsed,
|
||||
patch.Width ?? current?.Width);
|
||||
state[colId] = merged;
|
||||
|
||||
await settings.SetAsync(SettingsKeys.ColState, JsonSerializer.Serialize(state, JsonOptions), ct);
|
||||
return merged;
|
||||
}
|
||||
|
||||
// ── Внутреннее ───────────────────────────────────────────────────────
|
||||
|
||||
// Имя контейнера: trim; пустое → «Новая колонка».
|
||||
// name: Имя из запроса.
|
||||
// Возвращает: Непустое имя контейнера.
|
||||
private static string BuildName(string name)
|
||||
{
|
||||
string trimmed = name.Trim();
|
||||
return trimmed.Length == 0 ? DefaultContainerName : trimmed;
|
||||
}
|
||||
|
||||
// Правила: пустые правила (все группы пусты) нормализуются в null — хранилище пишет каноничное {}.
|
||||
// rules: Правила из запроса.
|
||||
// Возвращает: Нормализованные правила либо null.
|
||||
private static ContainerRulesDto? NormalizeRules(ContainerRulesDto? rules)
|
||||
{
|
||||
if (rules is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
bool isEmpty = rules.Mode.Length == 0
|
||||
&& rules.Direction.Count == 0
|
||||
&& rules.Keywords.Count == 0
|
||||
&& rules.Stack.Count == 0
|
||||
&& rules.Grade.Count == 0
|
||||
&& rules.Exclude.Count == 0
|
||||
&& (rules.Levels?.Count ?? 0) == 0
|
||||
&& (rules.Locations?.Count ?? 0) == 0
|
||||
&& (rules.Types?.Count ?? 0) == 0
|
||||
&& rules.Prices is null
|
||||
&& rules.Budget is null;
|
||||
return isEmpty ? null : rules;
|
||||
}
|
||||
|
||||
// Применяет не-null поля патча к текущему контейнеру.
|
||||
// container: Текущий контейнер.
|
||||
// patch: Патч.
|
||||
// Возвращает: Контейнер с применёнными изменениями.
|
||||
private static ContainerDto ApplyPatch(ContainerDto container, ContainerPatchDto patch)
|
||||
{
|
||||
return container with
|
||||
{
|
||||
Name = patch.Name ?? container.Name,
|
||||
Description = patch.Description ?? container.Description,
|
||||
Color = patch.Color ?? container.Color,
|
||||
Collapsed = patch.Collapsed ?? container.Collapsed,
|
||||
Suggested = patch.Suggested ?? container.Suggested,
|
||||
Note = patch.Note ?? container.Note,
|
||||
Rules = patch.Rules is null ? container.Rules : NormalizeRules(patch.Rules),
|
||||
Policy = patch.Policy ?? container.Policy,
|
||||
};
|
||||
}
|
||||
|
||||
// Читает весь объект colState из KV; повреждённый JSON трактуется как пустое состояние.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Словарь контейнер → состояние (Ordinal-ключи).
|
||||
private async Task<Dictionary<string, ColumnStateDto>> ReadColStateAsync(CancellationToken ct)
|
||||
{
|
||||
SettingValue? row = await settings.GetAsync(SettingsKeys.ColState, ct);
|
||||
if (row is null)
|
||||
{
|
||||
return new Dictionary<string, ColumnStateDto>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Dictionary<string, ColumnStateDto>? state =
|
||||
JsonSerializer.Deserialize<Dictionary<string, ColumnStateDto>>(row.ValueJson, JsonOptions);
|
||||
return state ?? new Dictionary<string, ColumnStateDto>(StringComparer.Ordinal);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Битое значение не должно ронять GET /state и последующие PATCH (перезапишут при записи).
|
||||
return new Dictionary<string, ColumnStateDto>(StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Modules.Settings.Application.Registrars;
|
||||
using Deal.Modules.Settings.Application.Services;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Extensions;
|
||||
using Deal.Modules.Kanban.Application.Registrars;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Пересчёт конверсий бюджетов карточек при смене курсов/целевой валюты (Ruling 7, план Task 12).
|
||||
/// Реализация порта модуля Settings <see cref="IRatesChangedListener"/> (регистрация — AddKanbanModule).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Чистый сервис модуля (без EF/HTTP), повторяет <c>rates.py recompute_conversions</c> (L106–130). Полный
|
||||
/// пересчёт: кандидаты <see cref="ICardStore.ListCardsForConversionAsync"/> (budget_cur != '' и колонка не
|
||||
/// archive/trash — фильтрует хранилище), курсы — из кэша ratesCache
|
||||
/// (<see cref="SettingsKeys.RatesCache"/> через типизированный снимок настроек TenantSettingsSnapshot, C30;
|
||||
/// конвертация — <see cref="RatesService.ConvertAmount"/>, USDT=USD), целевая валюта — настройка
|
||||
/// targetCurrency. conversionOn=false → 0 без изменений (rates.py L112–113). Карточка, нижняя граница которой
|
||||
/// не конвертируется (нет курса валюты либо budget_from не задан), пропускается ЦЕЛИКОМ — conv-поля не
|
||||
/// трогаются (rates.py L123–124: cf is None → continue). Нет кэша курсов → пересчёт не выполняется (мягкая
|
||||
/// семантика, дефолт-мок НЕ подставляется — по прототипу). Повторный вызов идемпотентен: пересчитывает по
|
||||
/// текущим настройкам/курсам всё заново.
|
||||
/// </remarks>
|
||||
/// <param name="settings">KV-хранилище настроек тенанта (conversionOn/targetCurrency/ratesCache).</param>
|
||||
/// <param name="store">Порт хранилища канбана: кандидаты пересчёта и запись conv-полей.</param>
|
||||
public sealed class ConversionRecomputer(ISettingsStore settings, ICardStore store) : IRatesChangedListener
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task OnRatesChangedAsync(bool fullRecompute, CancellationToken ct)
|
||||
{
|
||||
// Оба текущих триггера (обновление кэша курсов, PATCH targetCurrency/conversionOn) требуют полного
|
||||
// пересчёта всех кандидатов; параметр fullRecompute зарезервирован под будущие частичные события
|
||||
// (пересчёт одной карточки) — см. IRatesChangedListener.
|
||||
return RecomputeAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Полный пересчёт конверсий кандидатов по текущим настройкам и кэшу курсов.
|
||||
/// </summary>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Сколько карточек обновлено (0 — конверсия выключена / нет кэша курсов / нет кандидатов).</returns>
|
||||
public async Task<int> RecomputeAsync(CancellationToken ct)
|
||||
{
|
||||
// Все настройки/кэш — из одного типизированного снимка (C30): один GetAllAsync на пересчёт.
|
||||
TenantSettingsSnapshot snapshot = await TenantSettingsSnapshot.LoadAsync(settings, ct);
|
||||
bool conversionOn = snapshot.GetBool(SettingsKeys.ConversionOn, SettingsDefaults.ConversionOn);
|
||||
if (!conversionOn)
|
||||
{
|
||||
return 0; // конверсия снята — ничего не пересчитываем (rates.py L112–113)
|
||||
}
|
||||
|
||||
string targetCurrency = NormalizeTargetCurrency(
|
||||
snapshot.GetString(SettingsKeys.TargetCurrency, SettingsDefaults.TargetCurrency));
|
||||
RatesCacheValue? cache = snapshot.TryGetRatesCache();
|
||||
if (cache is not { Rates.Count: > 0 })
|
||||
{
|
||||
return 0; // кэша курсов нет — конвертировать нечем; карточки не трогаем
|
||||
}
|
||||
|
||||
int updated = 0;
|
||||
IReadOnlyList<CardDto> candidates = await store.ListCardsForConversionAsync(ct);
|
||||
foreach (CardDto card in candidates)
|
||||
{
|
||||
CardBudgetDto? budget = card.Budget;
|
||||
if (budget is null)
|
||||
{
|
||||
continue; // хранилище уже фильтрует budget_cur != '' (null Budget), страховка DTO
|
||||
}
|
||||
|
||||
// Нижняя граница; верхняя — как есть, при отсутствии — равна нижней (одна сумма/«от X», L121–122).
|
||||
double? convFrom = RatesService.ConvertAmount(budget.From, budget.Cur, targetCurrency, cache.Rates);
|
||||
if (convFrom is null)
|
||||
{
|
||||
continue; // нет курса валюты / нет нижней границы — строку не трогаем (rates.py L123–124)
|
||||
}
|
||||
|
||||
double? convTo =
|
||||
RatesService.ConvertAmount(budget.To ?? budget.From, budget.Cur, targetCurrency, cache.Rates);
|
||||
await store.UpdateConversionAsync(card.Id, convFrom, convTo, targetCurrency, ct);
|
||||
updated++;
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
// Код целевой валюты для пересчёта: trim + верхний регистр; пусто → дефолт RUB (как писал PATCH).
|
||||
// value: Значение настройки targetCurrency (JSON-строка).
|
||||
// Возвращает: Код валюты (RUB/USD/…) либо дефолт.
|
||||
private static string NormalizeTargetCurrency(string value)
|
||||
{
|
||||
string currency = value.Trim().ToUpperInvariant();
|
||||
return currency.Length > 0 ? currency : SettingsDefaults.TargetCurrency;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Extensions;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Registrars;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Чистый детектор типа вложения карточки (Ruling 4; 1:1 files.py detect L31–45).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Правила 1:1 с прототипом (files.py L13–28, L31–45): MIME-префикс <c>image/|video/|audio/</c> →
|
||||
/// соответствующий kind; иначе — расширение имени файла по наборам KIND_BY_EXT (archive/document);
|
||||
/// неопознанное → <c>other</c> («Файл»). kind/label — wire-значения CardFileDto и иконок
|
||||
/// фронта: image/«Изображение», video/«Видео», audio/«Аудио», archive/«Архив», document/«Документ»,
|
||||
/// other/«Файл». MIME сравнивается без учёта регистра (HTTP content-type регистронезависим), расширение
|
||||
/// приводится к нижнему регистру (как <c>.lower()</c> в python). Детектор чистый и детерминированный:
|
||||
/// «магия» (содержимое файла) не читается — тип даёт браузерный content-type и имя файла.
|
||||
/// Потребитель — CardsService при добавлении файла.
|
||||
/// </remarks>
|
||||
public static class FileKindDetector
|
||||
{
|
||||
/// <summary>
|
||||
/// Категория «изображение» (files.py KIND_BY_EXT: png/jpg/jpeg/gif/webp/svg/bmp/avif/heic).
|
||||
/// </summary>
|
||||
public const string ImageKind = "image";
|
||||
|
||||
/// <summary>
|
||||
/// Категория «видео» (mp4/mov/avi/mkv/webm/m4v).
|
||||
/// </summary>
|
||||
public const string VideoKind = "video";
|
||||
|
||||
/// <summary>
|
||||
/// Категория «аудио» (mp3/wav/ogg/m4a/flac/aac).
|
||||
/// </summary>
|
||||
public const string AudioKind = "audio";
|
||||
|
||||
/// <summary>
|
||||
/// Категория «архив» (zip/rar/7z/tar/gz/bz2).
|
||||
/// </summary>
|
||||
public const string ArchiveKind = "archive";
|
||||
|
||||
/// <summary>
|
||||
/// Категория «документ» (pdf/doc/docx/xls/xlsx/csv/txt/md/rtf/ppt/pptx/odt/ods).
|
||||
/// </summary>
|
||||
public const string DocumentKind = "document";
|
||||
|
||||
/// <summary>
|
||||
/// Категория «другое» — неопознанный MIME и расширение («Файл»).
|
||||
/// </summary>
|
||||
public const string OtherKind = "other";
|
||||
|
||||
private const string ImageLabel = "Изображение";
|
||||
private const string VideoLabel = "Видео";
|
||||
private const string AudioLabel = "Аудио";
|
||||
private const string ArchiveLabel = "Архив";
|
||||
private const string DocumentLabel = "Документ";
|
||||
private const string OtherLabel = "Файл";
|
||||
|
||||
// Расширения по категориям (1:1 files.py KIND_BY_EXT L13–19); наборы не пересекаются, порядок обхода не важен.
|
||||
private static readonly IReadOnlyDictionary<string, IReadOnlySet<string>> ExtensionsByKind = new Dictionary<string, IReadOnlySet<string>>(StringComparer.Ordinal)
|
||||
{
|
||||
[ImageKind] = new HashSet<string>(StringComparer.Ordinal) { "png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "avif", "heic" },
|
||||
[VideoKind] = new HashSet<string>(StringComparer.Ordinal) { "mp4", "mov", "avi", "mkv", "webm", "m4v" },
|
||||
[AudioKind] = new HashSet<string>(StringComparer.Ordinal) { "mp3", "wav", "ogg", "m4a", "flac", "aac" },
|
||||
[ArchiveKind] = new HashSet<string>(StringComparer.Ordinal) { "zip", "rar", "7z", "tar", "gz", "bz2" },
|
||||
[DocumentKind] = new HashSet<string>(StringComparer.Ordinal) { "pdf", "doc", "docx", "xls", "xlsx", "csv", "txt", "md", "rtf", "ppt", "pptx", "odt", "ods" },
|
||||
};
|
||||
|
||||
// Метки категорий (1:1 files.py KIND_LABELS L21–28).
|
||||
private static readonly IReadOnlyDictionary<string, string> LabelsByKind = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
[ImageKind] = ImageLabel,
|
||||
[VideoKind] = VideoLabel,
|
||||
[AudioKind] = AudioLabel,
|
||||
[ArchiveKind] = ArchiveLabel,
|
||||
[DocumentKind] = DocumentLabel,
|
||||
[OtherKind] = OtherLabel,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Определяет категорию вложения по MIME и расширению имени (files.py detect L31–45).
|
||||
/// </summary>
|
||||
/// <param name="name">Имя файла как прислано (расширение — часть после последней точки, в нижнем регистре).</param>
|
||||
/// <param name="mime">MIME-тип из загрузки (может быть null/пустым — тогда только расширение).</param>
|
||||
/// <returns>Категория {kind, label} (никогда не null; неопознанное → other/«Файл»).</returns>
|
||||
public static CardFileKind Detect(string name, string? mime)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
|
||||
string kind = OtherKind;
|
||||
string normalizedMime = mime?.Trim() ?? string.Empty;
|
||||
if (normalizedMime.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
kind = ImageKind;
|
||||
}
|
||||
else if (normalizedMime.StartsWith("video/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
kind = VideoKind;
|
||||
}
|
||||
else if (normalizedMime.StartsWith("audio/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
kind = AudioKind;
|
||||
}
|
||||
else
|
||||
{
|
||||
string extension = GetExtension(name);
|
||||
foreach ((string candidateKind, IReadOnlySet<string> extensions) in ExtensionsByKind)
|
||||
{
|
||||
if (extensions.Contains(extension))
|
||||
{
|
||||
kind = candidateKind;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new CardFileKind(kind, LabelsByKind[kind]);
|
||||
}
|
||||
|
||||
// Расширение имени файла после последней точки, в нижнем регистре (1:1 python: (name.split(".")[-1]).lower()).
|
||||
// name: Имя файла.
|
||||
// Возвращает: Расширение без точки; пустая строка, если точки в имени нет (или имя заканчивается точкой).
|
||||
private static string GetExtension(string name)
|
||||
{
|
||||
int lastDot = name.LastIndexOf('.');
|
||||
return lastDot < 0
|
||||
? string.Empty
|
||||
: name.Substring(lastDot + 1).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Modules.Settings.Application.Registrars;
|
||||
using Deal.Modules.Settings.Application.Services;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Extensions;
|
||||
using Deal.Modules.Kanban.Application.Registrars;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Сервис правил хранения: автоархив и очистка архива/корзины по срокам — Ruling 8, план Task 10.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Чистый сервис модуля (без EF/HTTP), повторяет <c>leads.py tick_storage</c> (L454–493). Настройки
|
||||
/// autoArchive/archiveAfterDays/archiveClearDays/trashClearDays читает типизированным снимком
|
||||
/// <see cref="TenantSettingsSnapshot"/> (C30: один GetAllAsync на тик; отсутствие/повреждение → дефолт из
|
||||
/// <c>SettingsDefaults</c>). Один «now»
|
||||
/// (UTC) на весь тик, как в прототипе. Последовательность 1:1 с tick_storage:
|
||||
/// (1) автоархив — карточки досок и «Неразобранного» со ReceivedAt старше archiveAfterDays → col=archive,
|
||||
/// is_new=false, archived_at=now (PrevCol НЕ трогается — Ruling 8: null в CardColumnUpdateDto), пачкой
|
||||
/// <see cref="ICardStore.ArchiveAsync"/> (один UPDATE вместо N);
|
||||
/// (2) очистка архива — col='archive' с ArchivedAt старше archiveClearDays (дефолт 90) → жёсткое удаление;
|
||||
/// (3) очистка корзины — col='trash' с ReceivedAt старше trashClearDays (дефолт 7) → жёсткое удаление.
|
||||
/// Удаление — пачкой <see cref="ICardStore.PurgeAsync"/> (Cards + комментарии каскадом; журнал CardMoves
|
||||
/// и MlOutbox не трогаются, Ruling 10). purgedRejected — отсев пайплайна (этап 4); в этапе 3 всегда 0.
|
||||
/// SSE-тосты сервис НЕ публикует: это обязанность эндпоинтов Api (Ruling 5) по статистике TickAsync.
|
||||
/// </remarks>
|
||||
/// <param name="store">Порт хранилища канбана: кандидаты тика, batch-перенос в архив, жёсткое удаление пачки.</param>
|
||||
/// <param name="settings">KV-хранилище настроек тенанта (снимок правил хранения, Ruling 8).</param>
|
||||
public sealed class StorageTickService(ICardStore store, ISettingsStore settings)
|
||||
{
|
||||
/// <summary>
|
||||
/// Один тик правил хранения тенанта (Ruling 8; leads.py tick_storage L454–493).
|
||||
/// </summary>
|
||||
/// <param name="ct">Токен отмены.</param>
|
||||
/// <returns>Статистика тика: сколько карточек архивировано/очищено из архива и корзины (purgedRejected=0).</returns>
|
||||
public async Task<StorageTickStatsDto> TickAsync(CancellationToken ct)
|
||||
{
|
||||
// Все правила хранения — из одного типизированного снимка настроек (C30): один GetAllAsync на тик.
|
||||
TenantSettingsSnapshot settingsSnapshot = await TenantSettingsSnapshot.LoadAsync(settings, ct);
|
||||
bool autoArchive = settingsSnapshot.GetBool(SettingsKeys.AutoArchive, SettingsDefaults.AutoArchive);
|
||||
int archiveAfterDays = settingsSnapshot.GetInt(SettingsKeys.ArchiveAfterDays, SettingsDefaults.ArchiveAfterDays);
|
||||
int archiveClearDays = settingsSnapshot.GetInt(SettingsKeys.ArchiveClearDays, SettingsDefaults.ArchiveClearDays);
|
||||
int trashClearDays = settingsSnapshot.GetInt(SettingsKeys.TrashClearDays, SettingsDefaults.TrashClearDays);
|
||||
|
||||
// Один «now» на весь тик — границы автоархива и очисток от одного момента (tick_storage L459).
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
|
||||
int archived = 0;
|
||||
if (autoArchive)
|
||||
{
|
||||
// Автоархив: карточки досок и «Неразобранного» (tick_storage L462–467). В архив — пачкой одним
|
||||
// UPDATE (архивация как системный перенос: col=archive, is_new=false, archived_at=now, matchHits
|
||||
// пусто; prev_col не трогается, Ruling 8); по-карточные UPDATE'ы тика заменены на batch.
|
||||
IReadOnlyList<string> candidates = await store.ListArchiveCandidatesAsync(
|
||||
now.AddDays(-archiveAfterDays), ct);
|
||||
archived = candidates.Count == 0 ? 0 : await store.ArchiveAsync(candidates, now, ct);
|
||||
}
|
||||
|
||||
// Очистка архива по сроку хранения (tick_storage L475–478).
|
||||
int purgedArchive = await PurgeAsync(
|
||||
await store.ListExpiredArchiveCandidatesAsync(now.AddDays(-archiveClearDays), ct), ct);
|
||||
|
||||
// Очистка корзины по сроку хранения (tick_storage L480–483).
|
||||
int purgedTrash = await PurgeAsync(
|
||||
await store.ListTrashCandidatesAsync(now.AddDays(-trashClearDays), ct), ct);
|
||||
|
||||
// purgedRejected — отсев пайплайна живёт в Pipeline (этап 4); в этапе 3 всегда 0 (Ruling 8).
|
||||
return new StorageTickStatsDto(archived, purgedArchive, purgedTrash, PurgedRejected: 0);
|
||||
}
|
||||
|
||||
// Жёстко удаляет пачку кандидатов очистки (Ruling 8; KanbanStore.PurgeAsync — комментарии каскадом).
|
||||
// cardIds: Id карточек-кандидатов из выборки хранилища.
|
||||
// ct: Токен отмены.
|
||||
// Возвращает: Сколько карточек реально удалено (0 — кандидатов не было).
|
||||
private async Task<int> PurgeAsync(IReadOnlyList<string> cardIds, CancellationToken ct)
|
||||
{
|
||||
return cardIds.Count == 0 ? 0 : await store.PurgeAsync(cardIds, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
using System.Text;
|
||||
using Deal.Modules.Cards.Application.Abstractions;
|
||||
using Deal.Modules.Cards.Application.Dtos;
|
||||
using Deal.Modules.Cards.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Extensions;
|
||||
using Deal.Modules.Kanban.Application.Registrars;
|
||||
|
||||
namespace Deal.Modules.Kanban.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Чистое ядро ИИ-предложений колонок/ключей — план Task 14 L467–471, Ruling 3.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Детерминированная эвристика этапа 3 (реальные предложения ИИ — этап 6): без EF/HTTP/хранилища.
|
||||
/// Тема колонки — повторяющееся слово-тема по source_msg карточек «Неразобранного»: текст
|
||||
/// токенизируется на слова (только буквы, ≥3 символов, нижний регистр, минус стоп-слова), слово,
|
||||
/// встречающееся в ≥2 карточках окна, становится кандидатом в колонку. Группы собираются жадным
|
||||
/// алгоритмом: самый частотный кандидат (при равенстве — более длинное слово, затем лексикографически)
|
||||
/// забирает свои карточки, следующие кандидаты получают только неразобранные (как used_msg прототипа,
|
||||
/// suggest.py L129–143). Выход — до <see cref="MaxColumns"/> планов, каждый с ≥2 карточками; похожие на
|
||||
/// существующие доски слова пропускаются (suggest.py _similar_exists L55–61). Suggest-keywords — те же
|
||||
/// частотные слова по выборке карточек (≤60, ≤40 симв.) — прототип suggest_domain_keywords L166–193.
|
||||
/// Все сравнения/порядки — Ordinal: одинаковый вход даёт одинаковый выход (Acceptance Task 14).
|
||||
/// </remarks>
|
||||
public static class SuggestHeuristics
|
||||
{
|
||||
// ── Пороги анализа (прототип suggest.py L48–52 и правила промпта L24–27) ──
|
||||
|
||||
/// <summary>
|
||||
/// Минимум карточек в «Неразобранном» для анализа (suggest.py MIN_INBOX L48).
|
||||
/// </summary>
|
||||
public const int MinInbox = 6;
|
||||
|
||||
/// <summary>
|
||||
/// Минимум карточек в одной колонке-предложении (MIN_INBOX_GROUP L49, «группируй… минимум 2 сообщения»).
|
||||
/// </summary>
|
||||
public const int MinInboxGroup = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Сколько сообщений берём в анализ — окно по received_at DESC (MAX_TEXT L50, LIMIT 12).
|
||||
/// </summary>
|
||||
public const int MaxText = 12;
|
||||
|
||||
/// <summary>
|
||||
/// Колонок-предложений за один прогон максимум (прототип cols[:4] L132, «2–4 колонки максимум»).
|
||||
/// </summary>
|
||||
public const int MaxColumns = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Минимальная длина слова-темы: слова короче 3 букв не несут темы (план Task 14 L468).
|
||||
/// </summary>
|
||||
public const int MinWordLength = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Максимальная длина слова-темы/ключа: 1:1 с прототипом (имя колонки ≤40, L136; ключ ≤40, L191).
|
||||
/// </summary>
|
||||
public const int MaxKeywordLength = 40;
|
||||
|
||||
/// <summary>
|
||||
/// Максимум ключей-маркеров suggest-keywords (прототип keywords[:60] L193).
|
||||
/// </summary>
|
||||
public const int MaxKeywordsTotal = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Окно карточек для suggest-keywords: свежие 40 с текстом (suggest.py L172–176, LIMIT 40).
|
||||
/// </summary>
|
||||
public const int KeywordsSampleLimit = 40;
|
||||
|
||||
/// <summary>
|
||||
/// Минимум карточек для suggest-keywords: «нужно хотя бы 3» (suggest.py L178).
|
||||
/// </summary>
|
||||
public const int MinKeywordsSample = 3;
|
||||
|
||||
// Сколько карточек группы учитывается в note-обосновании (текст note, план L469–470).
|
||||
private const string ColumnNoteFormat =
|
||||
"Эвристика (этап 3): слово-тема «{0}» встречается у {1} карточек; реальные предложения ИИ — этап 6";
|
||||
|
||||
// Служебные слова, не несущие темы: предлоги/союзы/частицы и типовые обращения заявок («нужен», «ищу»…).
|
||||
private static readonly HashSet<string> StopWords = new(StringComparer.Ordinal)
|
||||
{
|
||||
// Русские служебные слова (предлоги/союзы/частицы/местоимения).
|
||||
"а", "без", "более", "будет", "бы", "в", "вам", "вас", "ведь", "весь", "вместе", "вот", "впрочем",
|
||||
"всё", "все", "всего", "всех", "вы", "где", "да", "даже", "для", "до", "его", "её", "ей", "ему",
|
||||
"если", "есть", "ещё", "еще", "же", "за", "затем", "здесь", "и", "из", "или", "им", "иногда", "их",
|
||||
"к", "каждый", "как", "какая", "какие", "какой", "когда", "кого", "кому", "конечно", "которая",
|
||||
"которого", "которые", "который", "которых", "кто", "куда", "ли", "лишь", "лучше", "между", "меня",
|
||||
"мне", "много", "может", "можно", "мой", "моя", "мы", "на", "над", "надо", "нас", "не", "него",
|
||||
"нее", "неё", "ней", "нельзя", "нет", "ни", "но", "ну", "о", "об", "один", "однако", "он", "она",
|
||||
"они", "опять", "от", "очень", "перед", "по", "под", "после", "потом", "потому", "почти", "при",
|
||||
"про", "раз", "с", "сам", "сама", "самое", "самый", "свою", "себе", "себя", "сейчас", "сколько",
|
||||
"со", "совсем", "так", "такая", "такие", "такой", "там", "те", "тебе", "тебя", "тем", "теперь",
|
||||
"то", "тогда", "того", "тоже", "только", "том", "тот", "ту", "тут", "ты", "у", "уже", "хотя",
|
||||
"чего", "чем", "через", "что", "чтобы", "чуть", "эта", "эти", "этим", "этих", "это", "этого",
|
||||
"этой", "этом", "этот", "эту", "я",
|
||||
// Типовые обращения/глаголы-просьбы, общие для заявок любой сферы (не слова-темы).
|
||||
"добрый", "здравствуйте", "привет", "пожалуйста", "напишите", "написать", "пишите", "писать",
|
||||
"звоните", "свяжитесь", "связаться", "подробнее", "интересно", "интересует", "нужен", "нужна",
|
||||
"нужно", "нужны", "ищу", "ищем", "ищет", "ищут", "искал", "искали", "требуется", "требуются",
|
||||
"найти", "нанять", "рассмотрите", "рассмотрим",
|
||||
// Английские служебные слова.
|
||||
"the", "and", "for", "with", "from", "that", "this", "are", "was", "were", "have", "has", "had",
|
||||
"not", "but", "you", "your", "our", "can", "will", "would", "should", "about", "into", "over",
|
||||
"after", "before", "more", "most", "than", "then", "them", "they", "their", "there", "here", "when",
|
||||
"where", "which", "what", "who", "whom", "its", "been", "being", "all", "any", "some", "such",
|
||||
"only", "also", "very", "just", "please",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Планы колонок-предложений по карточкам «Неразобранного» (suggest_from_inbox L76–163).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Анализируется окно из <see cref="MaxText"/> самых свежих карточек (received_at DESC — как SQL
|
||||
/// L96–99); карточек в окне меньше <see cref="MinInbox"/> → пусто (причину «мало карточек…» называет
|
||||
/// адаптер). Слова существующих НЕ-suggested досок исключаются из кандидатов (похожесть имени,
|
||||
/// suggest.py L55–61). Каждая карточка попадает не более чем в одну группу (used_msg L129–143):
|
||||
/// кандидаты перебираются от самого частотного, группе достаются только ещё не разобранные карточки.
|
||||
/// Выход полностью детерминирован: сортировки Ordinal + порядок входа окна.
|
||||
/// </remarks>
|
||||
/// <param name="inbox">Карточки «Неразобранного» с непустым source_msg (ICardStore.ListInboxWithSourceAsync).</param>
|
||||
/// <param name="existingBoardNames">Имена существующих (suggested=false) досок — похожие темы не предлагаются.</param>
|
||||
/// <returns>Планы колонок (≤4, каждая ≥2 карточки); пусто — мало карточек/нечего сгруппировать.</returns>
|
||||
public static IReadOnlyList<SuggestedColumnPlan> PlanColumns(
|
||||
IReadOnlyList<CardDto> inbox,
|
||||
IReadOnlyList<string> existingBoardNames)
|
||||
{
|
||||
// Окно анализа: свежие карточки с текстом, как выборка SQL L96–99 (сортировка — страховка
|
||||
// детерминизма: адаптер уже отдаёт received_at DESC, но вход не должен влиять на выход).
|
||||
List<CardDto> window = inbox
|
||||
.Where(card => card.SourceMsg.Trim().Length > 0)
|
||||
.OrderByDescending(card => card.ReceivedAtMs)
|
||||
.ThenBy(card => card.Id, StringComparer.Ordinal)
|
||||
.Take(MaxText)
|
||||
.ToList();
|
||||
if (window.Count < MinInbox)
|
||||
{
|
||||
return Array.Empty<SuggestedColumnPlan>();
|
||||
}
|
||||
|
||||
// Слово → карточки окна, где оно встречается (у карточки слово учитывается один раз).
|
||||
Dictionary<string, List<string>> termCards = new(StringComparer.Ordinal);
|
||||
foreach (CardDto card in window)
|
||||
{
|
||||
// Distinct: у карточки слово учитывается один раз, сколько бы раз ни встречалось в тексте.
|
||||
foreach (string term in Tokenize(card.SourceMsg).Distinct(StringComparer.Ordinal))
|
||||
{
|
||||
if (!termCards.TryGetValue(term, out List<string>? cardIds))
|
||||
{
|
||||
cardIds = [];
|
||||
termCards[term] = cardIds;
|
||||
}
|
||||
|
||||
cardIds.Add(card.Id);
|
||||
}
|
||||
}
|
||||
|
||||
// Имена существующих досок — один раз в нижнем регистре (похожесть L55–61).
|
||||
List<string> existingLowered = existingBoardNames
|
||||
.Select(name => name.ToLowerInvariant())
|
||||
.ToList();
|
||||
|
||||
// Кандидаты: слово в ≥2 карточках и не похожее на существующую доску; порядок — частота,
|
||||
// при равенстве более длинное (специфичнее), затем лексикографически (детерминизм).
|
||||
List<string> candidates = termCards
|
||||
.Where(pair => pair.Value.Count >= MinInboxGroup && !SimilarToExisting(pair.Key, existingLowered))
|
||||
.Select(pair => pair.Key)
|
||||
.OrderByDescending(term => termCards[term].Count)
|
||||
.ThenByDescending(term => term.Length)
|
||||
.ThenBy(term => term, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
// Жадная сборка групп: каждый кандидат забирает только неразобранные карточки (suggest.py
|
||||
// used_msg L129–143); группа без ≥2 свободных карточек пропускается.
|
||||
var usedCardIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
var plans = new List<SuggestedColumnPlan>();
|
||||
foreach (string term in candidates)
|
||||
{
|
||||
if (plans.Count == MaxColumns)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
List<string> freeCardIds = termCards[term].Where(cardId => !usedCardIds.Contains(cardId)).ToList();
|
||||
if (freeCardIds.Count < MinInboxGroup)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
usedCardIds.UnionWith(freeCardIds);
|
||||
plans.Add(new SuggestedColumnPlan(
|
||||
Word: term,
|
||||
Name: Capitalize(term),
|
||||
CardIds: freeCardIds,
|
||||
Note: string.Format(ColumnNoteFormat, term, freeCardIds.Count)));
|
||||
}
|
||||
|
||||
return plans;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Частотные слова-маркеры по текстам карточек (suggest_domain_keywords L166–193, эвристика Ruling 3).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Маркер — слово (те же правила токенизации, что для колонок), встречающееся в ≥2 текстах выборки.
|
||||
/// Выход — до <see cref="MaxKeywordsTotal"/> слов, длина каждого ≤<see cref="MaxKeywordLength"/>;
|
||||
/// порядок — частота по убыванию, при равенстве более длинное, затем лексикографически
|
||||
/// (детерминизм: одинаковый вход → одинаковый выход). Выборку (свежие 40 вне trash/archive) и
|
||||
/// проверку «мало карточек» выполняет адаптер LocalColumnSuggester.
|
||||
/// </remarks>
|
||||
/// <param name="texts">Тексты source_msg карточек выборки (непустые, свежие ≤40).</param>
|
||||
/// <returns>Слова-маркеры (≤60); пусто — нет слов, встречающихся в ≥2 текстах.</returns>
|
||||
public static IReadOnlyList<string> SuggestDomainKeywords(IReadOnlyList<string> texts)
|
||||
{
|
||||
Dictionary<string, int> termCounts = new(StringComparer.Ordinal);
|
||||
foreach (string text in texts)
|
||||
{
|
||||
// Маркер — слово, встречающееся в ≥2 ТЕКСТАХ выборки: повтор в одном тексте не увеличивает счёт.
|
||||
foreach (string term in Tokenize(text).Distinct(StringComparer.Ordinal))
|
||||
{
|
||||
termCounts[term] = termCounts.GetValueOrDefault(term) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return termCounts
|
||||
.Where(pair => pair.Value >= MinInboxGroup)
|
||||
.OrderByDescending(pair => pair.Value)
|
||||
.ThenByDescending(pair => pair.Key.Length)
|
||||
.ThenBy(pair => pair.Key, StringComparer.Ordinal)
|
||||
.Select(pair => pair.Key)
|
||||
.Take(MaxKeywordsTotal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Слова текста: подряд букв (латиница/кириллица), нижний регистр, длина 3..40, минус стоп-слова.
|
||||
// text: Исходный текст (source_msg карточки).
|
||||
// Возвращает: Уникальные слова текста в порядке появления (Ordinal).
|
||||
private static List<string> Tokenize(string text)
|
||||
{
|
||||
var terms = new List<string>();
|
||||
var buffer = new StringBuilder();
|
||||
foreach (char c in text)
|
||||
{
|
||||
if (char.IsLetter(c))
|
||||
{
|
||||
buffer.Append(c);
|
||||
continue;
|
||||
}
|
||||
|
||||
AddTerm(buffer, terms);
|
||||
}
|
||||
|
||||
AddTerm(buffer, terms);
|
||||
return terms;
|
||||
}
|
||||
|
||||
// Сбрасывает буфер слова в список, если слово проходит пороги (длина, стоп-слова).
|
||||
// buffer: Накопленные буквы слова.
|
||||
// terms: Список слов текста.
|
||||
private static void AddTerm(StringBuilder buffer, List<string> terms)
|
||||
{
|
||||
if (buffer.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string term = buffer.ToString().ToLowerInvariant();
|
||||
buffer.Clear();
|
||||
if (term.Length is < MinWordLength or > MaxKeywordLength || StopWords.Contains(term))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
terms.Add(term);
|
||||
}
|
||||
|
||||
// Похоже ли слово-тема на существующую доску: равенство или вхождение имени в слово/наоборот
|
||||
// (suggest.py _similar_exists L55–61, регистронезависимо).
|
||||
// term: Слово-кандидат (нижний регистр).
|
||||
// existingLowered: Имена существующих досок в нижнем регистре.
|
||||
// Возвращает: True — тема уже покрыта существующей колонкой (кандидат пропускается).
|
||||
private static bool SimilarToExisting(string term, List<string> existingLowered)
|
||||
{
|
||||
foreach (string name in existingLowered)
|
||||
{
|
||||
if (term == name || name.Contains(term, StringComparison.Ordinal) || term.Contains(name, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Имя колонки из слова-темы: первая буква заглавная (python → Python, такси → Такси).
|
||||
// term: Слово-тема в нижнем регистре (непустое).
|
||||
// Возвращает: Слово с заглавной первой буквой.
|
||||
private static string Capitalize(string term) => char.ToUpperInvariant(term[0]) + term[1..];
|
||||
}
|
||||
Reference in New Issue
Block a user