План stage3 kanban
stepan edited this page 2026-09-13 00:17:00 +03:00
This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

Перенесено из репозитория (docs/superpowers/plans/2026-09-05-deal-stage3-kanban.md). Актуальная версия — здесь, в вики.

Дейл (Deal) — Этап 3: Kanban (дашборд): колонки, карточки, архив/корзина Implementation Plan

Исторический документ этапа 3. Актуальное состояние — docs/superpowers/STATUS.md и docs/technical/Техническая-документация-Дейл.md.

Goal: Оживить в модульном монолите src/core дашборд Vue-фронта 1:1-контрактом /api канбана: колонки-доски и их правила (детерминированная раскладка + «почему карточка в колонке»), карточки (поля ТЗ §5, комментарии, быстрые действия), архив/корзина с правилами хранения (автоархив 1–30 дн., очистка архива 90 дн. и корзины 7 дн., ручная очистка, возврат), переносы drag&drop с журналом обучения и сигналами ML, полнотекстовый-LIKE поиск по карточкам, SSE-реалтайм (new_lead/toast), ИИ-предложения колонок/ключей на детерминированной эвристике, пересчёт конверсий бюджетов при смене курсов/целевой валюты. К концу этапа канбан-экран фронта (колонки, карточки, архив/корзина, поиск, предложения) полностью обслуживается бэкендом; приёмка — unit/curl/psql + сквозной сценарий на демо- карточках (реальный ввод сообщений — этап 4 Pipeline).

