using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application.Abstractions;
using Deal.Modules.Cards.Application.Dtos;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Kanban.Application.ColumnRules;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Registrars;
namespace Deal.Modules.Kanban.Application.Services;
///
/// Публичные операции карточек — partial-часть (C32: выделено из общего
/// файла по темам, поведение не менялось): чтение списка/карточки, переносы/корзина/возврат/удаление/
/// очистка колонки, комментарии, mark-seen, счётчики и поиск (leads.py L151–279, L509–551).
///
public sealed partial class CardsService
{
// ── Чтение (list_leads/get_lead L151–160) ───────────────────────────────
///
/// Карточки колонки или всех колонок дашборда, received_at DESC (list_leads L151–156).
///
/// Колонка-фильтр (inbox/archive/trash/доска); null — все колонки дашборда.
/// Токен отмены.
/// Полные карточки (маппинг/комментарии/time — адаптер); пусто — карточек нет.
public Task> ListCardsAsync(string? col, CancellationToken ct)
{
return _store.ListCardsAsync(new CardsQuery(col), ct);
}
///
/// Одна карточка по id (get_lead L159–160; GET /api/cards/{id}).
///
/// Id карточки (c_...).
/// Токен отмены.
/// Карточка или null — строки нет (эндпоинт отвечает 404 «Карточка не найдена»).
public Task GetCardAsync(string cardId, CancellationToken ct)
{
return _store.GetCardAsync(cardId, ct);
}
// ── Переносы / архив / корзина (L163–247) ───────────────────────────────
///
/// Перенос карточки на доску или в «Неразобранное» (move_lead L177–191 + _move L163–174).
///
///
/// Цель валидируется до чтения карточки: не inbox и не существующая доска → 400
/// . Исходная колонка archive/trash для MoveLeadAsync недоступна
/// → 400 : вывод из них — только restore_lead (иначе перенос минует
/// снятие метки «спам» возврата из корзины, Ruling 4). Перенос «в ту же колонку» — no-op (карточка
/// возвращается без изменений; журнал и обучение не пишутся). При реальном переносе: колонка меняется
/// (is_new=FALSE, prev_col = прежняя колонка), matchHits пересчитываются для доски через
/// (Ruling 2; для inbox — пусто), пишется строка журнала action=move,
/// а при to≠inbox — обучающий сигнал PushAsync(text, id доски, 1.0) (Ruling 4; текст = source_msg или title;
/// пустой текст не учим — L188–191).
///
/// Id карточки (c_...).
/// Цель: inbox либо id доски (b_...).
/// Токен отмены.
/// Результат: Error (400) | Card=null (карточки нет, 404) | Card — карточка после переноса.
public async Task MoveDashboardCardAsync(
string cardId,
string toCol,
CancellationToken ct)
{
ContainerDto? board = null;
if (toCol != CardIds.Inbox)
{
board = await _store.GetContainerAsync(toCol, ct);
if (board is null)
{
return new CardResultDto(MoveTargetInvalidDetail, null);
}
}
CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null)
{
return new CardResultDto(null, null);
}
// Из archive/trash карточку выводит только restore (L204–222): прямой move в доску
// прошёл бы мимо снятия у ML веса «спама» возврата из корзины (Ruling 4, L218–221).
if (card.Col == CardIds.Archive || card.Col == CardIds.Trash)
{
return new CardResultDto(MoveSourceRestrictedDetail, null);
}
if (card.Col == toCol)
{
return new CardResultDto(null, card);
}
string text = LearningText(card);
IReadOnlyList hits = toCol == CardIds.Inbox
? Array.Empty()
: 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));
}
///
/// Перенос карточки в корзину (trash_lead L194–201): col=trash, is_new=FALSE, matchHits пусто.
///
///
/// Карточка уже в корзине — no-op (как в _move L165–166). Журнал action=trash пишется при реальном
/// переносе; обучающий сигнал «спам» 1.0 — только если карточка была НЕ в trash/archive (L198–201,
/// Ruling 4). Исключение archive: перенос архива в корзину не «переучивает» на спам.
///
/// Id карточки (c_...).
/// Токен отмены.
/// Карточка после переноса (при no-op — как была) либо null — карточки нет (404).
public Task TrashCardAsync(string cardId, CancellationToken ct)
{
return TrashCardAsync(cardId, teach: true, ct);
}
///
/// Перенос карточки в корзину с управлением обучением ML (trash_lead L194–201).
///
///
/// = false — «тихое» перемещение без сигнала «спам»: используется ручной
/// переклассификацией (leads.py reclassify_lead L311/L316), где обучение кладётся ЯВНО одним сигналом
/// с весом гипотезы ИИ (0.4), а не весом действия пользователя (1.0). Журнал action=trash пишется всегда.
///
/// Id карточки (c_...).
/// True — писать сигнал «спам» (действие пользователя); false — не писать.
/// Токен отмены.
/// Карточка после переноса (при no-op — как была) либо null — карточки нет (404).
public async Task 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(), 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);
}
///
/// Возврат карточки из архива/корзины на канбан (restore_lead L204–222).
///
///
/// Куда возвращаем: prev_col, если это «Неразобранное» или существующая доска, иначе inbox (L209).
/// При возврате is_new=TRUE, prev_col='inbox', archived_at=NULL (Ruling 10), matchHits пересчитаны для
/// доски (Ruling 2), журнал action=restore. Возврат ИЗ корзины снимает метку спам:
/// PushAsync(text, "spam", −1.0) (L218–221, Ruling 4); из архива сигнал не шлётся.
///
/// Id карточки (c_...).
/// Токен отмены.
/// Колонка возврата (inbox/доска) либо null — карточки нет (404).
public async Task 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 hits = back == CardIds.Inbox
? Array.Empty()
: 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;
}
///
/// Полное удаление карточки (delete_forever L225–234): Cards + комментарии (FK cascade),
/// журнал CardMoves/MlOutbox не трогаются (Ruling 10).
///
/// Id карточки (c_...).
/// Токен отмены.
/// False — карточки нет (404 «Карточка не найдена»); True — удалена.
public async Task DeleteForeverAsync(string cardId, CancellationToken ct)
{
if (await _store.GetCardAsync(cardId, ct) is null)
{
return false;
}
await _store.DeleteForeverAsync(cardId, ct);
return true;
}
///
/// Полная ручная очистка служебной колонки trash/archive (clear_col L237–247).
///
/// Другая колонка (inbox/доска/…) → 400 (как ValueError L239–240).
/// Очищаемая колонка: trash | archive.
/// Токен отмены.
/// Результат: Error (400) либо Cleared — сколько карточек удалено навсегда.
public async Task ClearColAsync(string col, CancellationToken ct)
{
if (col != CardIds.Trash && col != CardIds.Archive)
{
return new ClearColResultDto(ClearColInvalidDetail, 0);
}
int cleared = await _store.ClearColAsync(col, ct);
return new ClearColResultDto(null, cleared);
}
// ── Комментарии (add_comment L259–265) ──────────────────────────────────
///
/// Добавляет комментарий к карточке: строка LeadComments (id cm_) + журнал action=comment.
///
///
/// Текст Trim'ится (пустой после Trim → 400 , как dashboard_routes L240–241);
/// автор — «Вы»; ответ — полный список комментариев (свежий — «только что», маппинг адаптера). Карточки
/// нет → Comments=null, Error=null (404 «Карточка не найдена» — сервис читает карточку до записи, порт
/// LeadComments ссылается FK, Task 4).
///
/// Id карточки (c_...).
/// Текст комментария (непустой после Trim).
/// Токен отмены.
/// Результат: Error (400) | Comments=null (404) | Comments — список после добавления.
public async Task 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 comments = await _store.ListCommentsAsync(cardId, ct);
return new AddCommentResultDto(null, comments);
}
// ── Пометить прочитанным (mark_seen L250–256) ───────────────────────────
///
/// Снимает флаг «новое»: с одной карточки (cardId), колонки (col) или всех (оба null/пустые).
///
/// Семантика 1:1 с mark_seen L250–256 (проверка на truthiness: пустая строка = параметр не задан).
/// Эндпоинты этапа: mark-col-seen {col}, mark-all-seen (Ruling 11); /leads/{id}/seen фронтом не вызывается.
/// Id карточки либо null/пусто.
/// Колонка либо null/пусто (используется, когда cardId не задан).
/// Токен отмены.
public Task MarkSeenAsync(
string? cardId,
string? col,
CancellationToken ct)
{
return _store.UpdateSeenAsync(
string.IsNullOrEmpty(cardId) ? null : cardId,
string.IsNullOrEmpty(col) ? null : col,
ct);
}
// ── Счётчики (counts L268–279) ──────────────────────────────────────────
///
/// Счётчики колонок (count+new по Cards) + статистика обучения/решений ML (learning/ml/ai).
///
///
/// Форма CardCountsDto: Columns — только колонки с карточками; New — сумма «новых» по колонкам
/// (counts L270–274). learning/ml/ai — из IMlClient.StatusAsync (L275–278, план Task 7 L321): learning =
/// count(CardMoves), ml/ai — KV-счётчики решений пайплайна (на этапе 3 — 0, Ruling 4). Плоскую wire-форму
/// «{new, <col>:{…}, learning, ml, ai}» собирает эндпоинт Task 8.
///
/// Токен отмены.
/// Счётчики: колонки + learning/ml/ai (поля New/Learning/Ml/Ai и словарь Columns).
public async Task CountsAsync(CancellationToken ct)
{
IReadOnlyDictionary columns = await _store.CountCardsByColAsync(ct);
MlStatusResponseDto mlStatus = await _mlClient.StatusAsync(ct);
return new CardCountsDto
{
New = columns.Values.Sum(column => column.New),
Columns = columns,
Learning = mlStatus.Stats.Learning,
Ml = mlStatus.Stats.Ml,
Ai = mlStatus.Stats.Ai,
};
}
// ── Поиск (search L509–551, LIKE-вариант Ruling 6) ──────────────────────
///
/// Поиск карточек: FTS по Cards.SearchTsv + LIKE-дополнение (search L509–551, Ruling 6/Task 12).
///
///
/// q после Trim короче 2 символов → пусто, порт не вызывается (поведение этапа 3, L511–512). Сам поиск
/// выполняет адаптер — : SearchTsv @@ plainto_tsquery('russian')
/// (морфология) ∪ LIKE по lower(title/summary/contact/source_msg), контейнеры-стадии «Выбранных»
/// исключены, порядок
/// ts_rank DESC, ReceivedAt DESC, результат ограничен = 12. messages: [] — на
/// совесть эндпоинта (Task 8). Запрос нормализуется trim+lowercase (как отсев-поиск Ruling 6).
///
/// Поисковый запрос (trim + lowercase внутри).
/// Токен отмены.
/// Найденные карточки (≤12); пусто — запрос короче 2 символов или нет совпадений.
public async Task> SearchCardsAsync(string? query, CancellationToken ct)
{
string lowered = (query ?? string.Empty).Trim().ToLowerInvariant();
if (lowered.Length < MinSearchQueryLength)
{
return Array.Empty();
}
return await _store.SearchCardsAsync(lowered, SearchLimit, ct);
}
}