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