using Deal.Modules.Kanban.Application.Models; namespace Deal.Modules.Kanban.Application; /// /// Единый порт хранилища карточек и контейнеров (таблицы Cards/Containers/LeadComments/CardMoves тенанта; /// жёсткое удаление карточки дополнительно чистит строки DedupEntries — Ruling 3), Ruling 1. /// /// /// Объявлен в модуле Kanban — владельце единой сущности карточки (этап 9); реализация — EF-адаптер /// KanbanStore в Deal.Infrastructure (регистрация в AddDealPersistence). Порт покрывает оба /// пространства одной таблицы Cards: дашборд (служебные зоны/доски) и «Выбранные» (контейнеры-стадии), /// поэтому отдельный порт карточек «Выбранных» упразднён. Порт оперирует DTO модуля; маппинг /// DTO ↔ строки (включая JSON-поля и human-метки времени, Ruling 10) выполняет адаптер вручную /// (эталон SettingsStore.cs). Набор методов — ровно тот, что нужен сервису карточек (YAGNI): /// контейнеры, карточки и переносы, комментарии, журнал CardMoves, кандидаты правил хранения (Ruling 8), /// пересчёт конверсий (Ruling 7) и выборка «Неразобранного» для эвристики (Ruling 3). /// Id записей генерирует модуль (Ruling 12: короткие префиксные id через KanbanIdPrefixes) и передаёт /// готовыми — хранилище id не создаёт. Чтения — AsNoTracking; сортировки (received_at DESC и т.п.) — /// обязанность адаптера. Удаление карточки навсегда (DeleteForeverAsync/ClearColAsync/PurgeAsync) /// снимает и «мягкие» dedup-ссылки (Ruling 3): строки таблицы DedupEntries чужого модуля адаптер удаляет /// напрямую тем же TenantDbContext, цикла Kanban → Pipeline не возникает (Kanban о Pipeline не знает). /// public interface ICardStore { // ── Контейнеры (колонки/стадии/зоны) ───────────────────────────────── /// /// Контейнеры пространства в порядке показа: ORDER BY space, position (этап 9, T4). /// /// Пространство (dashboard/selected) либо null — все контейнеры. /// Токен отмены. /// Контейнеры (DTO, счётчики заполняет сервис); пусто — контейнеров нет. public Task> ListContainersAsync(string? space, CancellationToken ct); /// /// Один контейнер по id (PATCH 404-семантика, переносы и валидация колонок). /// /// Id контейнера (inbox/archive/trash/стадия/b_...). /// Токен отмены. /// Контейнер или null, если строки нет. public Task GetContainerAsync(string containerId, CancellationToken ct); /// /// Создаёт контейнер (id/позицию/цвет/дефолты вычисляет ContainersService). /// /// Полный контейнер для вставки, включая . /// Токен отмены. public Task CreateContainerAsync(ContainerDto container, CancellationToken ct); /// /// Обновляет контейнер целиком (сервис читает Get + применяет ContainerPatchDto). /// /// Контейнер с изменёнными полями (идентифицируется по Id). /// Токен отмены. public Task UpdateContainerAsync(ContainerDto container, CancellationToken ct); /// /// Удаляет контейнер, предварительно перенося его карточки в «Неразобранное». /// /// Id удаляемого контейнера. /// Токен отмены. /// Сколько карточек перенесено в inbox (0 — контейнера/карточек не было; ответ {ok, movedToInbox}). public Task DeleteContainerAsync(string containerId, CancellationToken ct); /// /// Переставляет контейнеры пространства: позиции 0..N-1 в порядке списка. /// /// Пространство переставляемых контейнеров. /// Id контейнеров в новом порядке. /// Токен отмены. public Task ReorderContainersAsync(string space, IReadOnlyList containerIds, CancellationToken ct); // ── Карточки ────────────────────────────────────────────────────────── /// /// Карточки колонки или всех колонок дашборда, ORDER BY received_at DESC (list_leads L151–156). /// /// Фильтр: Col — конкретная колонка либо null (все колонки дашборда). /// Токен отмены. /// Полные карточки (DTO): JSON-поля разобраны, комментарии приложены, time посчитан. public Task> ListCardsAsync(CardsQuery query, CancellationToken ct); /// /// Карточки пространства «Выбранные», ORDER BY updated_at DESC (list_cards projects.py L58–63). /// /// Фильтр по контейнеру-стадии (id каталога ); null — все стадии. /// Токен отмены. /// Полные карточки (DTO): JSON-поля разобраны, комментарии приложены, time посчитан. public Task> ListSelectedCardsAsync(string? containerId, CancellationToken ct); /// /// Полнотекстовый поиск карточек (FTS + LIKE-дополнение; leads.py search L509–551, Ruling 6/Task 12). /// /// Поисковый запрос (уже Trim+lowercase, как в отсев-поиске; короче 2 символов сервис не пропускает). /// Ограничение результата (эндпоинт шлёт 12, Ruling 6). /// Токен отмены. /// Полные карточки (комментарии приложены, time посчитан): FTS-кандидаты по /// убыванию ts_rank (SearchTsv @@ plainto_tsquery), затем LIKE-дополнение, внутри — ReceivedAt DESC. public Task> SearchCardsAsync(string q, int limit, CancellationToken ct); /// /// Одна карточка по id (GET /api/cards/{id}, а также перечитывание после переноса). /// /// Id карточки (c_...). /// Токен отмены. /// Карточка или null, если строки нет. public Task GetCardAsync(string cardId, CancellationToken ct); /// /// Карточка по исходному сообщению (диалог + id сообщения) — ручная разметка ML (§8). /// /// Если сообщение давало несколько карточек (повторная разметка/пересоздание), берётся самая /// свежая по ReceivedAt. Пустой dialogId — null (нечем искать). /// Id диалога-источника сообщения. /// Id исходного сообщения в Telegram. /// Токен отмены. /// Карточка или null, если сообщение не становилось карточкой. public Task GetCardBySourceAsync(string dialogId, long msgId, CancellationToken ct); /// /// Создаёт карточку из готового снимка (пайплайн этапа 4, ручное создание; CreatedAt — UTC-now). /// /// Полное состояние новой карточки (id сгенерирован модулем, см. CardSnapshot). /// Токен отмены. public Task AddCardAsync(CardSnapshot snapshot, CancellationToken ct); /// /// Точечная правка полей по присутствующим в патче + bump UpdatedAt (patch_card projects.py L159–187). /// /// Id карточки (c_...). /// Изменяемые поля (null — поле не меняется; JSON-поля — полная замена). /// Токен отмены. /// True — строка обновлена; false — карточки нет (404-семантика сервиса). public Task PatchCardAsync(string cardId, CardPatch patch, CancellationToken ct); /// /// Атомарно дописывает ссылку в JSON-массив links ОДНИМ UPDATE (jsonb-append) + bump UpdatedAt. /// /// Id карточки (c_...). /// Готовая ссылка {id,name,url} (id сгенерирован модулем). /// Токен отмены. /// True — строка обновлена; false — карточки нет (404-семантика сервиса). public Task AddLinkAsync(string cardId, CardLinkDto link, CancellationToken ct); /// /// Атомарно убирает из JSON-массива links элемент с указанным id ОДНИМ UPDATE (jsonb-фильтрация) + bump UpdatedAt. /// /// Id карточки (c_...). /// Id удаляемой ссылки (pl_...). /// Токен отмены. /// True — строка обновлена; false — карточки нет (404-семантика сервиса). public Task RemoveLinkAsync(string cardId, string linkId, CancellationToken ct); /// /// Атомарно дописывает метаданные файла в JSON-массив files ОДНИМ UPDATE (jsonb-append) + bump UpdatedAt. /// /// Id карточки (c_...). /// Готовые метаданные {id,name,size,kind,label,objectKey} (id сгенерирован модулем). /// Токен отмены. /// True — строка обновлена; false — карточки нет (404-семантика сервиса). public Task AddFileAsync(string cardId, CardFileDto file, CancellationToken ct); /// /// Атомарно убирает из JSON-массива files элемент с указанным id ОДНИМ UPDATE (jsonb-фильтрация) + bump UpdatedAt. /// /// Id карточки (c_...). /// Id удаляемой записи файла (pf_...). /// Токен отмены. /// True — строка обновлена; false — карточки нет (404-семантика сервиса). public Task RemoveFileAsync(string cardId, string fileId, CancellationToken ct); /// /// Смена контейнера-стадии: один UPDATE (col, reminder_at=NULL, reminder_fired=false, updated_at=atMs) /// + перезапись history-массива с добавленной записью (move_stage projects.py L202–216; Ruling 7). /// /// Id карточки (c_...). /// Новый контейнер-стадия (валидирует сервис каталогом ). /// Готовая запись истории {id,at,stage} (id сгенерирован модулем). /// Время переноса, epoch-ms (пишется в updated_at и в запись истории). /// Токен отмены. /// True — строка обновлена; false — карточки нет (404-семантика сервиса). public Task MoveCardStageAsync(string cardId, string containerId, CardHistoryDto historyEntry, long atMs, CancellationToken ct); /// /// Устанавливает напоминание: reminder_at, reminder_fired=false + bump UpdatedAt (set_reminder projects.py L236–243). /// /// Id карточки (c_...). /// Время напоминания, epoch-ms. /// Токен отмены. public Task SetReminderAsync(string cardId, long atMs, CancellationToken ct); /// /// Снимает напоминание: reminder_at=NULL, reminder_fired=false (clear_reminder projects.py L246–247). /// /// Id карточки (c_...). /// Токен отмены. public Task ClearReminderAsync(string cardId, CancellationToken ct); /// /// Полная ручная очистка контейнера-стадии: DELETE строк (clear_stage projects.py L223–231). /// /// Очищаемая стадия (допустимость — только rejected — валидирует сервис). /// Токен отмены. /// Сколько карточек удалено (0 — стадия пуста). public Task ClearStageAsync(string containerId, CancellationToken ct); /// /// Наступившие напоминания стадии hold: ReminderAt ≤ now, ReminderFired=false, ORDER BY ReminderAt (check_reminders projects.py L270–275). /// /// Текущий момент (UTC) для сравнения с ReminderAt. /// Токен отмены. /// Due-строки {id,title,containerId} в порядке наступления; пусто — сработавших нет. public Task> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct); /// /// Помечает due-карточки сработавшими: ReminderFired=true по списку id (check_reminders projects.py L277–278). /// /// Id карточек, чьи напоминания «выстрелили». /// Токен отмены. public Task MarkRemindersFiredAsync(IReadOnlyList cardIds, CancellationToken ct); /// /// Очищает протухшие напоминания при выключенной настройке: ReminderAt=NULL WHERE ReminderAt ≤ now (check_reminders L266–269). /// /// Текущий момент (UTC). /// Токен отмены. /// Сколько строк очищено. public Task ClearExpiredRemindersAsync(DateTimeOffset now, CancellationToken ct); /// /// Меняет колонку/состояние карточки (move/trash/restore/автоархив) — см. CardColumnUpdateDto. /// /// Новое состояние колонки карточки (matchHits пересчитан в модуле, Ruling 2). /// Токен отмены. public Task UpdateColumnAsync(CardColumnUpdateDto update, CancellationToken ct); /// /// Применяет результат ручной переклассификации к существующей карточке ОДНИМ обновлением /// (leads.py reclassify_lead L346–367): тип/заголовок/суть/стек/бюджет/конверсия/контакты/matchHits + колонка. /// /// Поля классификации (полная замена; см. CardReclassificationDto). /// Токен отмены. /// True — строка обновлена; false — карточки нет (404-семантика сервиса). public Task ApplyReclassificationAsync(CardReclassificationDto update, CancellationToken ct); /// /// Снимает флаг «новое»: с одной карточки (cardId), с колонки (col) или со всех (оба null) — leads.py mark_seen L250–256. /// /// Id карточки либо null. /// Колонка либо null. /// Токен отмены. public Task UpdateSeenAsync(string? cardId, string? col, CancellationToken ct); /// /// Удаляет карточку навсегда: Cards + комментарии (FK cascade) + строки дедупа карточки /// (DedupEntries WHERE LeadId=?, Ruling 3); журнал/outbox не трогает (leads.py _hard_delete L225–234). /// /// Id карточки. /// Токен отмены. public Task DeleteForeverAsync(string cardId, CancellationToken ct); /// /// Полная очистка служебной колонки (только trash|archive — валидирует сервис), leads.py clear_col L237–247; /// строки дедупа удаляемых карточек чистятся вместе с ними (Ruling 3). /// /// Очищаемая колонка (trash/archive). /// Токен отмены. /// Сколько карточек удалено (0 — колонка пуста). public Task ClearColAsync(string col, CancellationToken ct); /// /// Счётчики карточек по колонкам (count + new) для GET /api/cards/counts (leads.py counts L268–279). /// /// Токен отмены. /// Словарь col → {count, new}; ключи — только колонки с карточками. public Task> CountCardsByColAsync(CancellationToken ct); // ── Комментарии (LeadComments) ──────────────────────────────────────── /// /// Комментарии карточки (для ответа add-comment и карточки; сортировка по времени добавления). /// /// Id карточки. /// Токен отмены. /// Комментарии (time — human-метка от CreatedAt); пусто — комментариев нет. public Task> ListCommentsAsync(string cardId, CancellationToken ct); /// /// Добавляет комментарий (leads.py add_comment L259–265; CreatedAt — UTC-now). /// /// Готовый id (cm_..., генерирует модуль). /// Id карточки. /// Автор («Вы» — свои комментарии). /// Текст (непустой — валидирует сервис, 400 «Пустой комментарий»). /// Токен отмены. public Task AddCommentAsync(string commentId, string cardId, string by, string text, CancellationToken ct); // ── Журнал действий (CardMoves = learning_log) ──────────────────────── /// /// Пишет строку журнала действия пользователя (move/trash/restore/comment) — leads.py _log_learning L40–44. /// /// Запись журнала (id lm_... сгенерирован модулем). /// Токен отмены. public Task AddMoveAsync(CardMoveDto move, CancellationToken ct); /// /// Число записей журнала = счётчик learning (Ruling 4, Task 5 StatusAsync). /// /// Токен отмены. /// Количество строк CardMoves. public Task CountMovesAsync(CancellationToken ct); /// /// Свежие примеры разметки пользователя для few-shot ИИ-классификации (pipeline.py _learning_examples L201–215). /// /// /// Join журнала CardMoves с карточками (Cards.SourceMsg): действия move/restore, цель не служебная /// (trash/archive), исходный текст непустой; ORDER BY created_at DESC, ≤ limit записей. Используется /// контекст-билдером классификации (план Task 15, Ruling 5) — текст примера дополнительно режется /// вызывающим до 500 кодовых точек (python L214). /// /// Максимум примеров (python L201: 8). /// Токен отмены. /// Примеры «текст → колонка», свежие первыми; пусто — истории разметки нет. public Task> GetAiMarkupExamplesAsync(int limit, CancellationToken ct); // ── Правила хранения (тик, Ruling 8) ────────────────────────────────── /// /// Кандидаты на автоархив: карточки досок и «Неразобранного» со ReceivedAt старше срока (tick_storage L462–467). /// /// Граница: received_at < now − archiveAfterDays. /// Токен отмены. /// Id карточек-кандидатов (колонка при архивации пишется archive через UpdateColumnAsync). public Task> ListArchiveCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct); /// /// Архивирует пачку карточек ОДНИМ UPDATE: col=archive, is_new=false, archived_at=archivedAt, /// matchHits — пустой массив (тик tick_storage L462–473; batch-замена по-карточных UpdateColumnAsync, /// PrevCol при архивации не трогается — Ruling 8). /// /// Id карточек-кандидатов (список из ListArchiveCandidatesAsync). /// Момент архивации (один «now» тика). /// Токен отмены. /// Сколько карточек реально архивировано (0 — кандидатов не было/уже не в рабочих колонках). public Task ArchiveAsync(IReadOnlyList cardIds, DateTimeOffset archivedAt, CancellationToken ct); /// /// Кандидаты на очистку архива: col='archive' и ArchivedAt старше срока (tick_storage L475–478). /// /// Граница: archived_at < now − archiveClearDays. /// Токен отмены. /// Id карточек для жёсткого удаления. public Task> ListExpiredArchiveCandidatesAsync(DateTimeOffset archivedBeforeUtc, CancellationToken ct); /// /// Кандидаты на очистку корзины: col='trash' и ReceivedAt старше срока (tick_storage L480–483). /// /// Граница: received_at < now − trashClearDays. /// Токен отмены. /// Id карточек для жёсткого удаления. public Task> ListTrashCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct); /// /// Жёстко удаляет пачку карточек: Cards + комментарии (FK cascade) + строки дедупа карточек /// (DedupEntries WHERE LeadId IN …, Ruling 3) — для очисток тика и clear-col (Ruling 8). /// /// Id карточек на удаление. /// Токен отмены. /// Сколько карточек удалено. public Task PurgeAsync(IReadOnlyList cardIds, CancellationToken ct); // ── Пересчёт конверсий (Ruling 7, Task 12) ───────────────────────────── /// /// Карточки для пересчёта ConvFrom/ConvTo/ConvCur: budgetCur непуст и col NOT IN (archive, trash) — recompute_conversions L106–130. /// /// Токен отмены. /// Карточки с бюджетом (используются Budget/бюджетные поля; служебные колонки исключены). public Task> ListCardsForConversionAsync(CancellationToken ct); /// /// Записывает пересчитанную конверсию бюджета карточки (обновляет только conv-поля). /// /// Id карточки. /// Сконвертированная нижняя граница, либо null. /// Сконвертированная верхняя граница, либо null. /// Валюта конверсии (целевая валюта тенанта); пусто — конверсия снята. /// Токен отмены. public Task UpdateConversionAsync(string cardId, double? convFrom, double? convTo, string convCur, CancellationToken ct); // ── Эвристика ИИ-предложений (Ruling 3, Task 14) ─────────────────────── /// /// Карточки «Неразобранного» с исходным текстом — вход эвристики suggest (suggest.py, Ruling 3). /// /// Токен отмены. /// Карточки col='inbox' с непустым SourceMsg (частотные темы считаются по source_msg). public Task> ListInboxWithSourceAsync(CancellationToken ct); }