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); } }