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