Table of Contents
- Дейл (Deal) — Этап 3: Kanban (дашборд): колонки, карточки, архив/корзина Implementation Plan
- Global Constraints
- Зафиксированные решения (Rulings этапа)
- Задачи
- Task 1: Миграция TenantKanban — таблицы Boards/Cards/LeadComments/CardMoves/MlOutbox
- Task 2: Модуль Kanban — DTO, порт IKanjStore, реестр
- Task 3: Чистые правила колонок — ColumnRules + BudgetParser + unit-тесты
- Task 4: EF-адаптер KanbanStore + DI
- Task 5: IMlClient.PushAsync + LocalMlClient (outbox/learning/status/reset)
- Task 6: BoardsService — колонки-доски и colState + unit-тесты
- Task 7: CardsService — карточки: чтение, переносы, архив/корзина, комментарии, counts
- Task 8: Эндпоинты досок/колонок/карточек/поиска + DI + curl-приёмка
- Task 9: SSE-брокер + GET /api/events + boot-заглушки /projects и /tg/status
- Task 10: StorageTickService + POST /api/admin/tick + /admin/fts/rebuild + SSE-toast
- Task 11: StorageTickScheduler — фоновый цикл правил хранения по тенантам
- Task 12: Пересчёт конверсий — ConversionRecomputer + IRatesChangedListener
- Task 13: Демо-карточки — POST /demo/simulate-lead, /demo/age-lead (флаг DEAL_DEMO)
- Task 14: ИИ-предложения — порт IColumnSuggester + эвристика + /api/ai/suggest-columns|keywords
- Task 15: Финал этапа — интеграция и сквозная приёмка
- Self-Review
Перенесено из репозитория (
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 (L60–121), §2 SSE (L27–44), правила (L7–24, п.9 «экономия» L399,
кривые места L390–400); §4.1 карточка (L228–257), §4.2 доска (L259–278), §4.6 colState (L333);
docs/spec/ТЗ-дейл-новая-архитектура.md §5 «Карточка» (L112–121), §6 «Дашборд (канбан)» (L121–135);
roadmap (этап 3, L46–53); референс-семантика: backend/app/routers/dashboard_routes.py целиком,
backend/app/services/leads.py, rules.py, suggest.py, rates.py (L62–74, L106–130),
backend/app/services/ml_client.py, backend/app/services/pipeline.py (L433–514, L540–586),
backend/app/sse.py, backend/app/main.py (L43–53 фоновые циклы), backend/app/constants.py
(PALETTE L12–16, DAY_MS L249–254); фронт: src/frontend/src/store.js (boot L565–628 — какие группы
обязаны отвечать 200; SSE L650–688; действия лидов L833–975; доски L977–1183; поиск L1185–1206;
tickAuto L1855–1863, rebuildFts L1884–1889; colMeta/orderedCols L188–223), src/frontend/src/api.js
(openEvents L62–104 — слушает только 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-Postgresdeal-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; журнал действий/обучения, idlm_),MlOutbox(= ml_outbox; очередь обучающих сигналов, idmle_). 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 L163–174), возвратrestore(L204–222), назначение при создании (этап 4/демо). Вычисление — чистые функцииColumnRules(переносrules.py:match_textL176–209,score_textL212–227,excluded_terms/is_excludedL230–248,board_acceptsL251–268,hitsL271–296,hits_for_boardL311–319,has_active_rulesL322–338,describeL341–368,extract_amountsL93–144 с grade-алиасами L20–27 иcontent_textL51–54). Для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=truecnote, раскладывает карточки). 1:1 ответы{ok,created}/{ok:false, reason}; детерминированные причины — строками прототипа («мало карточек в «Неразобранном» (нужно от 6)», «похожие колонки уже есть или нечего сгруппировать»). Фоновый автоцикл suggest (180 с,main.py L114–123) НЕ заводим — фронт запускает предложение только кнопкой, аboards_changedне слушает (Ruling 5). - Ruling 4 (г) — ML-обучение drag&drop. Обе таблицы этапа создаём (Ruling 1). Семантика 1:1
с
leads.py/ml_client.py: каждое действие (move/trash/restore/comment) пишет строкуCardMoves(журнал,_log_learningL40–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_leadL177–191,trash_leadL194–201,restore_leadL204–222).LocalMlClient.PushAsyncпишет строкуMlOutbox(text≤6000, label, delta, created_at);StatusAsyncчитаетlearning = count(CardMoves),outbox = count(MlOutbox);ResetAsyncочищаетMlOutbox(какreset_modelL122; CardMoves и KV-счётчики не трогает). KVmlDecisions/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). Брокер — singletonSseBrokerвDeal.Api: per-tenant канал поTenantId(тенант сессии при подписке; публикация вне tenant-запроса не падает), очередь подписчика ≤200 с вытеснением старых (прототипsse.py). События этапа 3 — только те, что фронт реально слушает (api.js L62–104) и которые в этапе возникают: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 searchL509–551: 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 (L86–91), обновляетConvFrom/ConvTo/ConvCurу карточек сbudgetCur != ''иcol NOT IN ('archive','trash','taken')(L106–130). Триггеры — через порт модуля SettingsIRatesChangedListener(объявляется в Settings, реализуетConversionRecomputer, регистрация вAddKanbanModule): (1)RatesService.RefreshAsync— после успешной записи кэша (покрывает и фоновый RatesRefreshScheduler, какrates.py refresh_ratesL62–74); (2) PATCH/settings— если в теле присутствовалиtargetCurrency/conversionOn(синхронно,settings_routes.pyL186–192). Первичный пересчёт «при поступлении» (бюджет → целевая валюта,ai.py budget_to_targetL342–352) — чистыйBudgetNormalizer(используется демо-путём и этапом 4). - Ruling 8 (з) — архив/корзина: тик и фоновый цикл. Чистый
StorageTickService(модуль) повторяетtick_storage(leads.py L454–493): автоархив (autoArchive,archiveAfterDays1..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; фронт вtickAutoL1855–1863 читает толькоstorage) + SSE-toast статистики (notify_tick_statsL496–504; тексты 1:1 «Автоархив: N карточек»/«Архив очищен: N (90 дн.)»/«Корзина очищена: N (7 дн.)», иконки clock/trash). Фоновый цикл —StorageTickScheduler(Api, IHostedService): каждые 30 с обходит все тенанты системного репозитория, на каждый — собственный scope сITenantContext(паттерн TenantBootstrapService + guard RatesRefreshScheduler); аналог_storage_loopmain.py L43–53. - Ruling 9 — служебные точки фронта (boot).
boot()фронта (store.js L571–581) требует 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_ageL528–537) вычисляется на лету от ReceivedAt (колонкуtime_labelне храним; расхождение — только для demo age-lead). Статические сегменты регистрируются до/leads/{lead_id}(api-map L19). colState — KVcolState(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_deleteL225–234). - 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;Colmax 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 L261–277),BoardRulesDto.cs(+BudgetRangeDto.cs),BoardPatchDto.cs,CardDto.cs(§4.1 L230–254;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.cs—AddKanbanModule(): scopedBoardsService,CardsService,StorageTickService,ConversionRecomputer+AddScoped<IRatesChangedListener, ConversionRecomputer>()(Ruling 7). Modify:K/Deal.Modules.Kanban.csproj— ProjectReference наDeal.Modules.SettingsиDeal.Contracts.
Источники: Rulings 1–2, 7; api-map §4.1/§4.2; leads.py (структуры); pipeline.py lead_to_dict
L540–586.
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, L47–54),AmountParser.cs(extract_amounts L93–144: «к/К», символы/слова валют, «от…до»/«до…»/«A–B», «$1 200»),GradeAliases.cs(L20–27),ColumnMatcher.cs(match/score/has_active_rules L176–227, L322–338),ColumnExclusions.cs(excluded/is_excluded L230–248),MatchHitBuilder.cs(hits L271–296, метки «Направление»/«Слова»/«Стек»/«Грейд/уровень»/«Бюджет»,wordдля грейдов),RulesDescriber.cs(describe L341–368 — для note),BudgetInRange.cs(конвертация валюты при сравнении — чистый интерфейс курсов). - Create:
K/Application/BudgetNormalizer.cs— clean_budget (ai.py L316–326: одна сумма → from=to, «до X» → from null; from=0 → null) + conv-поля «при поступлении» (budget_to_target L342–352: conversionOn/targetCurrency, курсы через интерфейс курсов). - Test:
T/ColumnRulesTests.cs,T/AmountParserTests.cs,T/BudgetNormalizerTests.cs(кейсы из правил прототипа: alias «mid»→middle, исключение veto, budget-диапазон с конвертацией USDT=USD, «2к», «от 0 до 100» и т.п.).
Источники: rules.py целиком (L15–368), ai.py L316–352; 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.cs—AddScoped<IKanjStore, KanbanStore>(). - Modify:
A/Program.cs—AddKanbanModule().
Источники: 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 L40–49). DTO-метки: label = id доски |"spam"|"t:hire"|"t:order"(полные — этап 4/6). - Modify:
I/Integrations/LocalMlClient.cs— ctor +TenantDbContext(таблицы CardMoves/MlOutbox): PushAsync → INSERT MlOutbox (idmle_, 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 (L40–49, L110–124, L138–150), Ruling 4, этап 2 Task 9.
Acceptance: build 0/0; тесты PASS; curl: login → GET /api/ml/status → stats.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 L49–67 (ORDER BY suggested, pos; дефолты collapsed из поля), create_board L74–104 (цвет/позиция/ширина/visibleFields; namestrip() or «Новая колонка»), patch_board L107–121 (404-семантика через результат; allowed: name/description/color/width/collapsed/prompt/keywords/visibleFields/suggested/rules/note), delete_board L124–130 (карточки → inbox isNew, prevCol=inbox; вернуть moved), reorder_boards L133–135, get/set_col_state L138–146 (KV colState через ISettingsStore; словарь JSON). - Test:
T/BoardsServiceTests.cs(fake IKanjStore): создание (pos/цвет/width/visibleFields), патч (JSON-поля), удаление (moved→inbox), colState merge/значения.
Источники: leads.py L49–146; api-map §3.2 доски L66–70, §4.2; Rulings 1/10; constants.py
PALETTE L12–16.
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 (L151–160): маппинг CardDto (receivedAt ms, time от ReceivedAt, budget,
converted, contacts fallback
qualify_contact-проверка, ch, comments из LeadComments, matchHits); - move_lead (L177–191): валидация
to ∈ inbox ∪ доски(иначе 400 «Переносить можно только на доски или в «Неразобранное»»),_moveL163–174 (matchHits пересчёт через ColumnRules для досок), журнал CardMoves(action=move) + PushAsync (текст = sourceMsg или title) при to≠inbox; - trash_lead (L194–201): журнал(action=trash) + Push spam 1.0 (кроме карточек уже в archive/trash);
- restore_lead (L204–222): назад в prevCol (валидный), isNew=true, archivedAt=null, matchHits, журнал(action=restore); возврат из корзины — Push spam −1.0;
- delete_forever (L225–234), clear_col (L237–247: только trash|archive, 400 «Очищать можно только корзину или архив», вернуть cleared);
- mark_seen (L250–256: id|col|all); add_comment (L259–265: 400 «Пустой комментарий», LeadComments вставка, журнал(action=comment));
- counts (L268–279): по Cards (col + isNew) + learning/ml/ai из IMlClient.StatusAsync;
- search (L509–551, LIKE-вариант) — вызывается эндпоинтом напрямую или через сервис (см. Task 8).
- list_leads/get_lead (L151–160): маппинг CardDto (receivedAt ms, time от ReceivedAt, budget,
converted, contacts fallback
- Create:
K/Application/CardMapper.cs(CardEntity/сырые строки → CardDto; чистая функция;human_ageL528–537),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 L151–279, L509–551; pipeline.py lead_to_dict L540–586; rules.py
hits_for_board; api-map §3.2 лиды L83–95, §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, 401AuthHelpers.UnauthorizedDetail(эталон MlEndpoints). - Create:
A/Endpoints/RequestModels/*—BoardCreateRequest,BoardPatchRequest,OrderBody,ColStateBody,MoveBody,CommentBody,MarkColBody,ClearColBody,ReclassifyBody(1 тип = 1 файл). - Modify:
A/Program.cs—MapBoardsEndpoints(),MapLeadsEndpoints(). - Modify:
A/Deal.Api.csproj— ProjectReferenceDeal.Modules.Kanban.
Контракт: api-map §3.2 L66–101; ответы/детали — 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 L62–104; api-map §2, L43; store.js boot
L571–581; §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 L454–493 + notify_tick_stats L496–504; dashboard_routes.py
L327–337 (admin_tick), L261–264 (fts_rebuild); api-map L103–112; store.js tickAuto L1855–1863,
rebuildFts L1884–1889; 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,ITenantContextset (эталон TenantBootstrapService),StorageTickService.TickAsync+ SSE-toast через SseBroker (публикация в канал тенанта; без подписчиков — no-op). In-flight guard (Interlocked) и try/catch — как RatesRefreshScheduler. - Modify:
A/Program.cs—AddHostedService<StorageTickScheduler>().
Источники: main.py _storage_loop L43–53; 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 L106–130, refresh L62–74, _resolve_rate L86–91;
settings_routes.py L186–192; 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 L77–89+ создание карточки: нормализация бюджета (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 L287–324; pipeline.py _store_lead L433–514; 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.cs—AddScoped<IColumnSuggester, LocalColumnSuggester>();A/Program.cs— map.
Источники: suggest.py целиком (константы L48–52, suggest L76–163, keywords L166–193,
_make_note/_store_suggested/_assign_ids/_rollback L196–248); api-map L120–121; store.js suggestColumns L1097–1113; 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.sln0/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
- Spec coverage: ТЗ §5 «Карточка» (L112–121) — Task 7/13 (поля, «О заявке»-summary — приходит структурой из pipeline/demo; блоки Компания→Условия — формат summary, композиция — этап 4); ТЗ §6 (L121–135) — Tasks 1–14 (колонки/фильтры/отрицательные — Task 3/6; «почему в колонке» — Task 7; свежие сверху/виджеты/ширина/colState — Task 6/8; drag&drop+ML — Task 7; ИИ-предложения — Task 14; архив/корзина — Tasks 10/11); api-map §3.2 (L60–121) — 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.
- Placeholder scan: заглушки — только согласованные:
LocalMlClient(модель не готова до этапа 4, outbox/learning живые),LocalColumnSuggester(эвристика до ИИ-этапа 6), reclassify (форма-ветка, Ruling 11), boot-стабы /projects и /tg/status (этапы 5/6), fts/rebuild no-op (этап 4), демо-пул (как прототип). Референсы на строки файлов прототипа — точные; FIXME/TODO нет. - Type consistency: один модуль Kanban владеет карточками/колонками; настройки (архив/colState/
счётчики/курсы) — через
ISettingsStoreмодуля Settings (общий каталог ключей не дублируется);IMlClient-контракт един (панель этапа 2 + обучение этапа 3 + предсказания этапа 4);IColumnSuggesterв Contracts — подмена реализации на ИИ этапа 6 без правки эндпоинтов; новые сущности/конфиги/миграция следуют конвенцииTenantSettingEntity; сущности Settings не меняются; время жизни — scoped/singleton как в этапах 1–2. - Вне 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.