Architecture: новый модуль Deal.Modules.Kanban (чистый, без EF): DTO (Board/Card/…), порт IKanjStore, сервисы BoardsService/CardsService/StorageTickService/ConversionRecomputer, чистые правила колонок ColumnRules (перенос backend/app/services/rules.py) и ядро эвристик предложений SuggestHeuristics. Адаптеры — в Deal.Infrastructure: KanbanStore (таблицы Boards/Cards/LeadComments/CardMoves/MlOutbox), доработка LocalMlClient (PushAsync + счётчики learning/outbox из таблиц), LocalColumnSuggester (порт IColumnSuggester из Deal.Contracts). HTTP-эндпоинты — Deal.Api/Endpoints/* (MapBoardsEndpoints, MapLeadsEndpoints, MapStorageEndpoints, MapDemoEndpoints, MapAiSuggestEndpoints, MapEventsEndpoint, MapBootStubEndpoints); SSE-брокер per-tenant — в Deal.Api. Карточки создаёт пока только демо-путь (simulate-lead, как devtests прототипа) — pipeline-воркер приходит этапом 4; внешний ИИ/ML — этапы 6/4. Один новый EF-контекст не заводится: таблицы добавляются в существующий TenantDbContext (миграция TenantKanban, применяется провижинером ко всем схемам тенантов, этап 1).

Spec: docs/api/api-map.md §3.2 (L60121), §2 SSE (L2744), правила (L7–24, п.9 «экономия» L399, кривые места L390–400); §4.1 карточка (L228257), §4.2 доска (L259278), §4.6 colState (L333); docs/spec/ТЗ-дейл-новая-архитектура.md §5 «Карточка» (L112–121), §6 «Дашборд (канбан)» (L121135); roadmap (этап 3, L46–53); референс-семантика: backend/app/routers/dashboard_routes.py целиком, backend/app/services/leads.py, rules.py, suggest.py, rates.py (L6274, L106130), backend/app/services/ml_client.py, backend/app/services/pipeline.py (L433514, L540586), backend/app/sse.py, backend/app/main.py (L43–53 фоновые циклы), backend/app/constants.py (PALETTE L1216, DAY_MS L249254); фронт: src/frontend/src/store.js (boot L565–628 — какие группы обязаны отвечать 200; SSE L650–688; действия лидов L833–975; доски L9771183; поиск L11851206; tickAuto L18551863, rebuildFts L18841889; colMeta/orderedCols L188223), src/frontend/src/api.js (openEvents L62104 — слушает только new_lead/toast/reminder_due/system_status), views/DashboardView.vue, components/Column.vue, LeadCard.vue, LeadDrawer.vue, MoveMenu.vue, BoardRulesDialog.vue, SearchPalette.vue, ConfirmDialog.vue, data.js.

Global Constraints

  • Проект НЕ git; фиксация — отчёты task-N-report.md и progress.md в .superpowers/sdd/deal-stage3-kanban/.
  • .NET 10 SDK, scripts/build.sh/scripts/test.sh; решение собирается 0 warnings / 0 errors (TreatWarningsAsErrors). Dev-Postgres deal-postgres (:5433), curl-приёмка :5080.
  • Код-стайл этапов 1–2: 1 тип = 1 файл; XML-doc на public-контракты; комментарии на русском; явные модификаторы; настройки через ISettingsStore/IOptions<T>; без регионов; без магических чисел; PascalCase-колонки БД; JSON camelCase; ошибки {"detail"}.
  • Модуль Kanban — чистый: без EF и HTTP; зависимости — Deal.Modules.Settings (порт ISettingsStore) и Deal.Contracts (IMlClient). Реверс-зависимостей (Settings → Kanban) нет.
  • LeadRadar-контейнеры, backend/, mlservice/, src/frontend/ НЕ трогаем.
  • Строки ошибок/тостов — фиксированные из прототипа (см. задачи); новые строки только для согласованных заглушек (Ruling 7, Ruling 11).
  • Vue-фронт не переписывается: формы JSON и эндпоинты 1:1 с api-map; «кривые места» (голый массив /boards, messages: [], недостижимые SSE-события) сохраняем как в прототипе.

Зафиксированные решения (Rulings этапа)

  • Ruling 1 (а) — миграция TenantKanban и таблицы. Новая миграция TenantKanban контекста TenantDbContext (папка I/Migrations/TenantDb, применяется провижинером к схемам всех тенантов). Таблицы (PascalCase, соответствие прототипу): Boards (= boards; колонки-доски), Cards (= leads; карточки дашборда), LeadComments (= comments-массив строки leads, нормализуем), CardMoves (= learning_log; журнал действий/обучения, id lm_), MlOutbox (= ml_outbox; очередь обучающих сигналов, id mle_). JSON-поля храним как text с сериализованным JSON (как value_json настроек). Времена — timestamptz (DateTimeOffset); наружу epoch-ms конвертирует маппинг. Cards.Col — текст без FK (значения inbox|archive|trash|taken|<b_…>, как прототип); приложение валидирует существование досок. LeadComments.CardId — FK → Cards.Id (cascade delete); CardMoves/MlOutbox — без FK (журнал живёт дольше карточки, прототип _hard_delete его не чистит). Индексы: Cards (Col, ReceivedAt DESC), Cards (Col, IsNew), Boards (Suggested, Position) (ORDER BY suggested, pos), LeadComments (CardId), MlOutbox (CreatedAt). Колонки Boards: Id/Name/Description/Color/Width/Position/KeywordsJson/Prompt/VisibleFieldsJson/Collapsed/Suggested/ RulesJson/Note/CreatedAt; Cards: Id/Col/IsNew/IsVacancy/IsVacancyKnown/Title/Summary/StackJson/ BudgetFrom/BudgetTo/BudgetCur/ConvFrom/ConvTo/ConvCur/Contact/ContactsJson/ChannelName/ChannelHandle/ ChannelHue/ReceivedAt/SourceMsg/SourceDialogId/SourceMsgId/PrevCol/ArchivedAt/MatchHitsJson/CreatedAt (сущности/конфигурации — 1 тип = 1 файл, эталон TenantSettingEntity+Configuration).
  • Ruling 2 (б) — «почему карточка в колонке» (matchHits). Совпавшие критерии вычисляет модуль Kanban в момент размещения карточки в доску: перенос move (leads.py L163174), возврат restore (L204–222), назначение при создании (этап 4/демо). Вычисление — чистые функции ColumnRules (перенос rules.py: match_text L176209, score_text L212227, excluded_terms/ is_excluded L230248, board_accepts L251268, hits L271296, hits_for_board L311319, has_active_rules L322338, describe L341368, extract_amounts L93144 с grade-алиасами L2027 и content_text L5154). Для inbox/archive/trash и досок без активных правил — []. Значение хранится в Cards.MatchHitsJson, отдаётся как matchHits (§4.1 L251). Страховка «ИИ/ML не кладут в отфильтрованную колонку» (board_accepts) понадобится этапам 4/6 — правила готовы сейчас.
  • Ruling 3 (в) — порт ИИ-предложений колонок. В C/Integrations объявляется IColumnSuggester + record-DTO (SuggestColumnsResultDto {Ok, Created, Reason, Cooldown}, SuggestKeywordsResultDto {Ok, Keywords, Reason}) — этап 6 заменит реализацию gRPC-клиентом ai-service (тот же контракт). Этап 3 — детерминированная эвристика: чистый SuggestHeuristics в модуле Kanban (частотные слова-темы по source_msg карточек «Неразобранного»; MIN_INBOX=6, группа ≥2 карточек, ≤4 колонок, {mode:"any", keywords:[…]}) + тонкий адаптер Infrastructure/Integrations/LocalColumnSuggester (читает карточки через IKanjStore, создаёт колонки-предложения suggested=true c note, раскладывает карточки). 1:1 ответы {ok,created} / {ok:false, reason}; детерминированные причины — строками прототипа («мало карточек в «Неразобранном» (нужно от 6)», «похожие колонки уже есть или нечего сгруппировать»). Фоновый автоцикл suggest (180 с, main.py L114123) НЕ заводим — фронт запускает предложение только кнопкой, а boards_changed не слушает (Ruling 5).
  • Ruling 4 (г) — ML-обучение drag&drop. Обе таблицы этапа создаём (Ruling 1). Семантика 1:1 с leads.py/ml_client.py: каждое действие (move/trash/restore/comment) пишет строку CardMoves (журнал, _log_learning L40–44) — из него счётчик learning. Обучающие сигналы для модели — IMlClient.PushAsync(text, label, delta) (добавляется в контракт, Ruling 5 этапа 2 L76–82): перенос на доску (не inbox) → push(text, <b_…>, 1.0); корзина из канбана → push(text, "spam", 1.0); возврат из корзины → push(text, "spam", 1.0) (move_lead L177191, trash_lead L194201, restore_lead L204222). LocalMlClient.PushAsync пишет строку MlOutbox (text≤6000, label, delta, created_at); StatusAsync читает learning = count(CardMoves), outbox = count(MlOutbox); ResetAsync очищает MlOutbox (как reset_model L122; CardMoves и KV-счётчики не трогает). KV mlDecisions/aiDecisions не инкрементируются — это счётчики РЕШЕНИЙ пайплайна (этап 4), на этапе 3 всегда 0. Обоснование: без таблиц нельзя 1:1 держать learning/outbox и семантику reset.
  • Ruling 5 (д) — SSE. GET /api/events (text/event-stream, Cache-Control: no-cache, X-Accel-Buffering: no; ping каждые 15 с; без сессии — 401). Брокер — singleton SseBroker в Deal.Api: per-tenant канал по TenantId (тенант сессии при подписке; публикация вне tenant-запроса не падает), очередь подписчика ≤200 с вытеснением старых (прототип sse.py). События этапа 3 — только те, что фронт реально слушает (api.js L62104) и которые в этапе возникают: new_lead (полный объект карточки §4.1; шлёт demo simulate-lead) и toast {text, icon} (автоархив/очистки, demo, ИИ-предложения). boards_changed/pipeline_stats/leads_reclassified (недостижимы у фронта, api-map L43) и reminder_due/system_status (этапы 5/6) НЕ публикуем. Публикации делают ТОЛЬКО эндпоинты Api после вызова сервисов модуля — модуль Kanban остаётся чистым.
  • Ruling 6 (е) — поиск/FTS. GET /api/search?q= в этапе 3 ищет по карточкам LIKE-дополнением (leads.py search L509551: title/summary/contact/source_msg, col != 'taken', ORDER BY received_at DESC, limit 12; q<2 символов → {leads:[], messages:[]}) без FTS-снимка; messages: [] (api-map п.3 L393 разрешает). POST /admin/fts/rebuild — контракт-заглушка {ok:true, ready:true} (реального tsvector-индекса нет; кнопка Settings «Пересобрать индекс» получает ожидаемый ok). Полноценный FTS (карточки+отсев) — этап 4.
  • Ruling 7 (ж) — пересчёт конверсий. Владелец — модуль Kanban (ConversionRecomputer): читает conversionOn/targetCurrency через ISettingsStore, курсы — из ключа ratesCache (SettingsKeys.RatesCache), USDT=USD (L8691), обновляет ConvFrom/ConvTo/ConvCur у карточек с budgetCur != '' и col NOT IN ('archive','trash','taken') (L106–130). Триггеры — через порт модуля Settings IRatesChangedListener (объявляется в Settings, реализует ConversionRecomputer, регистрация в AddKanbanModule): (1) RatesService.RefreshAsync — после успешной записи кэша (покрывает и фоновый RatesRefreshScheduler, как rates.py refresh_rates L6274); (2) PATCH /settings — если в теле присутствовали targetCurrency/conversionOn (синхронно, settings_routes.py L186–192). Первичный пересчёт «при поступлении» (бюджет → целевая валюта, ai.py budget_to_target L342352) — чистый BudgetNormalizer (используется демо-путём и этапом 4).
  • Ruling 8 (з) — архив/корзина: тик и фоновый цикл. Чистый StorageTickService (модуль) повторяет tick_storage (leads.py L454493): автоархив (autoArchive, archiveAfterDays 1..30, карточки досок+inbox по ReceivedAt старше срока → col=archive, isNew=false, ArchivedAt=now); очистка архива (ArchivedAt старше archiveClearDays, дефолт 90); очистка корзины (ReceivedAt старше trashClearDays, дефолт 7); возврат {archived, purgedArchive, purgedTrash, purgedRejected:0}. POST /api/admin/tick = тик текущего тенанта + {storage, reminders: [], pipeline: {}, queue: 0} (reminders/pipeline — этапы 5/4; фронт в tickAuto L1855–1863 читает только storage) + SSE-toast статистики (notify_tick_stats L496504; тексты 1:1 «Автоархив: N карточек»/«Архив очищен: N (90 дн.)»/«Корзина очищена: N (7 дн.)», иконки clock/trash). Фоновый цикл — StorageTickScheduler (Api, IHostedService): каждые 30 с обходит все тенанты системного репозитория, на каждый — собственный scope с ITenantContext (паттерн TenantBootstrapService + guard RatesRefreshScheduler); аналог _storage_loop main.py L4353.
  • Ruling 9 — служебные точки фронта (boot). boot() фронта (store.js L571581) требует 200 от девяти групп сразу; до этапов 5/6 недостающие GET /api/projects и GET /api/tg/status даём заглушками: /projects{items: []} (проектные карточки — этап 5), /tg/status → форма §4.9 {phase:"idle", connected:false, listener:false, account:"", monitored:0, keysSet:false, error:null, qrUrl:null} (telegram — этап 6). Без них фронт на 404 разлогинивается (catch boot).
  • Ruling 10 — форматы/маршрутизация/colState. Времена наружу — epoch-ms; человеческая метка time («только что»/«N мин»/«N ч»/«N дн», human_age L528–537) вычисляется на лету от ReceivedAt (колонку time_label не храним; расхождение — только для demo age-lead). Статические сегменты регистрируются до /leads/{lead_id} (api-map L19). colState — KV colState (SettingsKeys.ColState): GET /columns/state → весь объект; PATCH /columns/{id}/state → merge + ответ одной колонки (L141–149); свёрнутость/ширина ДОСКИ — поля Boards (PATCH /boards/{id} принимает collapsed/width, ответ {id} — quirk L400/п.10). Создание доски: pos = MAX+1, цвет PALETTE[pos % 8], width='md', visibleFields ["budget","stack","contacts"] (L74–104). Сортировка — ReceivedAt DESC. Удаление карточки навсегда = Cards + LeadComments (cascade), CardMoves/MlOutbox не трогаем (_hard_delete L225234).
  • Ruling 11 — границы и согласованные заглушки. В этап 3 входят эндпоинты: доски (5), состояние колонок (2), карточки 12 из 13 (без /leads/{id}/seen — фронт не вызывает, api-map п.9 L399), /search, /admin/tick, /admin/fts/rebuild, /ai/suggest-columns, /ai/suggest-keywords, /demo/simulate-lead, /demo/age-lead (флаг DEAL_DEMO=1, иначе 404 «Демо-режим отключён»), /events, boot-заглушки (Ruling 9). POST /leads/reclassify — заглушка всегда {started:false, busy:false, attempted:0, reason:"ИИ недоступен — переклассификация требует сервиса ИИ"} (форма ветки leads.py L424; реальная классификация — этапы 4/6). НЕ реализуем: admin/wipe, admin/clear-cards, admin/pump-gate, ml/learn, ml/flush, meta/constants, leads/{id}/seen (api-map п.9). За пределами этапа: pipeline/очередь/отсев (этап 4), projects/напоминания/файлы и reminder_due (этап 5), реальные ai/telegram/ml и discovery (этап 6), оператор/инвайты/лимиты (этап 7); ml/candidates и ml/apply остаются как в этапе 2.
  • Ruling 12 — DI и зависимости. AddKanbanModule() (модуль) регистрирует сервисы/IRatesChangedListener; AddDealPersistence() дополнительно — IKanjStore → KanbanStore; AddDealIntegrations()IColumnSuggester → LocalColumnSuggester; IMlClient уже scoped. Порядок вызовов в Program.cs — как в этапе 2, с добавлением map-групп этапа. Новые HTTP-клиенты не нужны. Id-генерация: короткие префиксные id (l_/b_/cm_/lm_/mle_ + случайный hex, прототип store.uid) — утилита в модуле Kanban (не GUID: прототип и фронт требуют коротких ключей в JSON).

Задачи

Сокращения путей: K= src/core/Deal.Modules.Kanban/, I= src/core/Deal.Infrastructure/, A= src/core/Deal.Api/, C= src/core/Deal.Contracts/, S= src/core/Deal.Modules.Settings/, T= src/core/tests/Deal.Tests.Unit/. Отчёты — task-N-report.md в .superpowers/sdd/deal-stage3-kanban/.

Task 1: Миграция TenantKanban — таблицы Boards/Cards/LeadComments/CardMoves/MlOutbox

Files:

  • Create: I/Persistence/Entities/{BoardEntity,CardEntity,LeadCommentEntity,CardMoveEntity, MlOutboxItemEntity}.cs (поля Ruling 1; DateTimeOffset для времён; text для JSON-полей и SourceMsg).
  • Create: I/Persistence/{BoardConfiguration,CardConfiguration,LeadCommentConfiguration, CardMoveConfiguration,MlOutboxItemConfiguration}.cs (имена таблиц/индексы Ruling 1; Col max 200; FK LeadComments→Cards cascade).
  • Modify: I/Persistence/TenantDbContext.cs — DbSet'ы и ApplyConfiguration.
  • EF: миграция TenantKanban для TenantDbContext (как InitialTenant: dotnet ef migrations add TenantKanban --context TenantDbContext --output-dir Migrations/TenantDb --project src/core/Deal.Infrastructure --startup-project src/core/Deal.Api); старт Api применяет её к схеме дефолтного тенанта (TenantBootstrapService/провижинер).

Источники: эталон: I/Persistence/Entities/TenantSettingEntity.cs + I/Persistence/TenantSettingConfiguration.cs + миграции I/Migrations/TenantDb/; Ruling 1.

Acceptance: build 0/0; dotnet test MarkerTests PASS; psql (SET search_path TO tenant_00000000000000000000000000000001;): таблицы Boards/Cards/LeadComments/CardMoves/MlOutbox созданы, PK, индексы IX_Cards_Col_ReceivedAt, IX_Cards_Col_IsNew, IX_Boards_Suggested_Position, IX_LeadComments_CardId; __TenantMigrationsHistory содержит TenantKanban. Отчёт: task-1-report.md.

Task 2: Модуль Kanban — DTO, порт IKanjStore, реестр

Files:

  • Create: K/Application/Models/BoardDto.cs (§4.2 L261277), BoardRulesDto.cs (+BudgetRangeDto.cs), BoardPatchDto.cs, CardDto.cs (§4.1 L230254; ReceivedAtMs наружу int64), CardBudgetDto.cs, CardContactDto.cs, CardChannelDto.cs, CardCommentDto.cs (id/by/text/time), MatchHitDto.cs (label/term/word?), CardCountsDto.cs, CardsQuery.cs (col-фильтр), CardSnapshot.cs (сырая запись для создания карточки — демо/этап 4), StorageTickStatsDto.cs.
  • Create: K/Application/IKanjStore.cs — порт: Boards (List/Get/Create/Update/Delete→moved/Reorder); Cards (List(col?), Get, Add(CardSnapshot), UpdateColumn, UpdateSeen(id|col|all), DeleteForever, ClearCol(col)→count, CountsByCol); Comments (List/Add); CardMoves (Add/Count); StorageTick (ListArchiveCandidates/ListTrashCandidates/Purge); Conversion (ListForConversion); Suggest (ListInboxWithSource).
  • Create: K/Application/KanbanModuleRegistrar.csAddKanbanModule(): scoped BoardsService, CardsService, StorageTickService, ConversionRecomputer + AddScoped<IRatesChangedListener, ConversionRecomputer>() (Ruling 7). Modify: K/Deal.Modules.Kanban.csproj — ProjectReference на Deal.Modules.Settings и Deal.Contracts.

Источники: Rulings 12, 7; api-map §4.1/§4.2; leads.py (структуры); pipeline.py lead_to_dict L540586.

Acceptance: build 0/0 (модуль собирается, DTO — record'ы c camelCase при сериализации, проверка Markers: маркер Kanban в MarkerTests). Отчёт: task-2-report.md.

Task 3: Чистые правила колонок — ColumnRules + BudgetParser + unit-тесты

Files:

  • Create: K/Application/ColumnRules/ContentNormalizer.cs (ссылки/markdown, L4754), AmountParser.cs (extract_amounts L93144: «к/К», символы/слова валют, «от…до»/«до…»/«A–B», «$1 200»), GradeAliases.cs (L2027), ColumnMatcher.cs (match/score/has_active_rules L176227, L322338), ColumnExclusions.cs (excluded/is_excluded L230248), MatchHitBuilder.cs (hits L271–296, метки «Направление»/«Слова»/«Стек»/«Грейд/уровень»/«Бюджет», word для грейдов), RulesDescriber.cs (describe L341368 — для note), BudgetInRange.cs (конвертация валюты при сравнении — чистый интерфейс курсов).
  • Create: K/Application/BudgetNormalizer.cs — clean_budget (ai.py L316326: одна сумма → from=to, «до X» → from null; from=0 → null) + conv-поля «при поступлении» (budget_to_target L342352: conversionOn/targetCurrency, курсы через интерфейс курсов).
  • Test: T/ColumnRulesTests.cs, T/AmountParserTests.cs, T/BudgetNormalizerTests.cs (кейсы из правил прототипа: alias «mid»→middle, исключение veto, budget-диапазон с конвертацией USDT=USD, «2к», «от 0 до 100» и т.п.).

Источники: rules.py целиком (L15368), ai.py L316352; BoardRulesDialog.vue (поля правил).

Acceptance: dotnet test новых тестов PASS; build 0/0. Отчёт: task-3-report.md.

Task 4: EF-адаптер KanbanStore + DI

Files:

  • Create: I/Persistence/Repositories/KanbanStore.cs — реализация IKanjStore на TenantDbContext (AsNoTracking для чтения; JSON-поля сериализует/читает модуль — порт оперирует DTO, маппинг вручную, эталон SettingsStore.cs). Хранимые id: PrefixGenerator в модуле (Ruling 12) передаёт готовые id.
  • Modify: I/ServiceCollectionExtensions.csAddScoped<IKanjStore, KanbanStore>().
  • Modify: A/Program.csAddKanbanModule().

Источники: SettingsStore.cs (эталон), Ruling 1/12.

Acceptance: build 0/0; psql+curl-проверка пустых чтений (GET /boards → [], GET /leads → {items:[]}, counts → 0) после Task 8-map (порядок: T4 затем T8). Отчёт: task-4-report.md.

Task 5: IMlClient.PushAsync + LocalMlClient (outbox/learning/status/reset)

Files:

  • Modify: C/Integrations/IMlClient.cs — добавить PushAsync(string text, string label, double delta, CancellationToken) (ml_client.push L4049). DTO-метки: label = id доски | "spam" | "t:hire" | "t:order" (полные — этап 4/6).
  • Modify: I/Integrations/LocalMlClient.cs — ctor + TenantDbContext (таблицы CardMoves/MlOutbox): PushAsync → INSERT MlOutbox (id mle_, text[:6000], label, delta, CreatedAt=UtcNow); StatusAsync: learning = count(CardMoves), outbox = count(MlOutbox), ml/ai — KV (как сейчас); модель не готова (ready=false) до этапа 4; ResetAsync — удалить строки MlOutbox (прототип reset_model L122); predict — не меняется.
  • Test: T/LocalMlClientTests.cs — дополнить PushAsync (пишет outbox, счётчики learning/outbox в status, reset чистит только outbox). Чтобы тест оставался unit — подсчёты вынести за чистый порт IMlLearningCounters (модуль Kanban); финальное решение за исполнителем, но LocalMlClient и тесты должны остаться unit-чистыми.

Источники: ml_client.py (L4049, L110124, L138150), Ruling 4, этап 2 Task 9.

Acceptance: build 0/0; тесты PASS; curl: login → GET /api/ml/statusstats.learning:0, stats.outbox:0; после переноса карточки (Task 7/8) — learning:1, outbox:1 (если колонка не inbox); POST /api/ml/reset → outbox:0, learning не меняется. Отчёт: task-5-report.md.

Task 6: BoardsService — колонки-доски и colState + unit-тесты

Files:

  • Create: K/Application/BoardsService.cs — list_boards L4967 (ORDER BY suggested, pos; дефолты collapsed из поля), create_board L74104 (цвет/позиция/ширина/visibleFields; name strip() or «Новая колонка»), patch_board L107121 (404-семантика через результат; allowed: name/description/color/width/collapsed/prompt/keywords/visibleFields/suggested/rules/note), delete_board L124130 (карточки → inbox isNew, prevCol=inbox; вернуть moved), reorder_boards L133135, get/set_col_state L138146 (KV colState через ISettingsStore; словарь JSON).
  • Test: T/BoardsServiceTests.cs (fake IKanjStore): создание (pos/цвет/width/visibleFields), патч (JSON-поля), удаление (moved→inbox), colState merge/значения.

Источники: leads.py L49146; api-map §3.2 доски L6670, §4.2; Rulings 1/10; constants.py PALETTE L1216.

Acceptance: dotnet test PASS; build 0/0. Отчёт: task-6-report.md.

Task 7: CardsService — карточки: чтение, переносы, архив/корзина, комментарии, counts

Files:

  • Create: K/Application/CardsService.cs:
    • list_leads/get_lead (L151160): маппинг CardDto (receivedAt ms, time от ReceivedAt, budget, converted, contacts fallback qualify_contact-проверка, ch, comments из LeadComments, matchHits);
    • move_lead (L177191): валидация to ∈ inbox доски (иначе 400 «Переносить можно только на доски или в «Неразобранное»»), _move L163174 (matchHits пересчёт через ColumnRules для досок), журнал CardMoves(action=move) + PushAsync (текст = sourceMsg или title) при to≠inbox;
    • trash_lead (L194201): журнал(action=trash) + Push spam 1.0 (кроме карточек уже в archive/trash);
    • restore_lead (L204222): назад в prevCol (валидный), isNew=true, archivedAt=null, matchHits, журнал(action=restore); возврат из корзины — Push spam 1.0;
    • delete_forever (L225234), clear_col (L237247: только trash|archive, 400 «Очищать можно только корзину или архив», вернуть cleared);
    • mark_seen (L250256: id|col|all); add_comment (L259265: 400 «Пустой комментарий», LeadComments вставка, журнал(action=comment));
    • counts (L268279): по Cards (col + isNew) + learning/ml/ai из IMlClient.StatusAsync;
    • search (L509551, LIKE-вариант) — вызывается эндпоинтом напрямую или через сервис (см. Task 8).
  • Create: K/Application/CardMapper.cs (CardEntity/сырые строки → CardDto; чистая функция; human_age L528537), K/Application/PrefixId.cs (Ruling 12).
  • Test: T/CardsServiceTests.cs (fake IKanjStore + fake IMlClient): move с правилами (matchHits), move на неизвестную доску → ошибка 400-текста, trash/restore (журнал+push), clear_col 400 на доске, mark_seen, комментарий пустой/валидный, counts-форма, search лимит/мин-длина.

Источники: leads.py L151279, L509551; pipeline.py lead_to_dict L540586; rules.py hits_for_board; api-map §3.2 лиды L8395, §4.1; Rulings 2/4/10.

Acceptance: dotnet test PASS; build 0/0. Отчёт: task-7-report.md.

Task 8: Эндпоинты досок/колонок/карточек/поиска + DI + curl-приёмка

Files:

  • Create: A/Endpoints/BoardsEndpoints.cs (MapBoardsEndpoints): GET /api/boards (голый массив!), POST /api/boards, PATCH /api/boards/{boardId} (404 «Доска не найдена»), DELETE /api/boards/{id}, POST /api/boards/reorder, GET /api/columns/state, PATCH /api/columns/{colId}/state.
  • Create: A/Endpoints/LeadsEndpoints.cs (MapLeadsEndpoints): GET /api/leads?col= (400 «Неизвестная колонка»), GET /api/leads/counts, GET /api/leads/{leadId} (404 «Карточка не найдена»), POST /api/leads/mark-all-seen, POST /api/leads/mark-col-seen {col}, POST /api/leads/{id}/move {to} (400 текст move_lead) → обновлённый CardDto, POST /api/leads/{id}/trash, POST /api/leads/{id}/restore{ok, col}, DELETE /api/leads/{id}, POST /api/leads/clear-col {col: trash|archive} → {ok, cleared}, POST /api/leads/{id}/comments {text} → {comments}, POST /api/leads/reclassify (заглушка Ruling 11), GET /api/search?q= (Ruling 6). ⚠ Статические сегменты регистрируются до {leadId} (Ruling 10). Сессия — HasUser/GetCurrentUser, 401 AuthHelpers.UnauthorizedDetail (эталон MlEndpoints).
  • Create: A/Endpoints/RequestModels/*BoardCreateRequest, BoardPatchRequest, OrderBody, ColStateBody, MoveBody, CommentBody, MarkColBody, ClearColBody, ReclassifyBody (1 тип = 1 файл).
  • Modify: A/Program.csMapBoardsEndpoints(), MapLeadsEndpoints().
  • Modify: A/Deal.Api.csproj — ProjectReference Deal.Modules.Kanban.

Контракт: api-map §3.2 L66101; ответы/детали — Task 6/7/Rulings. GET /boards — без {items}.

Acceptance (curl, admin/admin): пустые boards/leads/counts; создание доски POST {name:"Middle Python", keywords:["python"], rules:{mode:"all", stack:["python"]}} → {id:"b_…"}; PATCH width/collapsed; reorder; GET /columns/state {} и PATCH collapsed → {"collapsed":true}; затем Task 13 демо-карточки и полный цикл карточек (move/trash/restore/clear-col/комментарий/404-тексты). Отчёт: task-8-report.md.

Task 9: SSE-брокер + GET /api/events + boot-заглушки /projects и /tg/status

Files:

  • Create: A/Events/SseBroker.cs (singleton; Ruling 5), A/Events/SseEvent.cs (record: тип+JSON), A/Endpoints/EventsEndpoint.cs (MapEventsEndpoint): GET /api/events — авторизация (401), заголовки no-cache/X-Accel-Buffering, ping каждые 15 с, подписка на канал тенанта (ITenantContext), отписка при завершении.
  • Create: A/Endpoints/BootStubEndpoints.cs (MapBootStubEndpoints): GET /api/projects{items: []}; GET /api/tg/status → idle-форма Ruling 9 (комментарий: этапы 5/6).
  • Modify: A/Program.cs — singleton SseBroker, map-группы.

Источники: sse.py целиком; api.js openEvents L62104; api-map §2, L43; store.js boot L571581; §4.9 L359.

Acceptance: build 0/0; curl: curl -N на /api/events без куки → 401; с кукой — поток открыт, ping : ~15 с; GET /api/projects{"items":[]}, GET /api/tg/status — все поля §4.9. (Публикация событий проверяется в Tasks 10/13/14.) Отчёт: task-9-report.md.

Task 10: StorageTickService + POST /api/admin/tick + /admin/fts/rebuild + SSE-toast

Files:

  • Create: K/Application/StorageTickService.cs — Ruling 8 (архив/очистки через IKanjStore; кандидаты — по ReceivedAt/ArchivedAt с настройками из ISettingsStore; удаление = DeleteForever).
  • Create: A/Endpoints/StorageEndpoints.cs (MapStorageEndpoints): POST /api/admin/tick → StorageTickService.TickAsync + {storage, reminders:[], pipeline:{}, queue:0} + публикация SSE-toast по статистике (тексты/иконки 1:1, Ruling 8) через SseBroker; POST /api/admin/fts/rebuild{ok:true, ready:true} (Ruling 6).
  • Modify: A/Program.cs — map.

Источники: leads.py tick_storage L454493 + notify_tick_stats L496504; dashboard_routes.py L327337 (admin_tick), L261264 (fts_rebuild); api-map L103112; store.js tickAuto L18551863, rebuildFts L18841889; Rulings 5/6/8.

Acceptance: dotnet test (если юнит для StorageTickService — на fake store); curl: с демо-карточкой на доске PATCH settings archiveAfterDays=1 → POST /api/admin/tick (после demo/age-lead из Task 13) → storage.archived=1, SSE-toast «Автоархив…»; clear-col/trash → purged-тосты; fts/rebuild → ok:true. Отчёт: task-10-report.md.

Task 11: StorageTickScheduler — фоновый цикл правил хранения по тенантам

Files:

  • Create: A/StorageTickScheduler.cs — IHostedService: Timer 30 с; каждое срабатывание в собственном scope: список тенантов (ITenantRepository/системный контекст), на каждый тенант — новый scope, ITenantContext set (эталон TenantBootstrapService), StorageTickService.TickAsync + SSE-toast через SseBroker (публикация в канал тенанта; без подписчиков — no-op). In-flight guard (Interlocked) и try/catch — как RatesRefreshScheduler.
  • Modify: A/Program.csAddHostedService<StorageTickScheduler>().

Источники: main.py _storage_loop L4353; A/Hosting/TenantBootstrapService.cs, A/RatesRefreshScheduler.cs (эталоны); Ruling 8.

Acceptance: build 0/0; запуск Api — в логе нет ошибок цикла; с демо-возрастом карточки архив срабатывает и без ручного tick (в пределах ~40 с). Отчёт: task-11-report.md.

Task 12: Пересчёт конверсий — ConversionRecomputer + IRatesChangedListener

Files:

  • Create: S/Application/IRatesChangedListener.cs — порт модуля Settings: Task OnRatesChangedAsync(bool fullRecompute, CancellationToken ct).
  • Modify: S/Application/RatesService.cs — после успешной записи кэша (mock или cbr) вызвать всех IRatesChangedListener (список в ctor, пустой — no-op). Modify: S/Application/SettingsService.cs — в PATCH, если в теле присутствовали targetCurrency или conversionOn, вызвать listener'ов (Ruling 7).
  • Create: K/Application/ConversionRecomputer.cs (scoped; IRatesChangedListener): полный пересчёт — карточки из IKanjStore.ListCardsForConversion; курс из ratesCache (JSON {rates,…}, USDT=USD); conversionOn=false → 0; обновление ConvFrom/ConvTo/ConvCur через KanbanStore.
  • Create: K/Application/RateTable.cs — чистый парсинг ratesCache ({rates,…}, USDT=USD) + конвертер; RatesService-часть Settings не трогаем.
  • Modify: K/Application/KanbanModuleRegistrar.cs — регистрация (Ruling 12).
  • Test: T/ConversionRecomputerTests.cs (fake settings-store + fake kanban-store: mock-курсы, USDT=USD, conversionOn=false, col archive исключён, targetCurrency смена).

Источники: rates.py recompute_conversions L106130, refresh L6274, _resolve_rate L8691; settings_routes.py L186192; Ruling 7.

Acceptance: тесты PASS; curl-сценарий: demo-карточка с бюджетом USD (Task 13) → conv в RUB; PATCH settings {targetCurrency:"USD"} → conv пересчитан; PATCH {rateSource:"mock"} + POST /rates/refresh → conv обновлён; карточка в архиве — conv не меняется (psql-проверка). Отчёт: task-12-report.md.

Task 13: Демо-карточки — POST /demo/simulate-lead, /demo/age-lead (флаг DEAL_DEMO)

Files:

  • Create: K/Application/DemoLeadFactory.cs — демо-пул 1:1 с dashboard_routes.py L7789 + создание карточки: нормализация бюджета (BudgetNormalizer), контакты (build_contacts/primary_contact — достаточно примитивной версии для заданных полей), matchHits=[] для inbox, prevCol=inbox, sourceMsg/dialogId (demo_channel)/ch-поля, isNew=true. Добавление через IKanjStore.Add.
  • Create: A/Endpoints/DemoEndpoints.cs (MapDemoEndpoints): POST /api/demo/simulate-lead — флаг (appsettings/DEAL_DEMO), иначе 404 «Демо-режим отключён»; создание карточки → CardDto; SseBroker: new_lead (полная карточка) + toast «Демо: новый лид» (sparkles); POST /api/demo/age-lead — состарить самую старую карточку досок (receivedAt = now (archiveAfterDays+1) дней; 400 «Нет карточек на досках для демо»), затем тик StorageTickService и toast при архивировании.
  • Modify: A/appsettings*.json — секция Demo: { Enabled: false } (Development — true).
  • Modify: A/Program.cs — map + DI.

Источники: dashboard_routes.py L287324; pipeline.py _store_lead L433514; devtests backend/devtests/{boot_test,e2e_test}.py (эталон сценариев приёмки); api-map L114.

Acceptance: curl с DEAL_DEMO=1: simulate-lead → полный объект §4.1 (id l_…, col inbox, title, summary, stack, budget, contacts, ch, receivedAt); повторные вызовы наполняют inbox; age-lead → 200; GET /api/leads?col=inbox сортировка DESC. Без флага — 404. Отчёт: task-13-report.md.

Task 14: ИИ-предложения — порт IColumnSuggester + эвристика + /api/ai/suggest-columns|keywords

Files:

  • Create: C/Integrations/IColumnSuggester.cs, C/Integrations/Models/ColumnSuggestionDto.cs (Ok/Created/Reason/Cooldown/Keywords) — Ruling 3.
  • Create: K/Application/SuggestHeuristics.cs — чистое ядро: частотные слова-темы по текстам (≥3 букв, lowercase, минус стоп-слова), темы ≥2 карточек (MAX_TEXT=12, MIN_INBOX=6, ≤4 колонок), похожесть с существующими досками (L55–61), правила {mode:"any", keywords:[…]} и note-обоснования («Эвристика (этап 3): …N карточек; реальные предложения ИИ — этап 6»); для suggest-keywords — частотные маркеры (≤60, ≤40 симв.).
  • Create: I/Integrations/LocalColumnSuggester.cs — реализует IColumnSuggester: читает inbox через IKanjStore, вызывает SuggestHeuristics, создаёт доски suggested=true (note/description) и раскладывает карточки (isNew=true), возвращает created; причины — детерминированные строки Ruling 3. suggest-keywords: <3 карточек → «мало карточек — сначала накопите заявки (нужно хотя бы 3)».
  • Create: A/Endpoints/AiSuggestEndpoints.cs (MapAiSuggestEndpoints): POST /api/ai/suggest-columns → результат; при ok:true — SSE-toast «ИИ предложил колонок: N — откройте и решите» (sparkles) 1:1 (boards_changed не шлём — Ruling 5); POST /api/ai/suggest-keywords{ok, keywords} | {ok:false, reason}.
  • Modify: I/ServiceCollectionExtensions.csAddScoped<IColumnSuggester, LocalColumnSuggester>(); A/Program.cs — map.

Источники: suggest.py целиком (константы L4852, suggest L76163, keywords L166193, _make_note/_store_suggested/_assign_ids/_rollback L196248); api-map L120121; store.js suggestColumns L10971113; Rulings 3/5.

Acceptance: тесты на SuggestHeuristics (детерминированность: одинаковый вход → одинаковый выход); curl: 6+ демо-карточек с общей темой (например, повторяющиеся simulate с «Python») → POST /api/ai/suggest-columns → {ok:true, created≥1}; GET /api/boards — доска suggested=true с карточками; PATCH suggested:false → принята; «мало карточек» на пустом inbox → {ok:false, reason}. Отчёт: task-14-report.md.

Task 15: Финал этапа — интеграция и сквозная приёмка

  • scripts/build.sh/scripts/test.sh — успешны; dotnet build Deal.sln 0/0; все unit-тесты PASS (175 этапа 2 + новые).
  • Сквозной curl-сценарий канбана: login → boot-группы (boards/leads/counts/columns/state/projects/ tg/status/settings/rates/ml) → demo simulate-lead ×N → создание доски с правилами → move карточки (matchHits в ответе) → learning/ml-счётчики (status) → mark-col-seen/mark-all-seen → комментарий → trash → restore → clear-col → suggest-columns (эвристика, ok/created) → age-lead + admin/tick (автоархив, SSE-toast) → PATCH targetCurrency + rates/refresh (пересчёт conv, psql) → search?q= → admin/fts/rebuild → 401-проверки без куки.
  • psql-проверка схемы дефолтного тенанта: строки Boards/Cards/LeadComments/CardMoves/MlOutbox, PascalCase-колонки; matchHits/конвертации корректны; colState в settings.
  • Обновить docs/technical/Техническая-документация-Дейл.md: раздел «Дашборд/канбан» (эндпоинты, таблицы этапа, SSE-события, StorageTickScheduler, демо-режим DEAL_DEMO, пересчёт конверсий).
  • Отчёт task-15-report.md + финальная строка в progress.md; roadmap-флаг «этап 3 выполнен».

Self-Review

  1. Spec coverage: ТЗ §5 «Карточка» (L112121) — Task 7/13 (поля, «О заявке»-summary — приходит структурой из pipeline/demo; блоки Компания→Условия — формат summary, композиция — этап 4); ТЗ §6 (L121135) — Tasks 114 (колонки/фильтры/отрицательные — Task 3/6; «почему в колонке» — Task 7; свежие сверху/виджеты/ширина/colState — Task 6/8; drag&drop+ML — Task 7; ИИ-предложения — Task 14; архив/корзина — Tasks 10/11); api-map §3.2 (L60121) — Tasks 8/10/13/14; §2 SSE — Task 9; §4.1/4.2 — Tasks 2/6/7; роадмап-этап 3 — все задачи; рекомендации этапа 2 (Ruling 5 — PushAsync, Ruling 6 — recompute_conversions) — Tasks 5/12; boot-требование фронта — Ruling 9/Task 9.
  2. Placeholder scan: заглушки — только согласованные: LocalMlClient (модель не готова до этапа 4, outbox/learning живые), LocalColumnSuggester (эвристика до ИИ-этапа 6), reclassify (форма-ветка, Ruling 11), boot-стабы /projects и /tg/status (этапы 5/6), fts/rebuild no-op (этап 4), демо-пул (как прототип). Референсы на строки файлов прототипа — точные; FIXME/TODO нет.
  3. Type consistency: один модуль Kanban владеет карточками/колонками; настройки (архив/colState/ счётчики/курсы) — через ISettingsStore модуля Settings (общий каталог ключей не дублируется); IMlClient-контракт един (панель этапа 2 + обучение этапа 3 + предсказания этапа 4); IColumnSuggester в Contracts — подмена реализации на ИИ этапа 6 без правки эндпоинтов; новые сущности/конфиги/миграция следуют конвенции TenantSettingEntity; сущности Settings не меняются; время жизни — scoped/singleton как в этапах 1–2.
  4. Вне scope этапа 3: Projects (этап 5; отдаём boot-заглушку), Pipeline/очередь/отсев/FTS-индекс/ дедуп и pipeline_stats (этап 4; reclassify — заглушка), Discovery (этап 6), реальные ai/telegram/ml сервисы и /api/tg/* (этап 6; tg/status — boot-заглушка), «Отклонено» (проектный канбан, этап 5), reminder_due (этап 5), оператор/инвайты/лимиты/аудит (этап 7), события boards_changed/ leads_reclassified (недостижимы у фронта — не публикуем), админ-эндпоинты wipe/clear-cards/pump-gate (фронт не вызывает), ml/learn|flush, /leads/{id}/seen.