Deal — единая кодовая база
ci / build-test (push) Canceled after 0s

SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/
Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue,
контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер),
Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог).

Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0,
тесты 1340/130/52/38/9 зелёные.
This commit is contained in:
Rustam Khalimov
2026-09-11 23:56:47 +03:00
commit 27c7831910
1383 changed files with 158436 additions and 0 deletions
@@ -0,0 +1,80 @@
# SDD ledger — plan: docs/superpowers/plans/2026-09-05-deal-stage3-kanban.md
Проект НЕ git: фиксация — отчёты задач и этот ledger. Ревью — по фактическим файлам.
## Todos
- Task 1: complete (review clean; миграция TenantKanban применена, 175 PASS). Отчёт: task-1-report.md. Note: сущность MlOutboxEntity (не Item) — сверить в T4/T5.
- [x] Task 1: Миграция TenantKanban
- Task 2: complete (review clean; 176 PASS; DTO/порт/реестр, модуль чист). Отчёт: task-2-report.md.
- [x] Task 2: Модуль Kanban — DTO, порт IKanjStore, реестр
- Task 3: complete (review clean; 245 PASS; ColumnRules/AmountParser/BudgetNormalizer 1:1 с rules.py/ai.py). Отчёт: task-3-report.md.
- [x] Task 3: Чистые правила колонок — ColumnRules + BudgetParser + unit-тесты
- Task 4: complete (build 0/0; 245 PASS; KanbanStore на TenantDbContext — все 25 методов порта; DI в AddDealPersistence; функциональная проверка на дефолтном тенанте + psql; схема очищена для Task 8). Отчёт: task-4-report.md.
- Task 4: complete (review clean; 245 PASS; delete board → карточки в inbox новыми — 1:1). Отчёт: task-4-report.md. Note: guard «в ту же колонку» даст T7.
- [x] Task 4: EF-адаптер KanbanStore + DI
- Task 5: complete (build 0/0; 255 PASS; PushAsync в контракте; LocalMlClient через порт IMlLearningStore (Kanban) — outbox/learning/status/reset; dev-проверка 24/24 на real Postgres). Отчёт: task-5-report.md.
- Task 5: complete (review clean; 255 PASS; LocalMlClient на портах). Отчёт: task-5-report.md.
- [x] Task 5: IMlClient.PushAsync + LocalMlClient
- Task 6: complete (build 0/0; 282 PASS; BoardsService (list/create/patch/delete/reorder/colState) на портах IKanjStore+ISettingsStore; списки досок без counts — фронт берёт их из /api/leads; PrefixId вынесен из T7). Отчёт: task-6-report.md.
- Task 6: complete (review clean; 282 PASS). Отчёт: task-6-report.md. Note для T8: валидация 400 на null-name; rules null-устойчивость.
- [x] Task 6: BoardsService — колонки-доски и colState + unit-тесты
- Task 7: complete (build 0/0; 326 PASS: 282 → +44; CardsService (list/get/move/trash/restore/delete/clear_col/mark_seen/comment/counts/search) на портах IKanjStore+ISettingsStore+IMlClient; matchHits через ColumnRules; журнал CardMoves + push (обучение всегда); CardMapper не создавался — маппинг живёт в адаптере T4 (расхождение плана зафиксировано)). Отчёт: task-7-report.md.
- Task 7: complete (review clean; 326 PASS). Отчёт: task-7-report.md. Note: contacts-fallback doc≠code — поправить doc или реализовать позже.
- [x] Task 7: CardsService — карточки: чтение, переносы, архив/корзина, комментарии, counts
- Task 8: complete (build 0/0; 326 PASS; BoardsEndpoints/LeadsEndpoints + RequestModels; DI map в Program.cs; curl-приёмка 43/43 PASS — boards/columns/leads/search + 400/404-ветки). Отчёт: task-8-report.md. Note для T9+: карточки в этапе 3 создаёт демо T13 — полный цикл move/trash/restore/clear-col примем в T15.
- Task 8: complete (review clean; 326 PASS; curl 43/43). Отчёт: task-8-report.md.
- [x] Task 8: Эндпоинты досок/колонок/карточек/поиска + DI + curl-приёмка
- Task 9: complete (review clean; 332 PASS; curl 12/12). Отчёт: task-9-report.md. Note: для тестов ASP.NET-типов позже — FrameworkReference/Mvc.Testing.
- [x] Task 9: SSE-брокер + GET /api/events + boot-заглушки /projects и /tg/status
- Task 10: complete (build 0/0; 342 PASS: 332 → +10 StorageTickServiceTests на fake; StorageTickService (модуль, TickAsync) + POST /api/admin/tick (storage+reminders/pipeline/queue заглушки + SSE-toast 1:1) + /admin/fts/rebuild заглушка {ok,ready}; curl 12/12). Отчёт: task-10-report.md.
- Task 10: complete (review clean; 342 PASS; curl 12/12). Отчёт: task-10-report.md.
- [x] Task 10: StorageTickService + POST /api/admin/tick + /admin/fts/rebuild + SSE-toast
- Task 11: complete (build 0/0; 348 PASS: 342 → +6 — StorageTickSchedulerTests на реальном DI+fake (обход всех тенантов по scope/SetTenant/Reset, тост в канал каждого, изоляция падения тика тенанта и реестра) + StorageToastPublisherTests (тексты/иконки 1:1); StorageTickScheduler (IHostedService, Timer 30 с, guard Interlocked, graceful stop) + StorageToastPublisher (общий для /admin/tick и цикла); curl 10/10 — автоархив фоновым циклом без ручного tick, лог без ошибок, T10-тик не сломан). Отчёт: task-11-report.md.
- Task 11: complete (review clean; 348 PASS; curl 10/10). Отчёт: task-11-report.md.
- Task 11: complete (review clean; 348 PASS; curl 10/10). Отчёт: task-11-report.md.
- [x] Task 11: StorageTickScheduler — фоновый цикл правил хранения по тенантам
- Task 12: complete (build 0/0; 369 PASS: 348 → +21 — ConversionRecomputerTests (12: mock-курсы USD→RUB,
targetCurrency смена/дефолт, USDT=USD, conversionOn=false, archive/trash/taken исключены, нет курса/нет
кэша/битый кэш, событие порта, идемпотентность), RatesServiceTests +4 и SettingsServiceTests +5 — триггеры
listener'ов (refresh после записи кэша, PATCH targetCurrency/conversionOn после сохранения; сбой ЦБ/чужие
ключи/JSON-null не оповещают); IRatesChangedListener (Settings) + ConversionRecomputer/RateTable (Kanban),
регистрация в AddKanbanModule; dev-приёмка 14/14 — карточка 100 USD → conv RUB/USD/EUR по триггерам,
archive не тронута). Отчёт: task-12-report.md.
- Task 12: complete (review clean; 369 PASS). Отчёт: task-12-report.md. Note: конвертер продублирован (RateTable vs RatesService) — свести позже.
- [x] Task 12: Пересчёт конверсий — ConversionRecomputer + IRatesChangedListener
- Task 13: complete (build 0/0; 380 PASS: 369 → +11 DemoLeadFactoryTests; DemoLeadFactory (модуль, демо-пул 1:1 + создание карточки через IKanjStore.Add с BudgetNormalizer/контактами/конверсией + состаривание) + DemoEndpoints (POST /api/demo/simulate-lead, /age-lead; 401 → флаг DemoOptions/DEAL_DEMO → 404 «Демо-режим отключён»; SSE new_lead+toast, age+тик+toast) + DemoOptions/appsettings; порт +2 (GetOldestBoardCardAsync/UpdateReceivedAtAsync — age-lead gap-fill); curl 40/40 — полный §4.1, DESC, автоархив psql, restore/move/trash-цикл, SSE 3×new_lead+тосты, Production без флага 404). Отчёт: task-13-report.md.
- Task 13: complete (review clean; 380 PASS; curl 40/40). Отчёт: task-13-report.md.
- [x] Task 13: Демо-карточки — POST /demo/simulate-lead, /demo/age-lead (флаг DEAL_DEMO)
- Task 13: complete (review clean; 380 PASS; curl 40/40). Отчёт: task-13-report.md.
- [x] Task 13: Демо-карточки — POST /demo/simulate-lead, /demo/age-lead (флаг DEAL_DEMO)
- Task 14: complete (review clean; 410 PASS; curl 32/32). Отчёт: task-14-report.md.
- [x] Task 14: ИИ-предложения — IColumnSuggester + эвристика + /api/ai/suggest-columns|keywords
- Task 14: complete (build 0/0; 410 PASS: 380 → +30 — SuggestHeuristicsTests +14 (частотные темы/стоп-слова/группы ≥2/лимит 4/окно MAX_TEXT/похожесть с досками/детерминизм, маркеры keywords), LocalColumnSuggesterTests +11 (мало карточек/кулдаун/создание suggested+note+раскладка matchHits/похожие колонки/keywords выборка без trash-archive), SuggestResultDtosTests +5 (wire 1:1); порт IColumnSuggester + DTO в Contracts, чистый SuggestHeuristics (Kanban), адаптер LocalColumnSuggester (Infrastructure, кулдаун KV lastSuggestAt 20 мин), AiSuggestEndpoints (POST /api/ai/suggest-columns|keywords, 401-гейт, SSE-toast при ok); curl 32/32 ×2 — пустой inbox ok:false, 6 демо → {ok:true, created≥1} + suggested-доски c note/карточками + PATCH suggested:false + повтор → cooldown + keywords {ok,…} + logout 401). Отчёт: task-14-report.md.
- Task 15: complete (review pending). Отчёт: task-15-report.md. Артефакты: task-15-curl-acceptance.sh + .log
(build 0/0; 410 PASS; curl :5080 PASS=94 FAIL=0 — сквозной сценарий: 401/boot-группы/login, SSE 14×new_lead+
toasts, демо ×14, доски+rules+move matchHits, trash/restore/comment/mark-col-seen/search, suggest-columns
(created≥1, suggested-доски с note/карточками) → PATCH suggested:false, suggest-keywords, age-lead → автоархив,
admin/tick, StorageTickScheduler (автоархив без ручного tick, 25 с), conv 9250 RUB/100 USD/92.59 EUR + restore
настроек, logout→401; dev-БД очищена — схема/таблицы и настройки конверсии остались; техдок §11/§13 и roadmap
обновлены: этап 3 — выполнено с ограничениями 4/5/6).
- Task 15: complete (review clean; сквозная приёмка 94/94). Отчёт: task-15-report.md.
- **Этап 3 завершён**: финальное whole-scope ревью ✅ (build 0/0, 410 PASS, миграция TenantKanban применена, boot() фронта удовлетворён). Minor: (1) косметика acceptance-скрипта; (2) слабые ассерты скрипта (не продукта); (3) кириллица в curl-query — MSYS.
- [x] Task 15: Финал этапа — интеграция и сквозная приёмка
## Pre-flight scan (краткий)
| Пара | Производит/потребляет | Результат |
|---|---|---|
| T1 → T4 | миграция → EF-адаптер | Чисто |
| T2 → T6/T7 | IKanjStore → сервисы | Чисто |
| T3 → T7 | ColumnRules → move/restore matchHits | Чисто |
| T5 → T7 | PushAsync → логирование обучения при move/trash/restore | Чисто |
| T7 → T8/T10 | CardsService/StorageTick → эндпоинты/tick | Чисто |
| T6/T7 → T8 | BoardsService/CardsService → эндпоинты | Чисто |
| T9 → T8 | SSE-брокер публикует new_lead/toast из эндпоинтов | Чисто (только эндпоинты публикуют) |
| T10/T11 → T8 | StorageTickService + Scheduler | Чисто |
| T12 | Kanban→Settings: IRatesChangedListener в Settings, реализация в Kanban | Kanban зависит от Settings (разрешено); циклов нет |
| T13/T14 | demo/suggest зависят от CardsService/IKanjStore | Чисто |
| T12→T1 | Conv* колонки в Cards — в миграции T1 | Чисто |
| T5 | IMlClient контракт меняется (PushAsync) — Contracts/Integrations | Проверить обратную совместимость (этап 2 LocalMlClient) |
## Task status
@@ -0,0 +1,70 @@
# Task 1 — «Миграция TenantKanban: Boards/Cards/LeadComments/CardMoves/MlOutbox» — отчёт
Статус: **DONE** (build 0/0, тесты PASS, миграция применена к dev-схеме дефолтного тенанта, psql-приёмка зелёная).
## Файлы
### Созданы — сущности (`src/core/Deal.Infrastructure/Persistence/Entities/`, 1 тип = 1 файл)
| Файл | Таблица | Ключевые поля |
|---|---|---|
| `BoardEntity.cs` | `Boards` | Id (text PK), Name, Description, Color, Width, Position (int), KeywordsJson (text), Prompt, VisibleFieldsJson (text), Collapsed (bool), Suggested (bool), RulesJson (text), Note, CreatedAt (timestamptz) |
| `CardEntity.cs` | `Cards` | Id (text PK), Col (text, max 200), IsNew, IsVacancy, IsVacancyKnown (bool), Title, Summary, StackJson (text), BudgetFrom/To (double?), BudgetCur, ConvFrom/To (double?), ConvCur, Contact, ContactsJson (text), ChannelName/Handle/Hue, ReceivedAt (timestamptz), SourceMsg (text), SourceDialogId, SourceMsgId (bigint?), PrevCol, ArchivedAt (timestamptz?), MatchHitsJson (text), CreatedAt (timestamptz) |
| `LeadCommentEntity.cs` | `LeadComments` | Id (text PK, `cm_`), CardId (FK→Cards.Id, cascade), By, Text, CreatedAt |
| `CardMoveEntity.cs` | `CardMoves` | Id (text PK, `lm_`), LeadId (text, без FK), Action, FromCol (text?), ToCol (text?), CreatedAt |
| `MlOutboxEntity.cs` | `MlOutbox` | Id (text PK, `mle_`), Text, Label, Delta (double, 1.0), CreatedAt |
Все строковые NOT NULL поля в C# — `string` c дефолтным значением (как `TenantSettingEntity`); JSON-поля —
`text` с сериализованным JSON (конвенция `value_json`). Времена — `DateTimeOffset``timestamptz`.
### Созданы — конфигурации (`src/core/Deal.Infrastructure/Persistence/`)
| Файл | Содержание |
|---|---|
| `BoardConfiguration.cs` | `ToTable("Boards")`, HasKey(Id), JSON-поля `.HasColumnType("text")`, индекс `IX_Boards_Suggested_Position` (Suggested, Position) |
| `CardConfiguration.cs` | `ToTable("Cards")`, HasKey(Id), `Col.HasMaxLength(200)`, JSON-поля+SourceMsg `.HasColumnType("text")`, индексы `IX_Cards_Col_ReceivedAt` (Col ASC, ReceivedAt DESC) и `IX_Cards_Col_IsNew` (Col, IsNew) |
| `LeadCommentConfiguration.cs` | `ToTable("LeadComments")`, HasKey(Id), FK `HasOne<CardEntity>().WithMany().HasForeignKey(CardId).OnDelete(Cascade)`, индекс `IX_LeadComments_CardId` |
| `CardMoveConfiguration.cs` | `ToTable("CardMoves")`, HasKey(Id); без FK (журнал живёт дольше карточки) |
| `MlOutboxConfiguration.cs` | `ToTable("MlOutbox")`, HasKey(Id), индекс `IX_MlOutbox_CreatedAt`; без FK |
### Изменены
- `src/core/Deal.Infrastructure/Persistence/TenantDbContext.cs` — добавлены DbSet'ы `Boards/Cards/LeadComments/CardMoves/MlOutbox`
и вызовы `ApplyConfiguration(...)` в `OnModelCreating` (паттерн `TenantSettingConfiguration` — без `ApplyConfigurationsFromAssembly`).
- `src/core/Deal.Infrastructure/Migrations/TenantDb/20260906125058_TenantKanban.cs` (+ `.Designer.cs`, обновлён `TenantDbContextModelSnapshot.cs`) — миграция.
## Выбор типов чисел: `double?` (НЕ `decimal`)
Обоснование:
1. Прототип хранит бюджет/конверсию/вес обучения в DuckDB как `DOUBLE` (`backend/app/db.py`: `budget_from DOUBLE`, …, `ml_outbox.delta DOUBLE NOT NULL DEFAULT 1.0`); Postgres `double precision` ≡ IEEE-754 double — 1:1 без потерь.
2. Значения бюджета с суммой-строками прототипа («2к», «от 0 до 100») — дробные; конверсия (Ruling 7) — умножение на float-курс (`convert_amount`/`budget_to_target`, курсы CBR — float). Денежная арифметика с точностью не ведётся — сумма только отображается/нормализуется.
3. `decimal` добавил бы лишний маппинг `numeric` и конверсии на границе с ML/курсами без выгоды (нет бухгалтерского округления).
Итого: `BudgetFrom/BudgetTo/ConvFrom/ConvTo``double?` (nullable, «суммы нет» = NULL); `MlOutbox.Delta``double` (NOT NULL, дефолт значения 1.0 задан в C#).
## Отклонения и решения
- **Имя сущности `MlOutboxEntity`/`MlOutboxConfiguration`** — по формулировке задания (план в Files называл `MlOutboxItemEntity`; Ruling 1 и таблица — `MlOutbox`). Влияния на DDL нет (таблица `MlOutbox`), но стоит свериться при Task 4/5.
- **DB-дефолты колонок не заданы** (`HasDefaultValue` не использован): EF опускает колонку в INSERT, если значение = CLR-дефолт и настроен store-дефолт — `IsNew=false` (перенос из доски, `mark_seen`) уехало бы в DB-дефолт TRUE. Прототипные дефолты (`IsNew=true`, `Delta=1.0`, цвета/ширины) перенесены в C#-инициализаторы сущностей; приложение пишет полные строки.
- Дефолт `LeadComments`: колонки Id/CardId/By/Text/CreatedAt — нормализация объекта комментария прототипа `{id, by, text, time}`; human-метка `time` вычисляется маппингом от `CreatedAt` (Ruling 10, как у карточек от `ReceivedAt`).
- `CardMoves`/`MlOutbox` без FK — по Ruling 1; `Cards.Col` без FK, max 200; `LeadComments.CardId` FK cascade.
## Миграция и psql-проверка
- Создана: `dotnet ef migrations add TenantKanban --context TenantDbContext --output-dir Migrations/TenantDb --project Deal.Infrastructure --startup-project Deal.Api` (из `src/core`).
- DDL: 5 `CreateTable` без схемы (search_path), PK на Id; `IX_Cards_Col_ReceivedAt` c `descending: [false, true]`; `FK_LeadComments_Cards_CardId` ON DELETE CASCADE.
- Применение: краткий старт `Deal.Api``TenantBootstrapService`/провижинер применил миграцию к схеме дефолтного тенанта (реестр → все схемы).
- `dotnet ef migrations list`: `InitialTenant (Pending)`, `TenantKanban (Pending)` (список читает историю из public — таблица в схеме тенанта, поэтому «Pending» не значим).
psql (`schema tenant_00000000000000000000000000000001`):
- Таблицы: `Boards, CardMoves, Cards, LeadComments, MlOutbox` (+ `settings`, `__TenantMigrationsHistory`) — созданы.
- PK: `PK_Boards/PK_Cards/PK_LeadComments/PK_CardMoves/PK_MlOutbox`.
- Индексы: `IX_Cards_Col_ReceivedAt` = `("Col", "ReceivedAt" DESC)`, `IX_Cards_Col_IsNew`, `IX_Boards_Suggested_Position`, `IX_LeadComments_CardId`, `IX_MlOutbox_CreatedAt`.
- `__TenantMigrationsHistory` содержит `20260905193010_InitialTenant` и `20260906125058_TenantKanban`.
## Валидация
- `dotnet build Deal.sln`: Предупреждений 0, Ошибок 0.
- `dotnet test tests/Deal.Tests.Unit`: 175/175 PASS (MarkerTests PASS).
- Диагностики: только pre-existing ошибки в прототипе `backend/` (вне зоны задачи).
- Диагностический инструмент не показывал предупреждений в `src/core`.
@@ -0,0 +1,151 @@
#!/usr/bin/env sh
# Task 10 curl-приёмка: POST /api/admin/tick + POST /api/admin/fts/rebuild на :5080
# (план Task 10 L395-397; Ruling 6/8/11; dashboard_routes.py L261-264, L327-337; api-map §3.2 L105-107;
# store.js tickAuto L1855-1863, rebuildFts L1884-1889). Сценарий: очистка kanban-таблиц дефолтного
# тенанта → 401-гейты без куки (/admin/tick, /admin/fts/rebuild) → login admin/admin →
# POST /admin/tick → форма {storage:{archived,purgedArchive,purgedTrash,purgedRejected: 0},
# reminders:[], pipeline:{}, queue:0} (пусто — реальные перемещения карточек после Task 13) →
# POST /admin/fts/rebuild → {ok:true, ready:true} (заглушка Ruling 6) → logout → 401 на tick.
# Вывод всех шагов в stdout.
set -u
BASE_URL="http://localhost:5080"
API_DIR="C:/telbase/src/core/Deal.Api"
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
JAR="/tmp/task10-jar.txt"
OUT="/tmp/task10-out.txt"
LOG="/tmp/task10-api.log"
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
SCHEMA="tenant_00000000000000000000000000000001"
PASS_COUNT=0
FAIL_COUNT=0
check() {
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
desc=$1
shift
ok=1
for pat in "$@"; do
if ! grep -qF -- "$pat" "$OUT"; then
ok=0
fi
done
if [ "$ok" = 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не найдено: $*"
fi
}
cleanup() {
echo
echo "== Завершение: останавливаем Deal.Api (pid $APP_PID) =="
kill "$APP_PID" 2>/dev/null
sleep 2
rm -f "$JAR" "$OUT"
}
trap cleanup EXIT INT TERM
rm -f "$JAR" "$OUT" "$LOG"
echo "== 0. Очистка kanban-таблиц дефолтного тенанта (повторяемость приёмки) =="
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"CardMoves\";"
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"LeadComments\";"
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Cards\";"
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Boards\";"
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".settings WHERE \"Key\" IN ('autoArchive','archiveAfterDays','archiveClearDays','trashClearDays');"
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT (SELECT count(*) FROM \"$SCHEMA\".\"Boards\") + (SELECT count(*) FROM \"$SCHEMA\".\"Cards\");")
if [ "$ROWS_LEFT" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] kanban-таблицы пусты"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] kanban-таблицы не пусты (Boards+Cards = $ROWS_LEFT)"
fi
echo
echo "== 1. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) =="
cd "$API_DIR" || exit 1
ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
APP_PID=$!
i=0
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
i=$((i + 1))
if [ "$i" -ge 40 ]; then
echo " [FAIL] сервер не поднялся за 40 с (лог: $LOG)"
tail -n 20 "$LOG"
exit 1
fi
sleep 1
done
echo " [PASS] health: $(curl -s "$BASE_URL/api/health")"
echo
echo "== 2. 401-гейты без куки: /api/admin/tick, /api/admin/fts/rebuild =="
curl -s -w "\n[HTTP:%{http_code}]" -X POST "$BASE_URL/api/admin/tick" > "$OUT"
cat "$OUT"
echo
check "/api/admin/tick без сессии → 401" '[HTTP:401]' '"detail":"Требуется авторизация"'
curl -s -w "\n[HTTP:%{http_code}]" -X POST "$BASE_URL/api/admin/fts/rebuild" > "$OUT"
cat "$OUT"
echo
check "/api/admin/fts/rebuild без сессии → 401" '[HTTP:401]' '"detail":"Требуется авторизация"'
echo
echo "== 3. POST /api/auth/login admin/admin =="
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT"
cat "$OUT"
echo
check "login 200 ok" '[HTTP:200]' '"ok":true' '"login":"admin"'
echo
echo "== 4. POST /api/admin/tick → storage-нули + форма {reminders, pipeline, queue} =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/tick" > "$OUT"
cat "$OUT"
echo
check "admin/tick 200" '[HTTP:200]'
check "storage-форма нулей (карточек нет)" \
'"storage":{"archived":0,"purgedArchive":0,"purgedTrash":0,"purgedRejected":0}'
check "reminders:[] (этап 5)" '"reminders":[]'
check "pipeline:{} (этап 4)" '"pipeline":{}'
check "queue:0 (этап 4)" '"queue":0'
echo
echo "== 5. POST /api/admin/fts/rebuild → {ok:true, ready:true} (заглушка Ruling 6) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/fts/rebuild" > "$OUT"
cat "$OUT"
echo
check "admin/fts/rebuild 200 {ok,ready}" '[HTTP:200]' '{"ok":true,"ready":true}'
echo
echo "== 6. POST /api/auth/logout, затем POST /api/admin/tick — 401 =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
cat "$OUT"
echo
check "logout 200" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/tick" > "$OUT"
cat "$OUT"
echo
check "после logout /api/admin/tick 401" '[HTTP:401]' '"detail":"Требуется авторизация"'
echo
echo "== 7. Очистка kanban-таблиц после приёмки (dev-БД к исходному состоянию) =="
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"CardMoves\";"
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"LeadComments\";"
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Cards\";"
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Boards\";"
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".settings WHERE \"Key\" IN ('autoArchive','archiveAfterDays','archiveClearDays','trashClearDays');"
echo
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
if [ "$FAIL_COUNT" != 0 ]; then
echo " [FAIL] есть проваленные проверки"
exit 1
fi
echo " [PASS] все проверки curl-приёмки прошли"
@@ -0,0 +1,77 @@
# Task 10 — «StorageTickService + POST /api/admin/tick + /admin/fts/rebuild + SSE-toast» — отчёт
Статус: **complete** (build 0/0, тесты 342/342 PASS — +10 новых, curl-приёмка :5080 PASS=12 FAIL=0).
План: `docs/superpowers/plans/2026-09-05-deal-stage3-kanban.md` Task 10 (L380398), Rulings 5/6/8/11;
эталоны — `backend/app/services/leads.py` tick_storage L454493 + notify_tick_stats L496504,
`backend/app/routers/dashboard_routes.py` L261264 (fts_rebuild) и L327337 (admin_tick),
`src/frontend/src/store.js` tickAuto L18551863 / rebuildFts L18841889, api-map §3.2 L103112.
## Файлы
### Создан — `src/core/Deal.Modules.Kanban/Application/`
- `StorageTickService.cs` — чистый сервис модуля (без EF/HTTP/SSE): `TickAsync(CancellationToken)` повторяет
`tick_storage` (Ruling 8). Читает autoArchive/archiveAfterDays/archiveClearDays/trashClearDays через
`ISettingsStore` (отсутствие/повреждённый JSON → дефолты `SettingsDefaults` — мягкая семантика как
LocalMlClient); один `DateTimeOffset.UtcNow` на весь тик. Порядок 1:1: (1) автоархив кандидатов досок+inbox
(`ListArchiveCandidatesAsync(now afterDays)`) → `UpdateColumnAsync` col=archive, isNew=false,
PrevCol=null (не трогаем, Ruling 8), ArchivedAt=now, matchHits=[]; (2) очистка архива
(`ListExpiredArchiveCandidatesAsync(now archiveClearDays)`); (3) очистка корзины
(`ListTrashCandidatesAsync(now trashClearDays)`); удаление пачкой `PurgeAsync`. Возврат
`StorageTickStatsDto` (Archived/PurgedArchive/PurgedTrash/PurgedRejected: 0 — отсев этап 4). SSE НЕ публикует.
### Создан — `src/core/Deal.Api/Endpoints/`
- `StorageEndpoints.cs` (`MapStorageEndpoints`) — POST `/api/admin/tick`: 401-гейт → `StorageTickService.TickAsync`
→ публикация SSE-тостов по ненулевым счётчикам (тексты/иконки 1:1 notify_tick_stats: «Автоархив: N
карточек»/clock, «Архив очищен: N (90 дн.)»/trash, «Корзина очищена: N (7 дн.)»/trash; дни в скобках —
фиксированные строки прототипа; канал тенанта сессии, без подписчиков — no-op) → ответ
`{storage, reminders: [], pipeline: {}, queue: 0}` (reminders/pipeline/queue — заглушки этапов 5/4; фронт
tickAuto читает только storage). POST `/api/admin/fts/rebuild``{ok:true, ready:true}` (заглушка Ruling 6).
### Изменены
- `src/core/Deal.Modules.Kanban/Application/KanbanModuleRegistrar.cs``AddScoped<StorageTickService>()`.
- `src/core/Deal.Api/Program.cs``app.MapStorageEndpoints();` после MapLeadsEndpoints (роутер dashboard).
- `src/core/tests/Deal.Tests.Unit/FakeKanjStore.cs` — реализованы методы правил хранения порта (были
NotSupportedException): кандидаты автоархива/архива/корзины, `PurgeAsync`; модель `archived_at` рядом с
CardDto (`archivedAtById`: UpdateColumnAsync пишет/обнуляет, purge/delete/clear-col чистят; ReceivedAt — из
`ReceivedAtMs` epoch-ms как маппинг адаптера). Хелперы `SetArchivedAt`/`ArchivedAtOf` для тестов.
- Создан: `tests/Deal.Tests.Unit/StorageTickServiceTests.cs` (10 тестов).
- `.superpowers/sdd/deal-stage3-kanban/task-10-curl-acceptance.sh` (+ `.log`-запись вывода шага) и
`progress.md` (Task 10 complete).
## Решения (зафиксированные)
1. **Имя метода — `TickAsync`** (план Task 10 L386 и Task 11 L405; вариант «ExecuteTickAsync» в описании
задачи не использован — план для Tasks 10/11 един на имя).
2. **Удаление очисток — `PurgeAsync`** (пачка, порт документирован «для очисток тика», Ruling 8), а не
построчный DeleteForever из формулировки плана L383 (порт T2 уже дал пачечный метод).
3. **Счётчики очисток = фактически удалённое** (возврат `PurgeAsync`); автоархив — по числу кандидатов
(как python инкремент на строку).
4. **Тосты с «(90 дн.)»/«(7 дн.)» — фиксированные строки прототипа** (notify_tick_stats L500/L502), не
пересчитываются от фактических настроек; toast purgedRejected не публикуется (всегда 0, этап 4).
5. **Публикации SSE — только из эндпоинта** (Ruling 5): сервис модуля чистый; `PublishTickToasts`
приватный хелпер StorageEndpoints, тексты/иконки — константы.
6. **Чтение настроек** — мягкий дефолт на отсутствие/битый JSON (как LocalMlClient.ReadMlEnabledAsync);
границы дней без повторного клампа (кламп 1..30 — ответственность SettingsService на PATCH).
7. **FakeKanjStore.ArchivedAt** — CardDto не несёт ArchivedAt (Ruling 10), поэтому метка архивации
моделируется отдельным словарём; кандидатные методы повторяют 1:1 SQL KanbanStore (колонка/граница <).
## Проверка
1. **Build**: `dotnet build Deal.sln` (из `src/core`) — 0 ошибок / 0 предупреждений.
2. **Тесты**: `dotnet test tests/Deal.Tests.Unit`**342/342 PASS** (было 332; +10 StorageTickServiceTests:
дефолтный автоархив (доски+inbox, is_new=false, archived_at≈now, matchHits пусто, молодые не тронуты),
autoArchive=false, archiveAfterDays 30/1 из настроек, повторный тик без re-архива/очистки, очистка архива
по ArchivedAt (дефолт 90 и из настроек 30), очистка корзины по ReceivedAt (дефолт 7 и из настроек 3),
пустой тик → нули + purgedRejected:0).
3. **Curl-приёмка** (:5080, admin/admin, kanban-таблицы очищены; `task-10-curl-acceptance.log`): PASS=12
FAIL=0 — 401 без куки на tick/fts-rebuild; login; POST /admin/tick →
`{"storage":{"archived":0,"purgedArchive":0,"purgedTrash":0,"purgedRejected":0},"reminders":[],"pipeline":{},"queue":0}`
(форма нулей — карточек нет); fts/rebuild → `{"ok":true,"ready":true}`; logout → 401 на tick.
4. Диагностики по изменённым C#-файлам — без ошибок/предупреждений (refresh показывает только pre-existing
lint прототипа `backend/*.py`, вне scope этапа).
## Concerns для Task 11+
- Реальные перемещения тика (archived=1/purge-тосты) curl-приёмкой не проверяемы до Task 13 (демо-карточки
и age-lead) — финальная сквозная приёмка в T15 (план L395–397); юнит-покрытие перемещений — на фейке выше.
- Task 11 (StorageTickScheduler) будет звать `StorageTickService.TickAsync` в собственном scope с
ITenantContext и публиковать SSE-тосты в канал тенанта (Ruling 8) — метод/контракт готовы.
- «90 дн.»/«7 дн.» в тостах фиксированы как в прототипе даже при изменённых archiveClearDays/trashClearDays —
осознанное 1:1-расхождение (зафиксировано в п.4).
@@ -0,0 +1,154 @@
#!/usr/bin/env sh
# Task 11 curl-приёмка: фоновый StorageTickScheduler на :5080 (план Task 11 L413-414; Ruling 8;
# main.py _storage_loop L43-53). Сценарий: кладём в БД дефолтного тенанта просроченную карточку inbox
# (received_at старше дефолтных archiveAfterDays=14) → запуск Deal.Api → фоновый цикл (первый проход —
# сразу после старта, далее каждые 30 с) архивирует её БЕЗ ручного POST /admin/tick → повторный тик
# руками (login/tick) работает как в Task 10 (storage-нули — карточка уже в archive, свежая) → после
# ~35 с ожидания (второй проход цикла) в логе нет ошибок цикла → карточка остаётся в archive
# (до очистки архива 90 дн. не дошла). Вывод всех шагов в stdout.
# Очистка после приёмки: удаляем демо-карточку (таблицы канбана dev-БД к исходному состоянию).
set -u
BASE_URL="http://localhost:5080"
API_DIR="C:/telbase/src/core/Deal.Api"
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
JAR="/tmp/task11-jar.txt"
OUT="/tmp/task11-out.txt"
LOG="/tmp/task11-api.log"
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
SCHEMA="tenant_00000000000000000000000000000001"
CARD_ID="l_t11_sched"
PASS_COUNT=0
FAIL_COUNT=0
check() {
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
desc=$1
shift
ok=1
for pat in "$@"; do
if ! grep -qF -- "$pat" "$OUT"; then
ok=0
fi
done
if [ "$ok" = 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не найдено: $*"
fi
}
cleanup() {
echo
echo "== Завершение: останавливаем Deal.Api (pid $APP_PID) =="
kill "$APP_PID" 2>/dev/null
sleep 2
echo "== Очистка демо-карточки $CARD_ID (dev-БД к исходному состоянию) =="
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"LeadComments\" WHERE \"CardId\" = '$CARD_ID';" >/dev/null 2>&1
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Cards\" WHERE \"Id\" = '$CARD_ID';"
rm -f "$JAR" "$OUT"
}
trap cleanup EXIT INT TERM
rm -f "$JAR" "$OUT" "$LOG"
echo "== 0. Очистка прежней демо-карточки (повторяемость) и вставка просроченной карточки inbox =="
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Cards\" WHERE \"Id\" = '$CARD_ID';" >/dev/null 2>&1
$PSQL_BASE -c "INSERT INTO \"$SCHEMA\".\"Cards\"
(\"Id\",\"Col\",\"IsNew\",\"IsVacancy\",\"IsVacancyKnown\",\"Title\",\"Summary\",\"StackJson\",
\"BudgetCur\",\"ConvCur\",\"Contact\",\"ContactsJson\",\"ChannelName\",\"ChannelHandle\",\"ChannelHue\",
\"ReceivedAt\",\"SourceMsg\",\"SourceDialogId\",\"PrevCol\",\"MatchHitsJson\",\"CreatedAt\")
VALUES ('$CARD_ID','inbox',true,false,false,'','','[]','','','','[]','','','',
now() - interval '20 days','','','','[]', now());"
COL_BEFORE=$($PSQL_BASE -t -A -c "SELECT \"Col\" FROM \"$SCHEMA\".\"Cards\" WHERE \"Id\" = '$CARD_ID';")
if [ "$COL_BEFORE" = "inbox" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] демо-карточка вставлена в inbox (received_at 20 дн. > дефолт 14)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] демо-карточка не вставлена (col=$COL_BEFORE)"
fi
echo
echo "== 1. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) =="
cd "$API_DIR" || exit 1
ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
APP_PID=$!
i=0
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
i=$((i + 1))
if [ "$i" -ge 40 ]; then
echo " [FAIL] сервер не поднялся за 40 с (лог: $LOG)"
tail -n 30 "$LOG"
exit 1
fi
sleep 1
done
echo " [PASS] health: $(curl -s "$BASE_URL/api/health")"
echo
echo "== 2. Фоновый цикл: первый проход сразу после старта архивирует карточку БЕЗ ручного tick =="
sleep 5
COL_AFTER=$($PSQL_BASE -t -A -c "SELECT \"Col\" FROM \"$SCHEMA\".\"Cards\" WHERE \"Id\" = '$CARD_ID';")
ARCHIVED_AT=$($PSQL_BASE -t -A -c "SELECT \"ArchivedAt\" IS NOT NULL FROM \"$SCHEMA\".\"Cards\" WHERE \"Id\" = '$CARD_ID';")
if [ "$COL_AFTER" = "archive" ] && [ "$ARCHIVED_AT" = "t" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] автоархив фоновым циклом: col=$COL_AFTER, archived_at выставлен"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] автоархив не сработал: col=$COL_AFTER, archived_at set=$ARCHIVED_AT"
fi
echo
echo "== 3. Ручной POST /api/admin/tick работает как в Task 10 (карточка уже в archive — storage-нули) =="
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT"
check "login 200 ok" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/tick" > "$OUT"
cat "$OUT"
echo
check "admin/tick 200" '[HTTP:200]'
check "storage-форма нулей (карточка архивирована первым проходом цикла)" \
'"storage":{"archived":0,"purgedArchive":0,"purgedTrash":0,"purgedRejected":0}'
check "reminders:[] (этап 5)" '"reminders":[]'
check "pipeline:{} (этап 4)" '"pipeline":{}'
check "queue:0 (этап 4)" '"queue":0'
echo
echo "== 4. Второй проход цикла (~35 с ожидания): карточка остаётся в archive, лог без ошибок =="
sleep 35
COL_LATER=$($PSQL_BASE -t -A -c "SELECT \"Col\" FROM \"$SCHEMA\".\"Cards\" WHERE \"Id\" = '$CARD_ID';")
if [ "$COL_LATER" = "archive" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] после ~35 с карточка всё ещё в archive (очистка архива — 90 дн.)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] карточка изменила колонку: col=$COL_LATER"
fi
LOOP_ERRORS=$(grep -c "не удалось\|Unhandled exception\|System\..*Exception" "$LOG")
if [ "$LOOP_ERRORS" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] в логе нет ошибок цикла правил хранения"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] в логе ошибки цикла ($LOOP_ERRORS):"
grep "не удалось\|Unhandled exception\|System\..*Exception" "$LOG" | head -n 10
fi
echo
echo "== 5. Остановка: kill Deal.Api =="
kill "$APP_PID" 2>/dev/null
sleep 3
echo " [PASS] Deal.Api остановлен"
echo
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
if [ "$FAIL_COUNT" != 0 ]; then
echo " [FAIL] есть проваленные проверки"
exit 1
fi
echo " [PASS] все проверки Task 11 прошли"
@@ -0,0 +1,84 @@
# Task 11 — «StorageTickScheduler — фоновый цикл правил хранения по тенантам» — отчёт
Статус: **complete** (build 0/0; тесты 348/348 PASS — +6 новых; curl-приёмка :5080 PASS=10 FAIL=0 —
автоархив фоновым циклом без ручного tick, лог без ошибок, T10-тик не сломан).
План: `docs/superpowers/plans/2026-09-05-deal-stage3-kanban.md` Task 11 (L400414), Ruling 8 (L134145);
эталоны — `backend/app/main.py` _storage_loop L4353 (тик до первого sleep + каждые 30 с + публикация
тостов notify_tick_stats из фонового цикла), `A/Hosting/TenantBootstrapService.cs` (IHostedService +
scope'ы), `A/RatesRefreshScheduler.cs` (in-flight guard + try/catch), `A/Endpoints/StorageEndpoints.cs`
(публикация тостов T10, вынесена в общий хелпер).
## Файлы
### Создан — `src/core/Deal.Api/Hosting/StorageTickScheduler.cs` (IHostedService, namespace Deal.Api.Hosting)
- `System.Threading.Timer` период 30 с (`TickPeriodSeconds`); первый проход — сразу после старта
(dueTime=0), как в прототипе (тик до первого `asyncio.sleep(30)`). Каждый проход — собственный scope:
список тенантов системного реестра (`ITenantRepository`, вне tenant-контекста — паттерн
TenantBootstrapService); на каждый тенант — вложенный scope: `ITenantContext.SetTenant(new TenantId(id
"N"))` (эталон SessionMiddleware) → resolve `StorageTickService` ПОСЛЕ SetTenant (TenantDbContext строится
от схемы) → `TickAsync(ct)``StorageToastPublisher.PublishTickToasts(tenant.Id, stats)` (канал тенанта,
без подписчиков — no-op); `Reset()` в finally.
- In-flight guard `Interlocked.CompareExchange` (как RatesRefreshScheduler): проход длиннее 30 с — тик
таймера пропускается. Ошибки: тик одного тенанта → LogWarning, остальные тенанты обрабатываются
(main.py ловит на весь цикл — для мультитенанта изоляция по тенанту); сбой реестра → LogError, цикл
живёт. StopAsync: Change(∞)/Dispose таймера → `shutdownCts.Cancel()` (EF-запросы тика наблюдают токен) →
ожидание текущего прохода не дольше лимита хоста (`WaitAsync(ct)`) — graceful.
- `RunCycleAsync(CancellationToken)` публичен: та же guarded-точка, что у таймера — unit-тесты зовут
итерацию напрямую (тайминги цикла не тестируются, как и задумано планом).
### Создан — `src/core/Deal.Api/Events/StorageToastPublisher.cs` (Task 2 задания: вынос общей публикации)
- Хелпер Api-слоя `PublishTickToasts(Guid tenantId, StorageTickStatsDto stats)` — переезд приватного
`StorageEndpoints.PublishTickToasts` и его констант (тексты/иконки 1:1 notify_tick_stats L496504:
«Автоархив: N карточек»/clock, «Архив очищен: N (90 дн.)»/trash, «Корзина очищена: N (7 дн.)»/trash;
purgedRejected не публикуется — этап 4). Единственный источник тостов для ручного тика (T10) и
фонового цикла (T11) — без дублирования; публикация в канал тенанта, без подписчиков — no-op (Ruling 5).
### Изменены
- `src/core/Deal.Api/Endpoints/StorageEndpoints.cs``AdminTickAsync` резолвит
`StorageToastPublisher` из RequestServices и зовёт `PublishTickToasts` (контракт ответа и поведение
T10 не изменились: тосты по тем же текстам/иконкам/счётчикам).
- `src/core/Deal.Api/Program.cs``AddSingleton<StorageToastPublisher>()` (рядом с SseBroker) и
`AddHostedService<StorageTickScheduler>()` ПОСЛЕ `TenantBootstrapService` (первый проход стартует после
провижининга схем).
- Создан тест-фейк: `tests/Deal.Tests.Unit/FakeTenantRepository.cs` (реестр с фиксированным списком;
FindById/Create — NotSupportedException, как FakeKanjStore).
- Созданы тесты: `tests/Deal.Tests.Unit/StorageTickSchedulerTests.cs` (4 теста) и
`tests/Deal.Tests.Unit/StorageToastPublisherTests.cs` (2 теста).
- `.superpowers/sdd/deal-stage3-kanban/task-11-curl-acceptance.sh` (+ `.log`) и `progress.md` (Task 11 complete).
## Решения (зафиксированные)
1. **Первый проход — сразу при старте** (dueTime=0): 1:1 с прототипом, где тик выполняется до первого
sleep (main.py L47). Регистрация после Bootstrap гарантирует готовность схем.
2. **Таймер + guard Interlocked**, а не BackgroundService/PeriodicTimer: фиксированный период 30 с с
пропуском «длинного» прохода — буквально «Timer 30 с + in-flight guard, как RatesRefreshScheduler».
3. **Отмена graceful**: собственный `shutdownCts` (Cancel в StopAsync) → тик тенанта прерывается по
токену (OCE в тике ретраится наверх и гасится на уровне прохода без лога — штатная остановка);
StopAsync ждёт текущий проход в пределах лимита хоста.
4. **Изоляция ошибок по тенанту**: падение одного тенанта (имитация сбоя схемы/БД) не валит проход —
warning в лог, остальные тенанты тикаются (в main.py catch на весь цикл — для мультитенанта
осознанно строже, Ruling 8 «обход ВСЕХ тенантов»).
5. **StorageToastPublisher — singleton Api-слоя**: переиспользуется эндпоинтом и планировщиком; модуль
Kanban остался чистым (Ruling 5); тексты/иконки — в одном месте.
## Проверка
1. **Build**: `dotnet build Deal.sln` (из `src/core`) — 0 ошибок / 0 предупреждений.
2. **Тесты**: `dotnet test tests/Deal.Tests.Unit`**348/348 PASS** (было 342; +4 StorageTickSchedulerTests
на реальном DI+fake: обход всех тенантов в собственных scope (карточка каждого ушла в archive, тост в
свой канал, TenantContext сброшен), нулевые счётчики — тост только тенанту с изменениями, падение тика
одного тенанта не валит остальных, падение реестра не выбрасывается наружу; +2 StorageToastPublisherTests:
тексты/иконки 1:1 прототипа по ненулевым счётчикам (purgedRejected=4 — без тоста «Отсев очищен»),
нули — без публикаций).
3. **Curl-приёмка** (:5080, admin/admin; `task-11-curl-acceptance.log`): PASS=10 FAIL=0 — вставка
просроченной карточки inbox (received_at 20 дн.) через psql в схему дефолтного тенанта → запуск Api →
первый проход цикла архивировал карточку БЕЗ ручного tick (col=archive, archived_at set) → ручной
POST /admin/tick — форма T10 как была (storage-нули — карточка уже в archive) → второй проход через
~35 с: карточка остаётся в archive (до очистки 90 дн. не дошла), в логе НЕТ ошибок цикла → kill —
чистая остановка, демо-карточка удалена (dev-БД к исходному состоянию).
4. Диагностики по изменённым C#-файлам — без ошибок/предупреждений.
## Concerns для Task 12+
- Сквозной сценарий с SSE-подписчиком (тост фонового цикла на открытом /api/events) и демо-путём
(Task 13 demo/age-lead) — финальная приёмка T15 (план L413–414). Юнит-покрытие публикаций — на
каналах SseBroker с реальными текстами.
- Тайминги цикла (30-секундный период, stop-ожидание) unit-тестами не покрыты (план: «юнит ограничен»);
старт/остановка проверены в curl-приёмке и логе.
@@ -0,0 +1,172 @@
#!/usr/bin/env sh
# Task 12 curl-приёмка: пересчёт конверсий ConversionRecomputer на :5080 (план Task 12 L436-438;
# Ruling 7; rates.py refresh_rates L62-74 + recompute_conversions L106-130). Сценарий: кладём в БД
# дефолтного тенанта карточку с бюджетом 100 USD (col=inbox) и такую же в archive → PATCH
# {rateSource:"mock"} + POST /rates/refresh (пишет ratesCache mock → listener пересчитывает) →
# psql: conv_cur='RUB', conv 9250 (карточка в archive НЕ тронута) → PATCH {targetCurrency:"USD"} →
# conv пересчитан в USD (100) синхронно → PATCH {targetCurrency:"EUR"} → conv 92.59 EUR →
# очистка: PATCH возврат {targetCurrency:"RUB",conversionOn:true,rateSource:"cbr"}, удаление карточек.
# Вывод всех шагов в stdout.
set -u
BASE_URL="http://localhost:5080"
API_DIR="C:/telbase/src/core/Deal.Api"
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
JAR="/tmp/task12-jar.txt"
OUT="/tmp/task12-out.txt"
LOG="/tmp/task12-api.log"
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
SCHEMA="tenant_00000000000000000000000000000001"
CARD_INBOX="l_t12_inbox"
CARD_ARCHIVE="l_t12_archive"
PASS_COUNT=0
FAIL_COUNT=0
check() {
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
desc=$1
shift
ok=1
for pat in "$@"; do
if ! grep -qF -- "$pat" "$OUT"; then
ok=0
fi
done
if [ "$ok" = 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не найдено: $*"
fi
}
conv_of() {
$PSQL_BASE -t -A -c "SELECT COALESCE(\"ConvFrom\"::text, '') || '|' || COALESCE(\"ConvTo\"::text, '') || '|' || \"ConvCur\" FROM \"$SCHEMA\".\"Cards\" WHERE \"Id\" = '$1';"
}
expect_conv() {
# $1 — карточка; $2 — ожидаемое "from|to|cur"; $3 — описание
actual=$(conv_of "$1")
if [ "$actual" = "$2" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $3 (conv=$actual)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $3 — ожидалось '$2', получено '$actual'"
fi
}
cleanup() {
echo
echo "== Завершение: возврат настроек и удаление демо-карточек =="
curl -s -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" \
-d '{"targetCurrency":"RUB","conversionOn":true,"rateSource":"cbr"}' > /dev/null 2>&1
kill "$APP_PID" 2>/dev/null
sleep 2
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Cards\" WHERE \"Id\" IN ('$CARD_INBOX','$CARD_ARCHIVE');" >/dev/null 2>&1
rm -f "$JAR" "$OUT"
}
trap cleanup EXIT INT TERM
rm -f "$JAR" "$OUT" "$LOG"
echo "== 0. Очистка прежних демо-карточек и вставка карточек с бюджетом 100 USD =="
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Cards\" WHERE \"Id\" IN ('$CARD_INBOX','$CARD_ARCHIVE');" >/dev/null 2>&1
$PSQL_BASE -c "INSERT INTO \"$SCHEMA\".\"Cards\"
(\"Id\",\"Col\",\"IsNew\",\"IsVacancy\",\"IsVacancyKnown\",\"Title\",\"Summary\",\"StackJson\",
\"BudgetFrom\",\"BudgetTo\",\"BudgetCur\",\"ConvCur\",\"Contact\",\"ContactsJson\",\"ChannelName\",
\"ChannelHandle\",\"ChannelHue\",\"ReceivedAt\",\"SourceMsg\",\"SourceDialogId\",\"PrevCol\",
\"MatchHitsJson\",\"CreatedAt\")
VALUES ('$CARD_INBOX','inbox',true,false,false,'','','[]',
100,100,'USD','','','[]','','','',
now(),'','','','[]', now());"
$PSQL_BASE -c "INSERT INTO \"$SCHEMA\".\"Cards\"
(\"Id\",\"Col\",\"IsNew\",\"IsVacancy\",\"IsVacancyKnown\",\"Title\",\"Summary\",\"StackJson\",
\"BudgetFrom\",\"BudgetTo\",\"BudgetCur\",\"ConvCur\",\"Contact\",\"ContactsJson\",\"ChannelName\",
\"ChannelHandle\",\"ChannelHue\",\"ReceivedAt\",\"SourceMsg\",\"SourceDialogId\",\"PrevCol\",
\"MatchHitsJson\",\"CreatedAt\")
VALUES ('$CARD_ARCHIVE','archive',false,false,false,'','','[]',
100,100,'USD','','','[]','','','',
now(),'','','','[]', now());"
INBOX_CONV_BEFORE=$(conv_of "$CARD_INBOX")
if [ "$INBOX_CONV_BEFORE" = "||" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] демо-карточки вставлены (conv пуст: '$INBOX_CONV_BEFORE')"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] conv не пуст до пересчёта: '$INBOX_CONV_BEFORE'"
fi
echo
echo "== 1. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) =="
cd "$API_DIR" || exit 1
ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
APP_PID=$!
i=0
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
i=$((i + 1))
if [ "$i" -ge 40 ]; then
echo " [FAIL] сервер не поднялся за 40 с (лог: $LOG)"
tail -n 30 "$LOG"
exit 1
fi
sleep 1
done
echo " [PASS] health: $(curl -s "$BASE_URL/api/health")"
echo
echo "== 2. Login admin/admin =="
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT"
check "login 200 ok" '[HTTP:200]' '"ok":true'
echo
echo "== 3. PATCH {rateSource:mock} + POST /rates/refresh — кэш записан, conv пересчитан в RUB =="
curl -s -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" -d '{"rateSource":"mock"}' > "$OUT"
check "PATCH rateSource 200 (snapshot)" '"rateSource":"mock"'
curl -s -b "$JAR" -X POST "$BASE_URL/api/rates/refresh" > "$OUT"
cat "$OUT"
echo
check "rates/refresh ok=true" '"ok":true'
check "rates/refresh source=mock" '"source":"mock"'
expect_conv "$CARD_INBOX" "9250|9250|RUB" "inbox-карточка: 100 USD → RUB (9250)"
expect_conv "$CARD_ARCHIVE" "||" "archive-карточка НЕ тронута (conv пуст)"
echo
echo "== 4. PATCH {targetCurrency:USD} — синхронный пересчёт в USD =="
curl -s -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" -d '{"targetCurrency":"USD"}' > "$OUT"
check "PATCH targetCurrency 200 (snapshot)" '"targetCurrency":"USD"'
expect_conv "$CARD_INBOX" "100|100|USD" "inbox-карточка пересчитана в USD (100)"
expect_conv "$CARD_ARCHIVE" "||" "archive-карточка всё ещё не тронута"
echo
echo "== 5. PATCH {targetCurrency:EUR} — пересчёт по mock-курсу EUR (92.59) =="
curl -s -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" -d '{"targetCurrency":"EUR"}' > "$OUT"
check "PATCH targetCurrency EUR 200" '"targetCurrency":"EUR"'
expect_conv "$CARD_INBOX" "92.59|92.59|EUR" "inbox-карточка пересчитана в EUR (100*92.5/99.9)"
expect_conv "$CARD_ARCHIVE" "||" "archive-карточка не тронута (итог)"
ERRORS=$(grep -c "Unhandled exception\|System\..*Exception" "$LOG")
if [ "$ERRORS" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] в логе Api нет исключений"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] в логе Api исключения ($ERRORS):"
grep "Unhandled exception\|System\..*Exception" "$LOG" | head -n 10
fi
echo
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
if [ "$FAIL_COUNT" != 0 ]; then
echo " [FAIL] есть проваленные проверки"
exit 1
fi
echo " [PASS] все проверки Task 12 прошли"
@@ -0,0 +1,87 @@
# Task 12 — «Пересчёт конверсий — ConversionRecomputer + IRatesChangedListener» — отчёт
Статус: **complete** (build 0/0, тесты 369/369 PASS: 348 → +21 новых, dev-проверка на :5080 + psql PASS=14 FAIL=0).
План: `docs/superpowers/plans/2026-09-05-deal-stage3-kanban.md` Task 12 (L416438), Ruling 7 (L123133),
Ruling 12; эталоны — `backend/app/services/rates.py` (refresh_rates L6274, recompute_conversions L106130,
_resolve_rate L8691), `settings_routes.py` L186–192. Контекст «Готово» подтверждён: порт
`IKanjStore.ListCardsForConversionAsync`/`UpdateConversionAsync` (T2/T4) и `SettingsKeys`/`ISettingsStore`
на месте; `IRatesChangedListener` в модуле Settings ещё не существовал — создан по плану.
## Файлы
### Создан — `src/core/Deal.Modules.Settings/Application/`
- `IRatesChangedListener.cs` — порт модуля Settings (модуль не знает Kanban): `Task OnRatesChangedAsync(bool
fullRecompute, CancellationToken ct)`. Вызывают сервисы Settings ПОСЛЕ успешного изменения состояния;
реализация — `ConversionRecomputer` в Kanban (регистрация в `AddKanbanModule`), пустой список — no-op.
### Создан — `src/core/Deal.Modules.Kanban/Application/`
- `RateTable.cs` — чистый парсинг ratesCache (`{rates, source, updatedAtMs}` → словарь курсов; повреждённый
JSON/пустой rates → null) + конвертер с USDT=USD (собственный курс USDT — fallback, как rates.py L8691);
RatesService-часть модуля Settings не тронута.
- `ConversionRecomputer.cs` — `IRatesChangedListener` (scoped, порты ISettingsStore + IKanjStore): полный
пересчёт 1:1 с `recompute_conversions`: conversionOn=false → 0; target = targetCurrency (дефолт RUB);
кандидаты `ListCardsForConversionAsync` (budgetCur != '' и col NOT IN archive/trash/taken — фильтрует
хранилище); нет ratesCache → 0 (дефолт-мок НЕ подставляется — по прототипу курс читается из строки кэша
напрямую); карточка с неконвертируемой нижней границей (нет курса валюты/нет budget_from) пропускается
ЦЕЛИКОМ — conv-поля не трогаются (rates.py L123124: `if cf is None: continue`); conv_to — из budget_to,
при отсутствии — из budget_from (одна сумма/«от X»). Публичный `RecomputeAsync(CancellationToken) → int`
(сколько обновлено; интерфейсный метод делегирует в него — fullRecompute зарезервирован под будущие
частичные события, оба текущих триггера — полный пересчёт). Идемпотентно (повторный прогон пересчитывает
то же самое).
### Изменены
- `S/Application/RatesService.cs` — третий ctor-параметр `IEnumerable<IRatesChangedListener>`; после успешной
записи кэша (mock И cbr) — `NotifyRatesChangedAsync` (fullRecompute=true); при сбое ЦБ (кэш не записан)
слушатели НЕ вызываются.
- `S/Application/SettingsService.cs` — третий ctor-параметр `IEnumerable<IRatesChangedListener>`; в
`ApplyPatchAsync` ПОСЛЕ цикла сохранений: если в теле PATCH есть targetCurrency/conversionOn с не-null
значением (`settings_routes.py` L190–191, семантика `is not None`) — синхронный вызов слушателей
(пересчёт читает уже сохранённые настройки). Remarks класса/метода обновлены (эффекты L186–192 больше не
«только HTTP-слой»).
- `K/Application/KanbanModuleRegistrar.cs` — `AddScoped<IRatesChangedListener, ConversionRecomputer>()` (Ruling 12).
- `A/Endpoints/SettingsEndpoints.cs` — только doc-remarks (пересчёт делает SettingsService через порт).
- `tests/Deal.Tests.Unit/FakeKanjStore.cs` — реализованы методы конверсий порта (были NotSupportedException):
кандидаты 1:1 с SQL KanbanStore (Budget != null, col не archive/trash/taken, ORDER BY received_at DESC),
`UpdateConversionAsync` пишет только conv-поля (convCur пуст → Converted=null — маппинг адаптера).
- Создан `tests/Deal.Tests.Unit/FakeRatesListener.cs` (запись вызовов fullRecompute).
- Создан `tests/Deal.Tests.Unit/ConversionRecomputerTests.cs` (12 тестов).
- `tests/Deal.Tests.Unit/RatesServiceTests.cs` (+4 теста триггера; CreateService с listeners).
- `tests/Deal.Tests.Unit/SettingsServiceTests.cs` (+5 тестов триггера; ctor с пустым списком).
## Решения (зафиксированные)
1. **fullRecompute всегда true** — оба текущих триггера требуют полного пересчёта; параметр порта оставлен
по плану (L419–420) как задел под частичные события, реализация его документированно не ветвит.
2. **Строка с cf=null не обновляется (не обнуляется)** — 1:1 с прототипом L123–124, включая «нет курса
валюты» и «budget_from не задан» (карточки «до X» не пересчитываются). Вопрос задачи «без курса →
Conv* = null?» — проверен: по прототипу conv-поля остаются как были.
3. **Нет кэша ratesCache → 0 без изменений** (мок-дефолт НЕ подставляется): RatesService.GetAsync отдаёт
мок наружу, но recompute в прототипе читает строку курсов напрямую — отсутствие строки = нечем
конвертировать. Повреждённый JSON — то же (RateTable.Parse → null).
4. **Триггер PATCH — в SettingsService, не в HTTP-эндпоинте**: Ruling 7/план L422–423 прямо указывают
SettingsService; HTTP-слой остался только за фоновым refresh по rateSource (как было, Task 8).
5. **Тип слушателя в ctor — обязательный параметр** (не optional): DI-список из KanbanModuleRegistrar,
тесты передают пустой массив явно (явные зависимости, стиль модуля).
## Проверка
1. **Build**: `sh scripts/build.sh` — 0 ошибок / 0 предупреждений.
2. **Тесты**: `sh scripts/test.sh` — **369/369 PASS** (348 → +21: 12 ConversionRecomputerTests — mock-курсы
USD→RUB, targetCurrency из настроек (EUR 92.59) и дефолт RUB, USDT=USD (100 USDT → 10000 при USD=100/
USDT=90), «от X» без верхней границы, conversionOn=false, исключение archive/trash/taken, нет курса
валюты (старый conv сохраняется), нет/битый ratesCache, событие через IRatesChangedListener,
идемпотентность повторного прогона; +4 RatesServiceTests — refresh mock/cbr оповещает ПОСЛЕ записи кэша,
сбой ЦБ не оповещает, пустой список no-op; +5 SettingsServiceTests — PATCH targetCurrency/conversionOn
оповещает после сохранения, посторонние ключи/JSON-null не оповещают, пустой список no-op).
3. **Dev-проверка** (:5080, admin/admin, psql deal-postgres; `task-12-curl-acceptance.log`): PASS=14 FAIL=0 —
карточка 100 USD в inbox + такая же в archive; PATCH {rateSource:"mock"} + POST /rates/refresh →
ratesCache mock записан, inbox-карточка conv=9250|9250|RUB, archive-карточка не тронута; PATCH
{targetCurrency:"USD"} → conv=100|100|USD синхронно; PATCH {targetCurrency:"EUR"} → conv=92.59|92.59|EUR;
archive-карточка не тронута на всех шагах; в логе Api нет исключений. Демо-карточки удалены, настройки
возвращены (RUB/cbr/on).
## Concerns для Task 13+
- Сквозная приёмка «демо-карточка → conv при поступлении (budget_to_target)» — за Task 13 (DemoLeadFactory +
BudgetNormalizer); здесь проверен путь пересчёта (psql-карточка + триггеры).
- ConversionRecomputer не обнуляет conv при conversionOn=false (1:1 прототип: return 0) — старые conv-поля
остаются в БД до следующего включения/пересчёта; осознанное 1:1-расхождение с «логикой пользователя».
- Регистрация ConversionRecomputer — только как `IRatesChangedListener`; прямой резолв конкретного типа
(если понадобится Task 13+) потребует `AddScoped<ConversionRecomputer>()`.
@@ -0,0 +1,343 @@
#!/usr/bin/env sh
# Task 13 curl-приёмка /api/demo/simulate-lead и /api/demo/age-lead на :5080 (план Task 13 L458-459;
# Ruling 11; dashboard_routes.py L287-324). Сценарий: сброс kanban-таблиц → запуск Deal.Api с DEAL_DEMO=1
# (Development) → 401 без куки на обоих demo → login → SSE-подписка (curl -N в фон) → simulate-lead ×3
# (карточки в inbox, полный §4.1, SSE new_lead + toast) → GET /leads?col=inbox сортировка DESC →
# age-lead без карточек на досках → 400 → создание доски + move карточки → age-lead: receivedAt в прошлом,
# автоархив (stats.archived=1, psql), SSE-toast «…старше 15 дн.» → restore из архива → move/trash/restore-
# цикл (T7/T8 e2e) + комментарий + counts → остановка; повторный запуск БЕЗ DEAL_DEMO (Production) →
# simulate/age-lead → 404 «Демо-режим отключён». Очистка демо-строк после приёмки.
set -u
BASE_URL="http://localhost:5080"
API_DIR="C:/telbase/src/core/Deal.Api"
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
JAR="/tmp/task13-jar.txt"
JAR2="/tmp/task13-jar2.txt"
OUT="/tmp/task13-out.txt"
LOG="/tmp/task13-api.log"
LOG2="/tmp/task13-api2.log"
SSE_FILE="/tmp/task13-sse.txt"
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
SCHEMA="tenant_00000000000000000000000000000001"
PASS_COUNT=0
FAIL_COUNT=0
APP_PID=""
SSE_PID=""
CARD_A=""
CARD_B=""
CARD_C=""
BOARD_ID=""
check() {
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
desc=$1
shift
ok=1
for pat in "$@"; do
if ! grep -qF -- "$pat" "$OUT"; then
ok=0
fi
done
if [ "$ok" = 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не найдено: $*"
echo "--- ответ:"
cat "$OUT"
fi
}
# Первый id (l_/b_) из JSON-тела ответа: тело — первая строка $OUT (вторая — служебный [HTTP:...]).
extract_id() {
sed -n '1{s/.*"id":"\([lb]_[0-9a-f][0-9a-f]*\)".*/\1/p}' "$OUT"
}
stop_app() {
# $1 — pid; $2 — описание
if [ -n "$1" ] && kill -0 "$1" 2>/dev/null; then
kill "$1" 2>/dev/null
sleep 2
if netstat -ano 2>/dev/null | grep -q ':5080'; then
taskkill //F //PID "$1" 2>/dev/null
sleep 1
fi
fi
echo " [PASS] $2 остановлен"
}
cleanup() {
echo
echo "== Завершение: остановка процессов и очистка демо-строк =="
if [ -n "$SSE_PID" ]; then
kill "$SSE_PID" 2>/dev/null
fi
stop_app "$APP_PID" "Deal.Api (последний запуск)"
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"LeadComments\" WHERE \"CardId\" IN ('$CARD_A','$CARD_B','$CARD_C');" >/dev/null 2>&1
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Cards\" WHERE \"Id\" IN ('$CARD_A','$CARD_B','$CARD_C');" >/dev/null 2>&1
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Boards\" WHERE \"Id\" = '$BOARD_ID';" >/dev/null 2>&1
rm -f "$JAR" "$JAR2" "$OUT" "$SSE_FILE"
}
trap cleanup EXIT INT TERM
rm -f "$JAR" "$JAR2" "$OUT" "$LOG" "$LOG2" "$SSE_FILE"
echo "== 0. Очистка kanban-таблиц дефолтного тенанта (повторяемость приёмки) =="
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"CardMoves\";" >/dev/null
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"MlOutbox\";" >/dev/null
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"LeadComments\";" >/dev/null
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Cards\";" >/dev/null
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Boards\";" >/dev/null
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".settings WHERE \"Key\" IN ('colState','mlDecisions','aiDecisions');" >/dev/null
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT (SELECT count(*) FROM \"$SCHEMA\".\"Boards\") + (SELECT count(*) FROM \"$SCHEMA\".\"Cards\") + (SELECT count(*) FROM \"$SCHEMA\".\"CardMoves\");")
if [ "$ROWS_LEFT" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] kanban-таблицы пусты"
else
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
exit 1
fi
echo
echo "== 1. Запуск Deal.Api на :5080 с DEAL_DEMO=1 (Development) =="
cd "$API_DIR" || exit 1
ASPNETCORE_ENVIRONMENT=Development DEAL_DEMO=1 "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
APP_PID=$!
i=0
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
i=$((i + 1))
if [ "$i" -ge 40 ]; then
echo " [FAIL] сервер не поднялся за 40 с (лог: $LOG)"
tail -n 30 "$LOG"
exit 1
fi
sleep 1
done
echo " [PASS] health: $(curl -s "$BASE_URL/api/health")"
echo
echo "== 2. 401 без сессии на demo-эндпоинтах =="
curl -s -w "\n[HTTP:%{http_code}]" -X POST "$BASE_URL/api/demo/simulate-lead" > "$OUT"
check "simulate-lead без куки → 401" '[HTTP:401]' 'Требуется авторизация'
curl -s -w "\n[HTTP:%{http_code}]" -X POST "$BASE_URL/api/demo/age-lead" > "$OUT"
check "age-lead без куки → 401" '[HTTP:401]' 'Требуется авторизация'
echo
echo "== 3. Login admin/admin =="
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT"
check "login 200 ok" '[HTTP:200]' '"ok":true'
echo
echo "== 4. SSE-подписка на /api/events (фон) =="
curl -s -N -b "$JAR" "$BASE_URL/api/events" > "$SSE_FILE" 2>/dev/null &
SSE_PID=$!
sleep 1
if [ -n "$SSE_PID" ] && kill -0 "$SSE_PID" 2>/dev/null; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] SSE-поток открыт (pid $SSE_PID)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] SSE-поток не поднялся"
fi
echo
echo "== 5. simulate-lead ×3 — карточки в inbox (полный объект §4.1) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/demo/simulate-lead" > "$OUT"
check "simulate-lead #1 200" '[HTTP:200]'
check "полный §4.1: id l_ + col inbox" '"id":"l_' '"col":"inbox"'
check "полный §4.1: isNew/isVacancyKnown/title/summary" '"isNew":true' '"isVacancyKnown":false' '"title":"' '"summary":"'
check "полный §4.1: receivedAt epoch-ms + time" '"receivedAt":' '"time":"'
check "полный §4.1: демо-канал (ch/sourceDialogId)" '"ch":{"name":"Демо-канал","handle":"demo_channel","hue":"#8b8ff8"}' '"sourceDialogId":"demo_channel"'
check "полный §4.1: matchHits пусто (inbox, Ruling 2)" '"matchHits":[]'
CARD_A=$(extract_id)
echo " -> id: $CARD_A"
sleep 1
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/demo/simulate-lead" > "$OUT"
check "simulate-lead #2 200" '[HTTP:200]' '"col":"inbox"'
CARD_B=$(extract_id)
echo " -> id: $CARD_B"
sleep 1
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/demo/simulate-lead" > "$OUT"
check "simulate-lead #3 200 (повторные вызовы наполняют inbox)" '[HTTP:200]' '"col":"inbox"'
CARD_C=$(extract_id)
echo " -> id: $CARD_C"
echo
echo "== 6. GET /api/leads?col=inbox — 3 карточки, сортировка received_at DESC =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/leads?col=inbox" > "$OUT"
check "GET inbox 200" '[HTTP:200]'
INBOX_COUNT=$(grep -o '"col":"inbox"' "$OUT" | wc -l | tr -d ' ')
if [ "$INBOX_COUNT" = "3" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] в inbox 3 карточки"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] в inbox карточек: $INBOX_COUNT (ожидалось 3)"
fi
FIRST_ID=$(grep -o '"id":"l_[0-9a-f]*"' "$OUT" | head -n 1 | sed 's/.*:"//; s/"$//')
if [ "$FIRST_ID" = "$CARD_C" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] первая карточка — последняя созданная ($CARD_C): сортировка DESC"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] первая карточка $FIRST_ID, ожидалась $CARD_C (DESC)"
fi
echo
echo "== 7. age-lead без карточек на досках → 400 =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/demo/age-lead" > "$OUT"
check "age-lead 400" '[HTTP:400]' 'Нет карточек на досках для демо'
echo
echo "== 8. Создание доски и move карточки $CARD_A на доску =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/boards" \
-H "Content-Type: application/json" -d '{"name":"Demo Python Board","keywords":["python"]}' > "$OUT"
check "create board 200 {id:b_}" '[HTTP:200]' '"id":"b_'
BOARD_ID=$(extract_id)
echo " -> board id: $BOARD_ID"
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/leads/$CARD_A/move" \
-H "Content-Type: application/json" -d "{\"to\":\"$BOARD_ID\"}" > "$OUT"
check "move 200 → col = доска, isNew=false" '[HTTP:200]' "\"col\":\"$BOARD_ID\"" '"isNew":false'
echo
echo "== 9. age-lead: receivedAt в прошлом, автоархив (тик внутри), psql =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/demo/age-lead" > "$OUT"
cat "$OUT"
echo
check "age-lead 200 {ok:true}" '[HTTP:200]' '"ok":true'
check "stats.archived=1 (карточка ушла в архив тиком)" '"archived":1'
CARD_ROW=$($PSQL_BASE -t -A -c "SELECT \"Col\" || '|' || (\"ReceivedAt\" < now() - interval '14 days') || '|' || (\"ArchivedAt\" IS NOT NULL) FROM \"$SCHEMA\".\"Cards\" WHERE \"Id\" = '$CARD_A';")
if [ "$CARD_ROW" = "archive|true|true" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] psql: col=archive, received_at старше 14 дн., archived_at выставлен"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] psql: ожидалось archive|true|true, получено '$CARD_ROW'"
fi
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/leads/$CARD_A" > "$OUT"
check "GET карточки: col=archive (автоархив после age-lead)" '[HTTP:200]' '"col":"archive"'
echo
echo "== 10. Restore из архива → inbox (isNew=true) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/leads/$CARD_A/restore" > "$OUT"
check "restore 200 {ok, col:inbox}" '[HTTP:200]' '"ok":true' '"col":"inbox"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/leads/$CARD_A" > "$OUT"
check "карточка снова в inbox, isNew=true" '[HTTP:200]' '"col":"inbox"' '"isNew":true'
echo
echo "== 11. move → trash → restore-цикл (T7/T8 e2e) + комментарий + counts =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/leads/$CARD_A/move" \
-H "Content-Type: application/json" -d "{\"to\":\"$BOARD_ID\"}" > "$OUT"
check "move на доску 200" '[HTTP:200]' "\"col\":\"$BOARD_ID\""
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/leads/$CARD_A/trash" > "$OUT"
check "trash 200 {ok:true}" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/leads?col=trash" > "$OUT"
check "GET trash содержит карточку" '[HTTP:200]' "\"id\":\"$CARD_A\""
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/leads/$CARD_A/restore" > "$OUT"
check "restore из корзины → на доску (prevCol=доска)" '[HTTP:200]' '"ok":true' "\"col\":\"$BOARD_ID\""
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/leads/$CARD_A/comments" \
-H "Content-Type: application/json" -d '{"text":"demo comment"}' > "$OUT"
check "комментарий добавлен" '[HTTP:200]' '"text":"demo comment"' '"by":"Вы"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/leads/counts" > "$OUT"
check "counts: форма с колонками и learning/ml/ai" '"inbox":{"count":2' '"learning":' '"ml":0' '"ai":0'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/leads?col=inbox" > "$OUT"
if grep -q "\"id\":\"$CARD_A\"" "$OUT"; then
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] карточка $CARD_A не должна быть в inbox (она на доске)"
else
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] в inbox только две карточки ($CARD_B, $CARD_C) — $CARD_A на доске"
fi
echo
echo "== 12. SSE-проверка (файл $SSE_FILE) =="
sleep 1
kill "$SSE_PID" 2>/dev/null
SSE_PID=""
sleep 1
NEW_LEAD_COUNT=$(grep -c '^event: new_lead' "$SSE_FILE")
if [ "$NEW_LEAD_COUNT" = "3" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] SSE: 3 события new_lead (simulate ×3)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] SSE: событий new_lead: $NEW_LEAD_COUNT (ожидалось 3)"
fi
if grep -q 'Демо: новый лид' "$SSE_FILE" && grep -q '"icon":"sparkles"' "$SSE_FILE"; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] SSE: toast «Демо: новый лид» (sparkles)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] SSE: toast «Демо: новый лид» не найден"
fi
if grep -q 'Демо: карточка → Архив (старше 15 дн.)' "$SSE_FILE" && grep -q '"icon":"clock"' "$SSE_FILE"; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] SSE: toast age-lead «…Архив (старше 15 дн.)» (clock)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] SSE: toast age-lead не найден (старше 15 дн.)"
fi
echo
echo "== 13. Остановка первого запуска; ошибки Api в логе =="
ERRORS=$(grep -c "Unhandled exception\|System\..*Exception" "$LOG")
if [ "$ERRORS" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] в логе Api (DEAL_DEMO=1) нет исключений"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] в логе Api исключения ($ERRORS):"
grep "Unhandled exception\|System\..*Exception" "$LOG" | head -n 10
fi
stop_app "$APP_PID" "Deal.Api (DEAL_DEMO=1)"
APP_PID=""
echo
echo "== 14. Повторный запуск БЕЗ DEAL_DEMO (Production) — демо выключено =="
# Строка подключения — env (в appsettings.Development она не нужна была первому запуску; ConnectionStringProvider
# требует ключ конфигурации ConnectionStrings:DealPostgres в любом окружении).
ASPNETCORE_ENVIRONMENT=Production ConnectionStrings__DealPostgres="Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password" "$APP_EXE" --urls "$BASE_URL" > "$LOG2" 2>&1 &
APP_PID=$!
i=0
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
i=$((i + 1))
if [ "$i" -ge 40 ]; then
echo " [FAIL] сервер не поднялся за 40 с (лог: $LOG2)"
tail -n 30 "$LOG2"
exit 1
fi
sleep 1
done
echo " [PASS] health (Production, без DEAL_DEMO)"
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR2" -X POST "$BASE_URL/api/auth/login" \
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT"
check "login 200 ok" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR2" -X POST "$BASE_URL/api/demo/simulate-lead" > "$OUT"
check "simulate-lead без флага → 404 «Демо-режим отключён»" '[HTTP:404]' 'Демо-режим отключён'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR2" -X POST "$BASE_URL/api/demo/age-lead" > "$OUT"
check "age-lead без флага → 404 «Демо-режим отключён»" '[HTTP:404]' 'Демо-режим отключён'
ERRORS2=$(grep -c "Unhandled exception\|System\..*Exception" "$LOG2")
if [ "$ERRORS2" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] в логе Api (Production) нет исключений"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] в логе Api (Production) исключения ($ERRORS2)"
fi
stop_app "$APP_PID" "Deal.Api (Production)"
APP_PID=""
echo
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
if [ "$FAIL_COUNT" != 0 ]; then
echo " [FAIL] есть проваленные проверки"
exit 1
fi
echo " [PASS] все проверки Task 13 прошли"
@@ -0,0 +1,109 @@
# Task 13 — «Демо-карточки — POST /demo/simulate-lead, /demo/age-lead (флаг DEAL_DEMO)» — отчёт
Статус: **complete** (build 0/0, тесты 380/380 PASS: 369 → +11 новых, curl-приёмка :5080 PASS=40 FAIL=0).
План: `docs/superpowers/plans/2026-09-05-deal-stage3-kanban.md` Task 13 (L440460), Ruling 11 (границы:
флаг `DEAL_DEMO`, 404 «Демо-режим отключён»), Ruling 5 (SSE публикуют только эндпоинты), Ruling 7/2/12;
эталоны — `backend/app/routers/dashboard_routes.py` L7689 (демо-пул) и L287324 (simulate/age),
`backend/app/services/pipeline.py` `_store_lead` L433–514 (создание карточки), `backend/app/services/ai.py`
budget_to_target L342352. Контекст «Готово» подтверждён: порт `IKanjStore.AddCardAsync(CardSnapshot)` уже
существовал (Task 2/4 — создание заложено под демо T13 и пайплайн этапа 4), поэтому «метод создания» на
уровне CardsService не добавлялся (см. Решения 1–3).
## Файлы
### Создан — `src/core/Deal.Modules.Kanban/Application/`
- `Models/DemoLeadPreset.cs` — пресет демо-пула (текст + структура «как после разбора ИИ»: title/summary/
stack/budget{from,to,currency}/contactsRaw/isVacancy), 1:1 с элементом `_DEMO_POOL`.
- `Models/DemoAgeResultDto.cs` — результат состаривания: Found (нет карточек на досках → 400) + DaysAgo
(граница для SSE-тоста «старше N дн.»).
- `DemoLeadFactory.cs` — чистый сервис модуля: демо-пул (3 пресета 1:1 dashboard_routes L7789),
`CreateRandomCardAsync`/`CreateCardAsync(presetIndex)` (детерминированный путь тестов) и
`AgeOldestBoardCardAsync`. Создание повторяет `_store_lead`: id `l_`+hex (Ruling 12), col=inbox,
isNew=true, prevCol=inbox, matchHits=[] (Ruling 2), isVacancyKnown=false, receivedAt=now, ch-поля
«Демо-канал»/«demo_channel»/#8b8ff8, sourceDialogId=demo_channel, sourceMsgId=null (у демо нет
telegram msg_id; digest/dedup — отсев этапа 4); бюджет — BudgetNormalizer.Normalize + ToTarget
(conversionOn/targetCurrency/ratesCache через ISettingsStore; конверсия — «при поступлении», Ruling 7);
контакты — примитивная версия build_contacts/primary_contact (для полей пула достаточно ветки
«@username» tg, не бот, 4..32). Состаривание: самая старая карточка досок → received_at =
now (archiveAfterDays+1) дней (дефолт 14 → 15). Добавление — `IKanjStore.AddCardAsync`, ответ — чтение
после записи (полный §4.1, как lead_to_dict после INSERT).
- `IKanjStore.cs` (+2 демо-метода): `GetOldestBoardCardAsync` (col ∈ доски, ORDER BY received_at ASC
LIMIT 1) и `UpdateReceivedAtAsync` (сдвиг received_at; других писателей времени нет).
- `KanbanModuleRegistrar.cs``AddScoped<DemoLeadFactory>()`.
### Изменён — `src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.cs`
- `GetOldestBoardCardAsync` (EF: id досок → Cards.Where(col in ids).OrderBy(ReceivedAt).First) и
`UpdateReceivedAtAsync` (ExecuteUpdate только ReceivedAt), стиль остальных методов адаптера.
### Создан/изменён — `src/core/Deal.Api/`
- `Configuration/DemoOptions.cs` — настройка демо-режима (Enabled).
- `Endpoints/DemoEndpoints.cs` (`MapDemoEndpoints`) — POST `/api/demo/simulate-lead`: 401-гейт → флаг
(иначе 404 «Демо-режим отключён») → DemoLeadFactory → SSE new_lead (карточка) + toast «Демо: новый лид»
(sparkles) → карточка §4.1. POST `/api/demo/age-lead`: 401 → флаг → состаривание (нет карточек на
досках → 400 «Нет карточек на досках для демо») → тик StorageTickService → при archived>0 SSE-toast
«Демо: карточка → Архив (старше N дн.)» (clock) → `{ok:true, stats}` (форма demo_age_lead L324).
- `Program.cs` — DemoOptions (секция `Demo` + env `DEAL_DEMO=1`) + `app.MapDemoEndpoints()`.
- `appsettings.json``Demo: {Enabled: false}`; `appsettings.Development.json``Demo: {Enabled: true}`.
### Изменён — `src/core/tests/Deal.Tests.Unit/`
- `FakeKanjStore.cs` — реализованы 3 метода порта (AddCardAsync — снимок → CardDto 1:1 с маппингом
адаптера; GetOldestBoardCardAsync; UpdateReceivedAtAsync).
- Создан `DemoLeadFactoryTests.cs` (+11): дефолты создания (id l_, col inbox, isNew, prevCol, matchHits=[],
ch/демо-канал, contacts/contact, title/summary/stack/sourceMsg пресета), конверсия бюджета (мок-курсы
16002200 USD → 148000203500 RUB; одна сумма 2400→222000; targetCurrency EUR; ratesCache перекрывает
мок; conversionOn=false → converted пуст; пресет без бюджета → budget/converted null), внепуловой индекс
→ ArgumentOutOfRangeException, age-lead (нет карточек досок → Found=false и inbox не тронут; стареет
САМАЯ старая карточка досок на 15 дн., новая не тронута; более старая inbox-карточка игнорируется —
цель только доски; archiveAfterDays=1 → DaysAgo=2).
## Решения (зафиксированные)
1. **Метод создания на уровне порта уже был** (`IKanjStore.AddCardAsync(CardSnapshot)`, Task 2/4) —
CardsService.CreateAsync/CardCreateDto НЕ добавлялись (был бы мёртвый код): создание карточки собирает
DemoLeadFactory в полный снимок (как пайплайн этапа 4) и кладёт через порт — ровно по плану Task 13
(L443–446: «Добавление через IKanjStore.Add»).
2. **simulate-lead и age-lead — без тела запроса**, как в прототипе (dashboard_routes L292/L309):
simulate берёт случайный пресет демо-пула (random.choice L298), age состаривает самую старую карточку
досок. Формулировка задачи «тело {id, hours/days}» с планом не совпадает — план/прототип 1:1, что и
реализовано (границы состаривания считаются от настройки archiveAfterDays, не от тела).
3. **Порт дополнен двумя операциями состаривания** — плановый набор IKanjStore (T2) не предусматривал
«найти самую старую карточку досок» и «сдвинуть received_at», без них age-lead невозможен без SQL в
Api. Gap-fill минимален: GetOldestBoardCardAsync + UpdateReceivedAtAsync (только received_at).
4. **Age-логика живёт в DemoLeadFactory** (план назвал файл только «фабрикой», но состаривание — демо-путь
модуля; эндпоинт оркестрирует тик StorageTickService и SSE-публикации, Ruling 5 — модуль чист).
5. **Курсы при создании**: ratesCache с мок-дефолтом при отсутствии/повреждении (как CardsService
LoadRatesAsync / RatesService.GetAsync) — конверсия демо-карточки работает и до первого refresh курсов
(в отличие от ConversionRecomputer T12, который читает кэш напрямую по прототипу recompute).
6. **Флаг демо**: `DemoOptions.Enabled` из секции `Demo` (appsettings.json=false, Development=true);
`DEAL_DEMO=1` включает независимо от среды (аналог `LEADRADAR_DEMO=1` config.py). «Без флага — 404»
приёмкой проверено запуском в **Production** без DEAL_DEMO (Development по плану L452 включает демо для
разработчика); для Production-запуска строка подключения подана env `ConnectionStrings__DealPostgres`
(ConnectionStringProvider требует ключ конфигурации в любом окружении).
7. **Контакты демо-пула** — примитивная версия (план L444): только ветка tg `qualify_contact`; в пуле
иных типов нет. primary_contact для tg достаточно.
8. **SSE-проверка в curl** — реальным подписчиком: фоновый `curl -N` на /api/events, после simulate ×3
получено 3 события `new_lead` и toast «Демо: новый лид» (sparkles); после age-lead — toast «Демо:
карточка → Архив (старше 15 дн.)» (clock).
## Проверка
1. **Build**: `sh scripts/build.sh` — 0 ошибок / 0 предупреждений.
2. **Тесты**: `sh scripts/test.sh`**380/380 PASS** (369 → +11 DemoLeadFactoryTests).
3. **Curl-приёмка** (:5080, admin/admin; `task-13-curl-acceptance.log`): **PASS=40 FAIL=0** — 401 без куки
на simulate/age; login; simulate ×3 (полный §4.1: id l_/col inbox/isNew/title/summary/stack/budget/
contacts/ch/receivedAt/time/sourceDialogId/matchHits=[]); GET /leads?col=inbox — 3 карточки DESC;
age-lead без досок-карточек → 400 «Нет карточек на досках для демо»; создание доски + move карточки;
age-lead → 200 `{ok:true, stats:{archived:1,…}}`, psql: col=archive, received_at старше 14 дн.,
archived_at выставлен; restore из архива → inbox (isNew=true); move→trash→restore-цикл (restore из
корзины → на доску по prevCol); комментарий; counts (inbox count=2, learning>0, ml/ai=0); SSE
(3×new_lead + 2 toast-текста 1:1); лог Api без исключений; повторный запуск **Production без
DEAL_DEMO** → simulate/age → 404 «Демо-режим отключён». Демо-строки после приёмки удалены.
4. Диагностики: по изменённым C#-файлам ошибок/предупреждений нет (refresh показывает только pre-existing
lint прототипа `backend/*.py`, вне scope этапа — как в T10–T12).
## Concerns для Task 14+
- Сквозная проверка «демо-карточка → conv при поступлении» выполнена юнит-тестами (конверсия по
мок-курсам при отсутствии ratesCache); на живом сервере текущий ratesCache мог быть cbr/mock от T12 —
в curl значения conv не фиксировались (проверялись только поля/форма), точные цифры — в юнитах.
- Случайный пресет пула делает поиск/группировку по теме недетерминированными: T14 (suggest-эвристика)
потребует повторных simulate до накопления темы (напр. «Python») — предусмотрено планом T14.
- Production-запуск без appsettings.Development требует ConnectionStrings__DealPostgres env — это
ограничение ConnectionStringProvider, не демо-кода.
@@ -0,0 +1,266 @@
#!/usr/bin/env sh
# Task 14 curl-приёмка /api/ai/suggest-columns и /api/ai/suggest-keywords на :5080 (план Task 14 L487-491,
# Ruling 3; dashboard_routes.py L395-409; suggest.py L76-193). Сценарий: сброс kanban-таблиц → запуск
# Deal.Api с DEAL_DEMO=1 (Development) → 401 без куки на обоих suggest → login → «мало карточек» на пустом
# inbox (suggest-columns и suggest-keywords) → simulate-lead ×6 (демо-пул: python-вакансия/фронтенд/такси-
# бот) → suggest-columns: {ok:true, created≥1}, доски suggested=true с note в GET /boards, карточки в
# досках → повторный suggest-columns: {ok:false, cooldown} → PATCH suggested:false принят →
# suggest-keywords: {ok, keywords:[…]} → logout → 401. Очистка демо-строк после приёмки.
set -u
BASE_URL="http://localhost:5080"
API_DIR="C:/telbase/src/core/Deal.Api"
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
JAR="/tmp/task14-jar.txt"
OUT="/tmp/task14-out.txt"
LOG="/tmp/task14-api.log"
IDS="/tmp/task14-ids.txt"
BOARD_IDS="/tmp/task14-board-ids.txt"
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
SCHEMA="tenant_00000000000000000000000000000001"
PASS_COUNT=0
FAIL_COUNT=0
APP_PID=""
check() {
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
desc=$1
shift
ok=1
for pat in "$@"; do
if ! grep -qF -- "$pat" "$OUT"; then
ok=0
fi
done
if [ "$ok" = 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не найдено: $*"
echo "--- ответ:"
cat "$OUT"
fi
}
stop_app() {
if [ -n "$1" ] && kill -0 "$1" 2>/dev/null; then
kill "$1" 2>/dev/null
sleep 2
if netstat -ano 2>/dev/null | grep -q ':5080'; then
taskkill //F //PID "$1" 2>/dev/null
sleep 1
fi
fi
echo " [PASS] Deal.Api остановлен"
}
cleanup() {
echo
echo "== Завершение: остановка процесса и очистка демо-строк =="
stop_app "$APP_PID"
# Строки созданы приёмкой (таблицы перед стартом были пусты): карточки/комментарии/доски + lastSuggestAt.
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"LeadComments\";" >/dev/null 2>&1
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Cards\";" >/dev/null 2>&1
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Boards\";" >/dev/null 2>&1
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".settings WHERE \"Key\" = 'lastSuggestAt';" >/dev/null 2>&1
rm -f "$JAR" "$OUT" "$IDS" "$BOARD_IDS"
}
trap cleanup EXIT INT TERM
rm -f "$JAR" "$OUT" "$LOG" "$IDS" "$BOARD_IDS"
echo "== 0. Очистка kanban-таблиц дефолтного тенанта (повторяемость приёмки) =="
# Останавливаем «зависший» Deal.Api предыдущих запусков, если порт занят.
PID_5080=$(netstat -ano 2>/dev/null | grep ':5080' | grep -i listening | awk '{print $NF}' | head -1)
if [ -n "$PID_5080" ]; then
echo " [WARN] порт 5080 занят pid $PID_5080 — останавливаю"
taskkill //F //PID "$PID_5080" >/dev/null 2>&1
sleep 1
fi
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"CardMoves\";" >/dev/null
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"MlOutbox\";" >/dev/null
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"LeadComments\";" >/dev/null
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Cards\";" >/dev/null
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Boards\";" >/dev/null
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".settings WHERE \"Key\" IN ('colState','mlDecisions','aiDecisions','lastSuggestAt');" >/dev/null
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT (SELECT count(*) FROM \"$SCHEMA\".\"Boards\") + (SELECT count(*) FROM \"$SCHEMA\".\"Cards\") + (SELECT count(*) FROM \"$SCHEMA\".settings WHERE \"Key\" = 'lastSuggestAt');")
if [ "$ROWS_LEFT" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] kanban-таблицы пусты, lastSuggestAt сброшен"
else
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
exit 1
fi
echo
echo "== 1. Запуск Deal.Api на :5080 с DEAL_DEMO=1 (Development) =="
cd "$API_DIR" || exit 1
ASPNETCORE_ENVIRONMENT=Development DEAL_DEMO=1 "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
APP_PID=$!
i=0
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
i=$((i + 1))
if [ "$i" -ge 40 ]; then
echo " [FAIL] сервер не поднялся за 40 с (лог: $LOG)"
tail -n 30 "$LOG"
exit 1
fi
sleep 1
done
echo " [PASS] health: $(curl -s "$BASE_URL/api/health")"
echo
echo "== 2. 401 без сессии на suggest-эндпоинтах =="
curl -s -w "\n[HTTP:%{http_code}]" -X POST "$BASE_URL/api/ai/suggest-columns" > "$OUT"
check "suggest-columns без куки → 401" '[HTTP:401]' 'Требуется авторизация'
curl -s -w "\n[HTTP:%{http_code}]" -X POST "$BASE_URL/api/ai/suggest-keywords" > "$OUT"
check "suggest-keywords без куки → 401" '[HTTP:401]' 'Требуется авторизация'
echo
echo "== 3. Login admin/admin =="
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT"
check "login 200 ok" '[HTTP:200]' '"ok":true'
echo
echo "== 4. Пустой inbox: мягкие причины (HTTP 200 с ok:false) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ai/suggest-columns" > "$OUT"
check "suggest-columns на пустом inbox → {ok:false, мало карточек}" '[HTTP:200]' '"ok":false' 'мало карточек в «Неразобранном» (нужно от 6)'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ai/suggest-keywords" > "$OUT"
check "suggest-keywords на пустом inbox → {ok:false, нужно хотя бы 3}" '[HTTP:200]' '"ok":false' 'мало карточек — сначала накопите заявки (нужно хотя бы 3)'
echo
echo "== 5. simulate-lead ×6 — карточки в inbox (демо-пул: python/фронтенд/такси) =="
n=0
while [ "$n" -lt 6 ]; do
n=$((n + 1))
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/demo/simulate-lead" > "$OUT"
check "simulate-lead #$n 200" '[HTTP:200]' '"col":"inbox"'
sed -n '1{s/.*"id":"\(l_[0-9a-f][0-9a-f]*\)".*/\1/p}' "$OUT" >> "$IDS"
sleep 1
done
INBOX_COUNT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM \"$SCHEMA\".\"Cards\" WHERE \"Col\" = 'inbox';")
if [ "$INBOX_COUNT" = "6" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] psql: в inbox 6 демо-карточек"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] psql: ожидалось 6 карточек в inbox, получено $INBOX_COUNT"
fi
echo
echo "== 6. suggest-columns — созданы доски-предложения =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ai/suggest-columns" > "$OUT"
cat "$OUT"
echo
check "suggest-columns 200" '[HTTP:200]'
check "ok:true, created≥1" '"ok":true' '"created":'
echo
echo "== 7. GET /api/boards — доски suggested=true с note и карточками =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/boards" > "$OUT"
check "GET /boards 200" '[HTTP:200]'
check "доска suggested=true с note «Эвристика (этап 3)»" '"suggested":true' 'Эвристика (этап 3)'
check "правила доски {mode:any, keywords}" '"mode":"any"' '"keywords":['
grep -o '"id":"b_[0-9a-f]*"' "$OUT" | sed 's/"id":"//;s/"//' > "$BOARD_IDS"
SUGGESTED_COUNT=$(grep -c '"suggested":true' "$OUT")
if [ "$SUGGESTED_COUNT" -ge 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] suggested-досок: $SUGGESTED_COUNT"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] suggested-досок: 0 (ожидалось ≥1)"
fi
SUGGESTED_BOARD=$(sed -n '1p' "$BOARD_IDS")
echo " -> первая доска: $SUGGESTED_BOARD"
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/leads?col=$SUGGESTED_BOARD" > "$OUT"
CARD_COUNT=$(grep -o '"col":"'"$SUGGESTED_BOARD"'"' "$OUT" | wc -l | tr -d ' ')
if [ "$CARD_COUNT" -ge 2 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] в доске $SUGGESTED_BOARD карточек: $CARD_COUNT (matchHits по правилам)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] в доске $SUGGESTED_BOARD карточек: $CARD_COUNT (ожидалось ≥2)"
fi
check "карточки доски с matchHits" '"matchHits":['
echo
echo "== 8. Повторный suggest-columns — кулдаун =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ai/suggest-columns" > "$OUT"
check "повторный вызов → {ok:false, cooldown}" '[HTTP:200]' '"ok":false' '"cooldown":true' 'недавно предлагали — подождите'
echo
echo "== 9. PATCH suggested:false → колонка принята =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/boards/$SUGGESTED_BOARD" \
-H "Content-Type: application/json" -d '{"suggested":false}' > "$OUT"
check "PATCH suggested:false принят" '[HTTP:200]' '"id":"'"$SUGGESTED_BOARD"'"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/boards" > "$OUT"
check "доска стала обычной (suggested:false)" '[HTTP:200]' '"suggested":false'
echo
echo "== 10. suggest-keywords — {ok, keywords} =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ai/suggest-keywords" > "$OUT"
cat "$OUT"
echo
check "suggest-keywords 200" '[HTTP:200]'
check "{ok:true, keywords:[…]}" '"ok":true' '"keywords":['
echo
echo "== 11. Logout → 401 на suggest-эндпоинтах =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
check "logout 200 {ok:true}" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -X POST "$BASE_URL/api/ai/suggest-columns" > "$OUT"
check "suggest-columns после logout → 401" '[HTTP:401]' 'Требуется авторизация'
curl -s -w "\n[HTTP:%{http_code}]" -X POST "$BASE_URL/api/ai/suggest-keywords" > "$OUT"
check "suggest-keywords после logout → 401" '[HTTP:401]' 'Требуется авторизация'
echo
echo "== 12. psql: lastSuggestAt записан (кулдаун), карточки в досках, note в Boards =="
LAST_AT=$($PSQL_BASE -t -A -c "SELECT \"ValueJson\" FROM \"$SCHEMA\".settings WHERE \"Key\" = 'lastSuggestAt';")
case "$LAST_AT" in
''|*[!0-9]*) FAIL_COUNT=$((FAIL_COUNT + 1)); echo " [FAIL] lastSuggestAt не число: '$LAST_AT'";;
*)
NOW=$($PSQL_BASE -t -A -c "SELECT extract(epoch FROM now())::bigint;")
DELTA=$((NOW - LAST_AT))
if [ "$DELTA" -ge 0 ] && [ "$DELTA" -le 60 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] lastSuggestAt записан (эпоха-сек, $DELTA с назад)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] lastSuggestAt=$LAST_AT, now=$NOW (delta $DELTA)"
fi
;;
esac
NOTE_COUNT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM \"$SCHEMA\".\"Boards\" WHERE \"Note\" LIKE 'Эвристика (этап 3):%';")
if [ "$NOTE_COUNT" -ge 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] psql: suggested-доски с note «Эвристика (этап 3)»: $NOTE_COUNT"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] psql: досок с note-обоснованием: $NOTE_COUNT"
fi
echo
echo "== 13. Лог Api без исключений; итог =="
ERRORS=$(grep -c "Unhandled exception\|System\..*Exception" "$LOG")
if [ "$ERRORS" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] в логе Api нет исключений"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] в логе Api найдены исключения ($ERRORS)"
grep "Unhandled exception\|System\..*Exception" "$LOG" | head -n 10
fi
echo
echo "== ИТОГ: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
if [ "$FAIL_COUNT" != 0 ]; then
echo " [FAIL] есть проваленные проверки — см. выше"
exit 1
fi
echo " [PASS] все проверки Task 14 прошли"
@@ -0,0 +1,138 @@
# Task 14 — «ИИ-предложения — порт IColumnSuggester + эвристика + /api/ai/suggest-columns|keywords» — отчёт
Статус: **complete** (build 0/0, тесты 410/410 PASS: 380 → +30 новых, curl-приёмка :5080 PASS=32 FAIL=0, ×2 прогона).
План: `docs/superpowers/plans/2026-09-05-deal-stage3-kanban.md` Task 14 (L462491), Ruling 3 (L8596),
Ruling 5 (SSE-тост из эндпоинта, boards_changed не шлём), Ruling 2 (matchHits при раскладке); эталоны —
`backend/app/services/suggest.py` целиком (MIN_INBOX/COOLDOWN/строки причин, suggest_from_inbox L76163,
suggest_domain_keywords L166193, _similar_exists L5561, _assign_ids/_rollback L220248), api-map §3.2
L120121, `store.js` suggestColumns L10971113 / suggestDomainKeywords L16721680. Контекст «Готово»
подтверждён: `IKanjStore.ListInboxWithSourceAsync` уже существовал (Task 2/4 — метод заложен под эвристику
Ruling 3), колонки-предложения создаются через готовый `BoardsService.CreateBoardAsync` (suggested/note/rules,
Task 6), карточки раскладываются через `IKanjStore.UpdateColumnAsync` (Task 4), PATCH suggested:false уже
принимается BoardsEndpoints (Task 8).
## Файлы
### Создан — `src/core/Deal.Contracts/Integrations/` (Ruling 3 — порт в Contracts)
- `IColumnSuggester.cs` — порт: `SuggestColumnsAsync(ct)` / `SuggestKeywordsAsync(ct)`; на этапе 6
реализация заменяется gRPC-клиентом ai-service с тем же контрактом. Мягкие ошибки — Ok=false + Reason
(эндпоинт отвечает HTTP 200), без маппинга в 4xx/5xx.
- `Models/SuggestColumnsResultDto.cs` — record (Ok, Created, Reason, Cooldown); wire 1:1 с прототипом:
Created (0) и Cooldown (false) опускаются при дефолте, Reason — при null (JsonIgnoreCondition):
`{ok:true, created:N}` / `{ok:false, reason}` / кулдаун `{ok:false, reason, cooldown:true}`.
- `Models/SuggestKeywordsResultDto.cs` — record (Ok, Keywords, Reason); Keywords (null) и Reason (null)
опускаются: `{ok:true, keywords:[…]}` / `{ok:false, reason}`.
### Создан — `src/core/Deal.Modules.Kanban/Application/`
- `SuggestHeuristics.cs` — чистое ядро эвристики (без EF/HTTP/хранилища, детерминированное, Ordinal).
Пороги: MinInbox=6, MinInboxGroup=2, MaxText=12 (окно свежих по received_at DESC + id), MaxColumns=4,
MinWordLength=3, MaxKeywordLength=40, MaxKeywordsTotal=60, KeywordsSampleLimit=40, MinKeywordsSample=3
(1:1 suggest.py L4852/L132/L136/L176178/L191/L193). Токенизация: подряд букв (латиница/кириллица,
числа/знаки препинания разрывают), нижний регистр, стоп-слова (русские служебные + типовые обращения
«нужен/ищу/привет…» + английские служебные). `PlanColumns`: кандидаты — слова в ≥2 карточках окна,
минус похожие на существующие (suggested=false) доски (_similar_exists L5561: равенство/вхождение
регистронезависимо); порядок — частота ↓, длина ↓, лексикографически; жадная сборка групп с
«неразобранными» карточками (used_msg L129143), группа ≥2, лимит 4; note «Эвристика (этап 3):
слово-тема «X» встречается у N карточек; реальные предложения ИИ — этап 6», имя колонки — слово с
заглавной буквы. `SuggestDomainKeywords`: те же слова, частота ≥2 текстов (не вхождений), ≤60 шт.
- `Models/SuggestedColumnPlan.cs` — план одной колонки (Word, Name, CardIds, Note) — выход ядра.
### Создан — `src/core/Deal.Infrastructure/Integrations/LocalColumnSuggester.cs`
Адаптер порта (Ruling 3). `SuggestColumnsAsync`: кулдаун (KV `lastSuggestAt`, 20 мин = COOLDOWN_S L51,
ответ «недавно предлагали — подождите» + cooldown:true) → чтение inbox (`ListInboxWithSourceAsync`) →
< MIN_INBOX → «мало карточек в «Неразобранном» (нужно от 6)» → планы SuggestHeuristics → планов нет →
«похожие колонки уже есть или нечего сгруппировать» → создание досок через BoardsService (suggested=true,
RulesJson {mode:"any", keywords:[тема]}, keywords доски, note) → раскладка карточек (is_new=TRUE,
prev_col='inbox', archived_at=NULL, matchHits = ColumnRules.ComputeHits по правилам доски — Ruling 2);
журнал CardMoves/ML-сигналы НЕ пишутся (это предложение, не действие пользователя — suggest.py _assign_ids);
свежий снимок inbox перед раскладкой = страховка «карточку уже разобрали» (L231–233); пустая колонка
(placed==0) откатывается (L152–156); после created>0 пишется lastSuggestAt (L160). `SuggestKeywordsAsync`:
карточки вне trash/archive с текстом, свежие 40 (L172176) → <3 → «мало карточек — сначала накопите
заявки (нужно хотя бы 3)» → маркеры (пусто → «ИИ не смог выделить ключи — попробуйте ещё раз»).
### Создан — `src/core/Deal.Api/Endpoints/AiSuggestEndpoints.cs`
POST `/api/ai/suggest-columns` и POST `/api/ai/suggest-keywords`: 401-гейт (как demo/boards) → резолв
`IColumnSuggester` из RequestServices ПОСЛЕ проверки сессии → результат 1:1 (мягкие ошибки — HTTP 200 с
ok:false+reason). При ok:true suggest-columns — SSE-toast «ИИ предложил колонок: N — откройте и решите»
(sparkles, 1:1 L162); boards_changed НЕ шлём (Ruling 5). `Program.cs``app.MapAiSuggestEndpoints()`.
### Изменён — `src/core/Deal.Modules.Settings/Application/SettingsKeys.cs`
`LastSuggestAt = "lastSuggestAt"` (внутренний ключ, владелец — LocalColumnSuggester; KEY suggest.py L52).
### Изменён — `src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs`
`AddDealIntegrations`: `AddScoped<IColumnSuggester, LocalColumnSuggester>()` (зависимости scoped на
tenant-запрос; на этапе 6 адаптер заменяется gRPC-клиентом).
### Изменён — `src/core/tests/Deal.Tests.Unit/`
- `FakeKanjStore.cs` — реализован `ListInboxWithSourceAsync` (1:1 KanbanStore: inbox + непустой source_msg,
received_at DESC), добавлено свойство `Boards`; класс-доклад приведён (все методы порта реализованы).
- Создан `SuggestHeuristicsTests.cs` (+14): MIN_INBOX (5 карточек → пусто), группы по словам-темам
(python/такси: имя с заглавной, id карточек, note с N), стоп-слова не становятся темами, слово частоты 1
не собирает группу, нет общих слов → пусто, лимит 4 колонок при 5 темах (порядок лексикографический),
похожесть с существующей доской пропускает слово, окно MAX_TEXT=12 (старые python-карточки вне анализа),
детерминированность (прямой и перевёрнутый вход → одинаковый снимок), маркеры keywords (частота по
текстам, повтор в одном тексте не считается, пусто при отсутствии повторов, лимит 60, слово >40
символов не проходит).
- Создан `LocalColumnSuggesterTests.cs` (+11): пустой/малый inbox → «мало карточек…(нужно от 6)»; кулдаун
(Preload lastSuggestAt) → «недавно предлагали — подождите» + Cooldown; нет тем → «похожие колонки уже
есть…»; похожая существующая доска → то же + доска не тронута; успех (создание suggested-доски с
note/rules{mode:any,keywords}, раскладка: col доски/isNew/prevCol/matchHits, все 6 карточек разложены,
lastSuggestAt записан); успех → немедленный повтор → кулдаун; keywords: успех (маркеры), <3 карточек,
trash/archive не считаются, нет повторяющихся маркеров → причина.
- Создан `SuggestResultDtosTests.cs` (+5): wire-форма 1:1 (camelCase): успех columns — только ok+created;
мягкая ошибка — ok+reason без created/cooldown; кулдаун — cooldown:true; keywords — ok+keywords /
ok+reason. (HTTP-ветки 401/200 эндпоинтов — curl-приёмка, паттерн этапа.)
## Решения (зафиксированные)
1. **Кулдаун применён и к ручному вызову.** Прототип проверяет кулдаун только в автоцикле (force=true его
обходит), а автоцикл Ruling 3 не заводим. По Acceptance/инструкции (повторный вызов → ok:false с
reason/cooldown) кулдаун оставлен в единственном пути вызова: проверка идёт ДО чтения inbox, метка
пишется только после успешного прогона (повтор в течение 20 мин → «недавно предлагали — подождите»).
Проверки aiEnabled/pending-suggested досок (force-ветка прототипа их тоже обходит) не переносились.
2. **Группировка — жадная по частотности с «неразобранными» карточками** (1:1 used_msg L129143), а не
независимая кластеризация: карточка попадает ровно в одну колонку-предложение, каждая группа ≥2
карточек, лимит 4. При равенстве частот — более длинное слово (специфичнее), затем лексикографически;
имя колонки — слово с заглавной (python → Python). Известная черта эвристики: общее слово
пересекающихся тем («бота» у демо python-CRM и такси) может собрать одну широкую колонку — это
ожидаемо для этапа 3 («реальные предложения ИИ — этап 6» в note), пользователь решает судьбу колонки.
3. **Keywords-маркеры считаются по ТЕКСТАМ, а не по вхождениям**: слово, повторённое 3 раза в одном
сообщении, — частота 1 (иначе «частотность» искажается длинными сообщениями); порог маркера — ≥2
текстов (частотные маркеры, план L470–471), ≤60 шт., ≤40 симв.
4. **Раскладка через UpdateColumnAsync с matchHits по правилам созданной доски** (Ruling 2), без журнала
CardMoves/ML-push (прототип _assign_ids — чистая SQL-смена колонки). Страховка «карточку уже разобрали»
— свежий снимок inbox перед раскладкой (эквивалент SELECT ... AND col='inbox' на карточку, L231233);
пустая колонка-предложение откатывается (L242–248).
5. **DTO-поля не пишутся при дефолте/null** (JsonIgnore WhenWritingDefault/WhenWritingNull): ответы 1:1 с
dict прототипа (успех columns = {ok, created}, мягкая ошибка = {ok, reason}, кулдаун добавляет
cooldown:true), проверено wire-тестами.
## Тесты и сборка
- `dotnet build Deal.sln` — 0 warnings / 0 errors (TreatWarningsAsErrors).
- `dotnet test Deal.sln` — 410/410 PASS (было 380, +30 новых: SuggestHeuristics +14, LocalColumnSuggester
+11, SuggestResultDtos +5). Полный прогон чистый (затрагиваемые тесты Kanban/Settings не сломаны).
## Curl-приёмка :5080 (`task-14-curl-acceptance.sh` → `task-14-curl-acceptance.log`), PASS=32 FAIL=0 (×2)
Сброс kanban-таблиц + lastSuggestAt → запуск Deal.Api (Development, DEAL_DEMO=1) → **401** без куки на
suggest-columns/suggest-keywords → login admin/admin → пустой inbox: suggest-columns {ok:false,
reason «мало карточек в «Неразобранном» (нужно от 6)»} и suggest-keywords {ok:false, «…нужно хотя бы 3»}
(HTTP 200) → simulate-lead ×6 → **suggest-columns {ok:true, created:1}** → GET /api/boards: доска
suggested=true с note «Эвристика (этап 3)…», правила {mode:"any", keywords:[…]}, в доске карточки с
matchHits (psql: note в Boards, lastSuggestAt записан) → **повторный вызов {ok:false, cooldown:true,
«недавно предлагали — подождите»}** → **PATCH suggested:false → принят (200 {id}, доска стала обычной)**
**suggest-keywords {ok:true, keywords:[…]}** (без стоп-слов) → logout → **401** на обоих. В логе Api нет
исключений; демо-строки после приёмки очищены (0|0|0).
## Стиль
1 тип = 1 файл; XML-doc на public-контракты (русский); фиксированные строки причин — из прототипа/Ruling 3;
константы вместо магических чисел (пороги со ссылками на строки suggest.py); без регионов; явные
модификаторы; `KanbanColumns`/`SettingsKeys` вместо литералов; алиас `KanbanColumnRules` для одноимённых
класса/namespace (как CardsService).
## Concerns / на будущее
- Кулдаун 20 минут делает повторную *успешную* приёмку на тех же данных невозможной без сброса
`lastSuggestAt` (скрипт сбрасывает в шаге 0). Для Task 15 e2e достаточно одного успешного прогона.
- Демо-пул из 3 пресетов даёт эвристике пересекающиеся темы («бота»); сценарий acceptance с «Python»
воспроизводится не дословно, а по смыслу (повторяющаяся тема → доска-предложение с карточками).
В логе видно: 1-й прогон — 1 доска на 6 карточек, 2-й — 1 доска на 5 карточек (1 осталась в inbox).
- Эндпоинт-ветки (401/200/ok:false) покрыты curl-приёмкой (паттерн этапа: тонкие эндпоинты, логика — у
адаптера с unit-тестами); WebApplicationFactory в проекте не используется.
@@ -0,0 +1,665 @@
#!/usr/bin/env sh
# Task 15 curl-приёмка: сквозной сценарий канбана на :5080 (план Task 15 L493-507; Self-Review L509-534).
# Сценарий: сброс kanban-таблиц → схема-проверка (5 таблиц TenantKanban) → запуск Deal.Api с DEAL_DEMO=1
# (Development) → 401 без куки (boards/projects/events/demo) → login → boot-группы (boards [], leads
# {items:[]}, counts-нули, columns/state {}, projects {items:[]}, tg/status idle, settings/rates/ml 200) →
# SSE-подписка → simulate-lead ×6 (inbox 6, DESC, counts, psql) → создание доски + PATCH правил → move
# карточки (matchHits в ответе/psql) → trash → restore → комментарий → mark-col-seen (psql is_new=0) →
# search по тексту карточки → simulate ×4 (refill) → suggest-columns {ok, created≥1} + suggested-доски
# (note, карточки с matchHits) → PATCH suggested:false → simulate ×4 → suggest-keywords {ok, keywords} →
# age-lead (автоархив, psql archive) → POST /admin/tick (форма storage/reminders/pipeline/queue) → rates
# refresh + пересчёт conv (psql 9250 RUB / 100 USD / 92.59 EUR + restored) → фоновый StorageTickScheduler
# (просроченная карточка архивируется БЕЗ ручного tick) → SSE-разбор (14×new_lead, toasts) → logout → 401.
# В конце — очистка демо-строк (карточки/доски/комментарии/moves/outbox + служебные settings-ключи),
# схема/таблицы и ключи настроек (targetCurrency/rateSource/conversionOn/ratesCache) остаются.
set -u
BASE_URL="http://localhost:5080"
API_DIR="C:/telbase/src/core/Deal.Api"
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
WORK="/tmp/task15"
JAR="$WORK/jar.txt"
OUT="$WORK/out.txt"
LOG="$WORK/api.log"
SSE_FILE="$WORK/sse.txt"
SIM_DIR="$WORK/sims"
IDS="$WORK/ids.txt"
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
SCHEMA="tenant_00000000000000000000000000000001"
CONV_CARD="l_t15_conv"
SCHED_CARD="l_t15_sched"
PASS_COUNT=0
FAIL_COUNT=0
APP_PID=""
SSE_PID=""
CLEANED=0
check() {
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
desc=$1
shift
ok=1
for pat in "$@"; do
if ! grep -qF -- "$pat" "$OUT"; then
ok=0
fi
done
if [ "$ok" = 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не найдено: $*"
echo "--- ответ:"
cat "$OUT"
fi
}
check_absent() {
# $1 — описание; $2 — подстрока, которой НЕ должно быть в $OUT
desc=$1
pat=$2
if grep -qF -- "$pat" "$OUT"; then
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — найдено нежелательное: $pat"
else
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc"
fi
}
extract_first_id() {
# Первый id вида l_/b_ + 12 hex из первой строки $1
sed -n '1{s/.*"id":"\([a-z]_[0-9a-f][0-9a-f]*\)".*/\1/p}' "$1" | head -n 1
}
count_of() {
# $1 — файл; $2 — подстрока (регэксп)
grep -o "$2" "$1" | wc -l | tr -d ' '
}
stop_app() {
if [ -n "${1:-}" ] && kill -0 "$1" 2>/dev/null; then
kill "$1" 2>/dev/null
sleep 2
if netstat -ano 2>/dev/null | grep -q ':5080'; then
taskkill //F //PID "$1" 2>/dev/null
sleep 1
fi
fi
echo " [PASS] Deal.Api остановлен"
}
psql_delete_demo() {
# Демо-строки приёмки (карточки/доски/комментарии/moves/outbox + служебные settings-ключи).
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"CardMoves\";" >/dev/null 2>&1
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"MlOutbox\";" >/dev/null 2>&1
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"LeadComments\";" >/dev/null 2>&1
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Cards\";" >/dev/null 2>&1
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Boards\";" >/dev/null 2>&1
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".settings WHERE \"Key\" IN ('colState','lastSuggestAt','mlDecisions','aiDecisions');" >/dev/null 2>&1
}
cleanup() {
echo
echo "== Завершение (trap): остановка процесса и очистка демо-строк =="
if [ -n "$SSE_PID" ] && kill -0 "$SSE_PID" 2>/dev/null; then
kill "$SSE_PID" 2>/dev/null
SSE_PID=""
fi
if [ "$CLEANED" = "0" ]; then
stop_app "$APP_PID"
psql_delete_demo
fi
rm -rf "$WORK"
}
trap cleanup EXIT INT TERM
rm -rf "$WORK"
mkdir -p "$SIM_DIR"
touch "$IDS"
echo "== 0. Очистка kanban-таблиц дефолтного тенанта и проверка схемы (повторяемость приёмки) =="
PID_5080=$(netstat -ano 2>/dev/null | grep ':5080' | grep -i listening | awk '{print $NF}' | head -1)
if [ -n "$PID_5080" ]; then
echo " [WARN] порт 5080 занят pid $PID_5080 — останавливаю"
taskkill //F //PID "$PID_5080" >/dev/null 2>&1
sleep 1
fi
psql_delete_demo
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT (SELECT count(*) FROM \"$SCHEMA\".\"Boards\") + (SELECT count(*) FROM \"$SCHEMA\".\"Cards\") + (SELECT count(*) FROM \"$SCHEMA\".\"LeadComments\") + (SELECT count(*) FROM \"$SCHEMA\".\"CardMoves\") + (SELECT count(*) FROM \"$SCHEMA\".\"MlOutbox\") + (SELECT count(*) FROM \"$SCHEMA\".settings WHERE \"Key\" IN ('colState','lastSuggestAt','mlDecisions','aiDecisions'));")
if [ "$ROWS_LEFT" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] kanban-таблицы пусты, служебные settings-ключи сброшены"
else
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
exit 1
fi
TABLE_COUNT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM information_schema.tables WHERE table_schema = '$SCHEMA' AND table_name IN ('Boards','Cards','LeadComments','CardMoves','MlOutbox','settings');")
if [ "$TABLE_COUNT" = "6" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] схема $SCHEMA: таблицы Boards/Cards/LeadComments/CardMoves/MlOutbox/settings на месте"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] таблиц TenantKanban+settings в схеме: $TABLE_COUNT (ожидалось 6)"
fi
CARD_COLUMNS=$($PSQL_BASE -t -A -c "SELECT count(*) FROM information_schema.columns WHERE table_schema = '$SCHEMA' AND table_name = 'Cards' AND column_name IN ('Id','Col','IsNew','Title','Summary','StackJson','BudgetCur','ConvCur','ReceivedAt','PrevCol','MatchHitsJson','ArchivedAt');")
if [ "$CARD_COLUMNS" = "12" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] PascalCase-колонки Cards на месте (в т.ч. ConvCur/MatchHitsJson/ArchivedAt)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] PascalCase-колонок Cards найдено: $CARD_COLUMNS (ожидалось 12)"
fi
echo
echo "== 0a. Запуск Deal.Api на :5080 с DEAL_DEMO=1 (Development) =="
cd "$API_DIR" || exit 1
ASPNETCORE_ENVIRONMENT=Development DEAL_DEMO=1 "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
APP_PID=$!
i=0
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
i=$((i + 1))
if [ "$i" -ge 40 ]; then
echo " [FAIL] сервер не поднялся за 40 с (лог: $LOG)"
tail -n 30 "$LOG"
exit 1
fi
sleep 1
done
echo " [PASS] health: $(curl -s "$BASE_URL/api/health")"
sleep 2
echo
echo "== 1. 401 без сессии: boards/projects/events/demo =="
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/boards" > "$OUT"
check "GET /api/boards без куки → 401" '[HTTP:401]' 'Требуется авторизация'
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/projects" > "$OUT"
check "GET /api/projects без куки → 401" '[HTTP:401]' 'Требуется авторизация'
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/events" > "$OUT"
check "GET /api/events без куки → 401" '[HTTP:401]' 'Требуется авторизация'
curl -s -w "\n[HTTP:%{http_code}]" -X POST "$BASE_URL/api/demo/simulate-lead" > "$OUT"
check "POST /api/demo/simulate-lead без куки → 401" '[HTTP:401]' 'Требуется авторизация'
echo
echo "== 2. Login admin/admin =="
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT"
check "login 200 ok" '[HTTP:200]' '"ok":true' '"login":"admin"'
echo
echo "== 3. Boot-группы фронта: boards/leads/counts/columns-state/projects/tg-status/settings/rates/ml =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/boards" > "$OUT"
check "GET /boards → голый массив []" '[HTTP:200]' '[]'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/leads" > "$OUT"
check "GET /leads → {items:[]}" '[HTTP:200]' '"items":[]'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/leads/counts" > "$OUT"
check "GET /leads/counts → плоская форма нулей" '[HTTP:200]' '"new":0' '"learning":0' '"ml":0' '"ai":0'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/columns/state" > "$OUT"
check "GET /columns/state → {}" '[HTTP:200]' '{}'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects" > "$OUT"
check "GET /projects → boot-заглушка {items:[]}" '[HTTP:200]' '"items":[]'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/tg/status" > "$OUT"
check "GET /tg/status → boot-заглушка idle-форма" '[HTTP:200]' '"phase":"idle"' '"connected":false' '"keysSet":false'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/settings" > "$OUT"
check "GET /settings → 200 (снимок настроек)" '[HTTP:200]' '"targetCurrency"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/rates" > "$OUT"
check "GET /rates → 200" '[HTTP:200]'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/ml/status" > "$OUT"
check "GET /ml/status → 200 (заглушка)" '[HTTP:200]'
echo
echo "== 3a. Запоминаем исходные настройки конверсии (для восстановления в конце) =="
TC=$(grep -o '"targetCurrency":"[A-Z]*"' "$OUT" | head -n 1 | sed 's/.*:"//;s/"//')
RS=$(grep -o '"rateSource":"[a-z]*"' "$OUT" | head -n 1 | sed 's/.*:"//;s/"//')
CO=$(grep -o '"conversionOn":true\|"conversionOn":false' "$OUT" | head -n 1 | cut -d: -f2)
[ -z "$TC" ] && TC="RUB"
[ -z "$RS" ] && RS="cbr"
[ -z "$CO" ] && CO=true
echo " [INFO] исходные: targetCurrency=$TC rateSource=$RS conversionOn=$CO"
echo
echo "== 4. SSE-подписка на GET /api/events (фон, до simulate) =="
curl -s -N -b "$JAR" "$BASE_URL/api/events" > "$SSE_FILE" 2>/dev/null &
SSE_PID=$!
sleep 1
if [ -n "$SSE_PID" ] && kill -0 "$SSE_PID" 2>/dev/null; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] SSE-поток открыт (pid $SSE_PID)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] SSE-поток не поднялся"
fi
simulate_once() {
# $1 — номер; ответ уходит в $SIM_DIR/sim$1.json; id дописывается в $IDS
n=$1
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/demo/simulate-lead" > "$OUT"
if ! grep -qF '[HTTP:200]' "$OUT" || ! grep -qF '"col":"inbox"' "$OUT"; then
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] simulate-lead #$n: не 200/не inbox"
cat "$OUT"
return
fi
sed -n '1p' "$OUT" > "$SIM_DIR/sim$n.json"
echo "$(extract_first_id "$OUT")" >> "$IDS"
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] simulate-lead #$n 200 (inbox)"
sleep 1
}
echo
echo "== 5. simulate-lead ×6 — карточки в inbox (полный объект §4.1) =="
n=0
while [ "$n" -lt 6 ]; do
n=$((n + 1))
simulate_once "$n"
done
CARD6=$(sed -n '6p' "$IDS")
echo " -> id последней (6): $CARD6"
echo
echo "== 6. GET /api/leads?col=inbox — 6 карточек, сортировка DESC; counts; psql =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/leads?col=inbox" > "$OUT"
check "GET inbox 200" '[HTTP:200]'
INBOX_COUNT=$(count_of "$OUT" '"col":"inbox"')
if [ "$INBOX_COUNT" = "6" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] в inbox 6 карточек"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] в inbox карточек: $INBOX_COUNT (ожидалось 6)"
fi
FIRST_ID=$(grep -o '"id":"l_[0-9a-f]*"' "$OUT" | head -n 1 | sed 's/.*:"//;s/"$//')
if [ "$FIRST_ID" = "$CARD6" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] первая карточка — последняя созданная ($CARD6): received_at DESC"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] первая карточка $FIRST_ID, ожидалась $CARD6 (DESC)"
fi
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/leads/counts" > "$OUT"
check "counts: learning/ml/ai на месте" '"learning":' '"ml":0' '"ai":0'
check "counts: inbox {count:6}" '"inbox":{"count":6'
PSQL_INBOX=$($PSQL_BASE -t -A -c "SELECT count(*) FROM \"$SCHEMA\".\"Cards\" WHERE \"Col\" = 'inbox';")
if [ "$PSQL_INBOX" = "6" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] psql: в Cards 6 строк inbox"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] psql: inbox строк: $PSQL_INBOX"
fi
echo
echo "== 7. Тема карточки #6, создание доски + PATCH правил =="
if grep -qF 'Python-разработчик' "$SIM_DIR/sim6.json"; then
KW="python"
elif grep -qF 'Frontend-разработчик' "$SIM_DIR/sim6.json"; then
KW="frontend"
else
KW="такси"
fi
echo " -> тема карточки #6: $KW"
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/boards" \
-H "Content-Type: application/json" -d "{\"name\":\"T15 $KW\"}" > "$OUT"
check "create board 200 {id:b_}" '[HTTP:200]' '"id":"b_'
BOARD_ID=$(extract_first_id "$OUT")
echo " -> board id: $BOARD_ID"
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/boards/$BOARD_ID" \
-H "Content-Type: application/json" -d "{\"rules\":{\"mode\":\"any\",\"keywords\":[\"$KW\"]}}" > "$OUT"
check "PATCH rules {mode:any, keywords} → {id}" '[HTTP:200]' "\"id\":\"$BOARD_ID\""
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/boards" > "$OUT"
check "GET boards: правила доски разобраны" "\"id\":\"$BOARD_ID\"" "\"keywords\":[\"$KW\"]"
PSQL_RULES=$($PSQL_BASE -t -A -c "SELECT \"KeywordsJson\" IS NOT NULL FROM \"$SCHEMA\".\"Boards\" WHERE \"Id\" = '$BOARD_ID';")
if [ "$PSQL_RULES" = "t" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] psql: доска создана (KeywordsJson на месте)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] psql: доски нет/без KeywordsJson: $PSQL_RULES"
fi
echo
echo "== 8. move карточки $CARD6 на доску — matchHits непусто (ответ + psql) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/leads/$CARD6/move" \
-H "Content-Type: application/json" -d "{\"to\":\"$BOARD_ID\"}" > "$OUT"
check "move 200 → col=доска, isNew=false" '[HTTP:200]' "\"col\":\"$BOARD_ID\"" '"isNew":false'
check "move: matchHits непусто (почему в колонке)" '"matchHits":[{'
PSQL_HITS=$($PSQL_BASE -t -A -c "SELECT (\"MatchHitsJson\" ILIKE '%$KW%') FROM \"$SCHEMA\".\"Cards\" WHERE \"Id\" = '$CARD6';")
if [ "$PSQL_HITS" = "t" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] psql: MatchHitsJson карточки содержит терм «$KW»"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] psql: MatchHitsJson без «$KW»: $PSQL_HITS ($( $PSQL_BASE -t -A -c "SELECT \"MatchHitsJson\" FROM \"$SCHEMA\".\"Cards\" WHERE \"Id\" = '$CARD6';" ))"
fi
echo
echo "== 9. trash → restore → комментарий (полный цикл) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/leads/$CARD6/trash" > "$OUT"
check "trash 200 {ok:true}" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/leads?col=trash" > "$OUT"
check "GET trash содержит карточку" '[HTTP:200]' "\"id\":\"$CARD6\""
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/leads/$CARD6/restore" > "$OUT"
check "restore из корзины → на доску (prevCol)" '[HTTP:200]' '"ok":true' "\"col\":\"$BOARD_ID\""
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/leads/$CARD6/comments" \
-H "Content-Type: application/json" -d '{"text":"T15 e2e comment"}' > "$OUT"
check "комментарий добавлен (by=Вы)" '[HTTP:200]' '"text":"T15 e2e comment"' '"by":"Вы"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/leads/$CARD6" > "$OUT"
check "GET карточки: комментарий в comments" '[HTTP:200]' '"text":"T15 e2e comment"'
echo
echo "== 10. mark-col-seen по inbox — is_new снят (psql) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/leads/mark-col-seen" \
-H "Content-Type: application/json" -d '{"col":"inbox"}' > "$OUT"
check "mark-col-seen inbox → {ok:true}" '[HTTP:200]' '"ok":true'
PSQL_NEW=$($PSQL_BASE -t -A -c "SELECT count(*) FROM \"$SCHEMA\".\"Cards\" WHERE \"Col\" = 'inbox' AND \"IsNew\" = true;")
if [ "$PSQL_NEW" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] psql: в inbox нет карточек is_new=true после mark-col-seen"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] psql: inbox is_new=true: $PSQL_NEW"
fi
echo
"== 11. search по тексту карточки (ASCII-маркер темы: кириллица в query-строке curl/MSYS не проходит) =="
case "$KW" in
python) SEARCH_Q="python";;
frontend) SEARCH_Q="frontend";;
taxi) SEARCH_Q="taxi_owner";;
esac
echo " -> q = $SEARCH_Q (карточка #6: тема $KW)"
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" --get --data-urlencode "q=$SEARCH_Q" "$BASE_URL/api/search" > "$OUT"
check "search 200" '[HTTP:200]'
check "search: карточка найдена в leads" "\"id\":\"$CARD6\""
check "search: messages:[] (заглушка этапа)" '"messages":[]'
echo
echo "== 12. simulate ×4 (refill inbox для эвристики ≥6) =="
n=6
while [ "$n" -lt 10 ]; do
n=$((n + 1))
simulate_once "$n"
done
echo
echo "== 13. suggest-columns — {ok:true, created≥1} + SSE-toast =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ai/suggest-columns" > "$OUT"
check "suggest-columns 200" '[HTTP:200]'
check "ok:true, created≥1" '"ok":true' '"created":'
echo
echo "== 14. suggested-доски: note «Эвристика», карточки с matchHits =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/boards" > "$OUT"
check "GET boards: есть suggested:true + note" '[HTTP:200]' '"suggested":true' 'Эвристика (этап 3)'
SUGGESTED_COUNT=$(grep -c '"suggested":true' "$OUT")
if [ "$SUGGESTED_COUNT" -ge 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] suggested-досок: $SUGGESTED_COUNT"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] suggested-досок: 0"
fi
NOTE_COUNT=$($PSQL_BASE -t -A -c "SELECT count(*) FROM \"$SCHEMA\".\"Boards\" WHERE \"Suggested\" = true AND \"Note\" LIKE 'Эвристика (этап 3):%';")
if [ "$NOTE_COUNT" -ge 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] psql: досок suggested=true с note: $NOTE_COUNT"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] psql: досок suggested=true с note: $NOTE_COUNT"
fi
SUGGESTED_ID=$($PSQL_BASE -t -A -c "SELECT \"Id\" FROM \"$SCHEMA\".\"Boards\" WHERE \"Suggested\" = true ORDER BY \"Position\" LIMIT 1;")
echo " -> suggested board: $SUGGESTED_ID"
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/leads?col=$SUGGESTED_ID" > "$OUT"
S_BOARD_COUNT=$(count_of "$OUT" '"col":"'"$SUGGESTED_ID"'"')
if [ "$S_BOARD_COUNT" -ge 2 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] в suggested-доске карточек: $S_BOARD_COUNT (эвристика: ≥2)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] в suggested-доске карточек: $S_BOARD_COUNT (ожидалось ≥2)"
fi
check "карточки suggested-доски с matchHits" '"matchHits":[{'
echo
echo "== 15. PATCH suggested:false — колонка принята =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/boards/$SUGGESTED_ID" \
-H "Content-Type: application/json" -d '{"suggested":false}' > "$OUT"
check "PATCH suggested:false → {id}" '[HTTP:200]' "\"id\":\"$SUGGESTED_ID\""
PSQL_SUG=$($PSQL_BASE -t -A -c "SELECT \"Suggested\"::text FROM \"$SCHEMA\".\"Boards\" WHERE \"Id\" = '$SUGGESTED_ID';")
if [ "$PSQL_SUG" = "false" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] psql: доска принята (suggested=false)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] psql: suggested=$PSQL_SUG (ожидалось false)"
fi
echo
echo "== 16. simulate ×4 (свежие карточки для suggest-keywords) =="
n=10
while [ "$n" -lt 14 ]; do
n=$((n + 1))
simulate_once "$n"
done
echo
echo "== 17. suggest-keywords — {ok:true, keywords:[…]} =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/ai/suggest-keywords" > "$OUT"
check "suggest-keywords 200" '[HTTP:200]'
check "{ok:true, keywords непусто}" '"ok":true' '"keywords":['
echo
echo "== 18. age-lead — состаривание + автоархив (psql) =="
ARCHIVE_BEFORE=$($PSQL_BASE -t -A -c "SELECT count(*) FROM \"$SCHEMA\".\"Cards\" WHERE \"Col\" = 'archive';")
echo " -> archive до age-lead: $ARCHIVE_BEFORE"
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/demo/age-lead" > "$OUT"
check "age-lead 200 {ok:true}" '[HTTP:200]' '"ok":true'
sleep 2
ARCHIVE_ROW=$($PSQL_BASE -t -A -c "SELECT count(*) FROM \"$SCHEMA\".\"Cards\" WHERE \"Col\" = 'archive' AND \"ArchivedAt\" IS NOT NULL AND \"ReceivedAt\" < now() - interval '14 days';")
if [ "$ARCHIVE_ROW" = "1" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] psql: ровно 1 карточка в archive (старше 14 дн., ArchivedAt выставлен)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] psql: карточек archive старше 14 дн.: $ARCHIVE_ROW (ожидалось 1, до было $ARCHIVE_BEFORE)"
fi
echo
echo "== 19. POST /api/admin/tick — форма {storage, reminders, pipeline, queue} =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/admin/tick" > "$OUT"
check "admin/tick 200" '[HTTP:200]'
check "storage-форма (архивировать больше нечего)" '"storage":{"archived":0,"purgedArchive":0,"purgedTrash":0,"purgedRejected":0}'
check "reminders:[] (этап 5) / pipeline:{} (этап 4) / queue:0 (этап 4)" '"reminders":[]' '"pipeline":{}' '"queue":0'
echo
echo "== 20. Курсы: refresh (mock) + пересчёт конверсий (Ruling 7), psql =="
$PSQL_BASE -c "INSERT INTO \"$SCHEMA\".\"Cards\"
(\"Id\",\"Col\",\"IsNew\",\"IsVacancy\",\"IsVacancyKnown\",\"Title\",\"Summary\",\"StackJson\",
\"BudgetFrom\",\"BudgetTo\",\"BudgetCur\",\"ConvCur\",\"Contact\",\"ContactsJson\",\"ChannelName\",
\"ChannelHandle\",\"ChannelHue\",\"ReceivedAt\",\"SourceMsg\",\"SourceDialogId\",\"PrevCol\",
\"MatchHitsJson\",\"CreatedAt\")
VALUES ('$CONV_CARD','inbox',true,false,false,'','','[]',
100,100,'USD','','','[]','','','',
now(),'','','','[]', now());" >/dev/null
CONV_BEFORE=$($PSQL_BASE -t -A -c "SELECT COALESCE(\"ConvFrom\"::text,'') || '|' || COALESCE(\"ConvTo\"::text,'') || '|' || \"ConvCur\" FROM \"$SCHEMA\".\"Cards\" WHERE \"Id\" = '$CONV_CARD';")
if [ "$CONV_BEFORE" = "||" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] psql: карточка $CONV_CARD вставлена (conv пуст до пересчёта)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] psql: conv до пересчёта '$CONV_BEFORE'"
fi
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" -d '{"rateSource":"mock"}' > "$OUT"
check "PATCH rateSource mock 200" '"rateSource":"mock"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/rates/refresh" > "$OUT"
check "rates/refresh ok=true source=mock" '"ok":true' '"source":"mock"'
CONV_RUB=$($PSQL_BASE -t -A -c "SELECT COALESCE(\"ConvFrom\"::text,'') || '|' || COALESCE(\"ConvTo\"::text,'') || '|' || \"ConvCur\" FROM \"$SCHEMA\".\"Cards\" WHERE \"Id\" = '$CONV_CARD';")
if [ "$CONV_RUB" = "9250|9250|RUB" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] psql: refresh → conv 100 USD = 9250 RUB"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] psql: conv после refresh '$CONV_RUB' (ожидалось 9250|9250|RUB)"
fi
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/leads/$CONV_CARD" > "$OUT"
check "API: карточка с converted (target RUB)" '"converted":{' '"cur":"RUB"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" -d '{"targetCurrency":"USD"}' > "$OUT"
check "PATCH targetCurrency USD 200" '"targetCurrency":"USD"'
CONV_USD=$($PSQL_BASE -t -A -c "SELECT COALESCE(\"ConvFrom\"::text,'') || '|' || COALESCE(\"ConvTo\"::text,'') || '|' || \"ConvCur\" FROM \"$SCHEMA\".\"Cards\" WHERE \"Id\" = '$CONV_CARD';")
if [ "$CONV_USD" = "100|100|USD" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] psql: PATCH targetCurrency USD → conv 100 USD (идентичность)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] psql: conv после USD '$CONV_USD'"
fi
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" -d '{"targetCurrency":"EUR"}' > "$OUT"
check "PATCH targetCurrency EUR 200" '"targetCurrency":"EUR"'
CONV_EUR=$($PSQL_BASE -t -A -c "SELECT COALESCE(\"ConvFrom\"::text,'') || '|' || COALESCE(\"ConvTo\"::text,'') || '|' || \"ConvCur\" FROM \"$SCHEMA\".\"Cards\" WHERE \"Id\" = '$CONV_CARD';")
if [ "$CONV_EUR" = "92.59|92.59|EUR" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] psql: PATCH targetCurrency EUR → conv 92.59 EUR"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] psql: conv после EUR '$CONV_EUR'"
fi
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/settings" \
-H "Content-Type: application/json" -d "{\"targetCurrency\":\"$TC\",\"conversionOn\":$CO,\"rateSource\":\"$RS\"}" > "$OUT"
check "настройки конверсии восстановлены" "\"targetCurrency\":\"$TC\"" "\"rateSource\":\"$RS\""
sleep 2
CONV_RESTORED=$($PSQL_BASE -t -A -c "SELECT \"ConvCur\" FROM \"$SCHEMA\".\"Cards\" WHERE \"Id\" = '$CONV_CARD';")
if [ "$CONV_RESTORED" = "RUB" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] psql: после восстановления targetCurrency conv снова RUB"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] psql: ConvCur после восстановления '$CONV_RESTORED'"
fi
echo
echo "== 21. Фоновый StorageTickScheduler: просроченная карточка архивируется БЕЗ ручного tick =="
$PSQL_BASE -c "INSERT INTO \"$SCHEMA\".\"Cards\"
(\"Id\",\"Col\",\"IsNew\",\"IsVacancy\",\"IsVacancyKnown\",\"Title\",\"Summary\",\"StackJson\",
\"BudgetCur\",\"ConvCur\",\"Contact\",\"ContactsJson\",\"ChannelName\",\"ChannelHandle\",\"ChannelHue\",
\"ReceivedAt\",\"SourceMsg\",\"SourceDialogId\",\"PrevCol\",\"MatchHitsJson\",\"CreatedAt\")
VALUES ('$SCHED_CARD','inbox',true,false,false,'','','[]','','','','[]','','','',
now() - interval '20 days','','','','[]', now());" >/dev/null
echo " [INFO] $SCHED_CARD вставлена (received_at −20 дн.), ждём проход цикла (≤30 с)…"
SCHED_COL="inbox"
i=0
while [ "$i" -lt 12 ]; do
sleep 5
i=$((i + 1))
SCHED_COL=$($PSQL_BASE -t -A -c "SELECT \"Col\" FROM \"$SCHEMA\".\"Cards\" WHERE \"Id\" = '$SCHED_CARD';")
if [ "$SCHED_COL" = "archive" ]; then
break
fi
done
SCHED_AT=$($PSQL_BASE -t -A -c "SELECT \"ArchivedAt\" IS NOT NULL FROM \"$SCHEMA\".\"Cards\" WHERE \"Id\" = '$SCHED_CARD';")
if [ "$SCHED_COL" = "archive" ] && [ "$SCHED_AT" = "t" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] автоархив фоновым циклом: col=$SCHED_COL, ArchivedAt set (через $((i * 5)) с)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] автоархив не сработал за 60 с: col=$SCHED_COL, ArchivedAt=$SCHED_AT"
fi
echo
echo "== 22. SSE-разбор: new_lead ×14 и тосты =="
kill "$SSE_PID" 2>/dev/null
SSE_PID=""
sleep 1
NEW_LEAD_COUNT=$(grep -c '^event: new_lead' "$SSE_FILE")
if [ "$NEW_LEAD_COUNT" = "14" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] SSE: 14 событий new_lead (simulate ×14 в открытом потоке)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] SSE: событий new_lead: $NEW_LEAD_COUNT (ожидалось 14)"
fi
if grep -qF 'Демо: новый лид' "$SSE_FILE"; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] SSE: toast «Демо: новый лид»"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] SSE: toast «Демо: новый лид» не найден"
fi
if grep -qF 'ИИ предложил колонок' "$SSE_FILE"; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] SSE: toast suggest-columns «ИИ предложил колонок…»"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] SSE: toast suggest-columns не найден"
fi
CLOCK_TOASTS=$(grep -c '"icon":"clock"' "$SSE_FILE")
if [ "$CLOCK_TOASTS" -ge 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] SSE: toast(ы) с иконкой clock (архив: $CLOCK_TOASTS)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] SSE: toast с иконкой clock не найден"
fi
echo
echo "== 23. Logout → 401 на boards/suggest/demo =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
check "logout 200 {ok:true}" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/boards" > "$OUT"
check "boards после logout → 401" '[HTTP:401]' 'Требуется авторизация'
curl -s -w "\n[HTTP:%{http_code}]" -X POST "$BASE_URL/api/ai/suggest-keywords" > "$OUT"
check "suggest-keywords после logout → 401" '[HTTP:401]' 'Требуется авторизация'
curl -s -w "\n[HTTP:%{http_code}]" -X POST "$BASE_URL/api/demo/simulate-lead" > "$OUT"
check "simulate-lead после logout → 401" '[HTTP:401]' 'Требуется авторизация'
echo
echo "== 24. Лог Api без исключений =="
ERRORS=$(grep -c "Unhandled exception\|System\..*Exception" "$LOG")
if [ "$ERRORS" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] в логе Api нет исключений"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] в логе Api исключения ($ERRORS):"
grep "Unhandled exception\|System\..*Exception" "$LOG" | head -n 10
fi
echo
echo "== 25. Очистка dev-БД: демо-строки удалены, схема/таблицы и настройки остаются =="
stop_app "$APP_PID"
APP_PID=""
psql_delete_demo
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT (SELECT count(*) FROM \"$SCHEMA\".\"Boards\") + (SELECT count(*) FROM \"$SCHEMA\".\"Cards\") + (SELECT count(*) FROM \"$SCHEMA\".\"LeadComments\") + (SELECT count(*) FROM \"$SCHEMA\".\"CardMoves\") + (SELECT count(*) FROM \"$SCHEMA\".\"MlOutbox\") + (SELECT count(*) FROM \"$SCHEMA\".settings WHERE \"Key\" IN ('colState','lastSuggestAt','mlDecisions','aiDecisions'));")
if [ "$ROWS_LEFT" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] демо-данные очищены (карточки/доски/комментарии/moves/outbox + служебные ключи)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
fi
TABLE_COUNT_END=$($PSQL_BASE -t -A -c "SELECT count(*) FROM information_schema.tables WHERE table_schema = '$SCHEMA' AND table_name IN ('Boards','Cards','LeadComments','CardMoves','MlOutbox','settings');")
SETTINGS_KEYS_END=$($PSQL_BASE -t -A -c "SELECT count(*) FROM \"$SCHEMA\".settings WHERE \"Key\" IN ('conversionOn','rateSource','ratesCache','targetCurrency');")
if [ "$TABLE_COUNT_END" = "6" ] && [ "$SETTINGS_KEYS_END" = "4" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] схема/таблицы и ключи настроек конверсии на месте"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] таблиц=$TABLE_COUNT_END (ожид. 6), settings-ключей=$SETTINGS_KEYS_END (ожид. 4)"
fi
CLEANED=1
echo
echo "== ИТОГ: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
if [ "$FAIL_COUNT" != 0 ]; then
echo " [FAIL] есть проваленные проверки — см. выше"
exit 1
fi
echo " [PASS] все проверки Task 15 (сквозная приёмка этапа 3) прошли"
@@ -0,0 +1,87 @@
# Task 15 — «Финал этапа — интеграция и сквозная приёмка» — отчёт
Статус: **complete (review pending)**. Build 0/0, unit-тесты **410/410 PASS**, сквозная curl-приёмка на
:5080 **PASS=94 FAIL=0** (`task-15-curl-acceptance.sh` + `task-15-curl-acceptance.log`). План:
`docs/superpowers/plans/2026-09-05-deal-stage3-kanban.md` Task 15 (L493507) + Self-Review (L509534).
Код/конфиги не менялись (только доки, ledger и артефакты приёмки — по заданию).
## Сквозной сценарий (curl, DEAL_DEMO=1, Development, :5080) — PASS=94 FAIL=0
Полный прогон в `task-15-curl-acceptance.log`. Ключевые вехи:
- **Схема/чистота (psql)**: перед стартом таблицы пусты; в схеме `tenant_000…0001` на месте
`Boards/Cards/LeadComments/CardMoves/MlOutbox/settings` (6), PascalCase-колонки `Cards` (12: в т.ч.
`ConvCur`, `MatchHitsJson`, `ArchivedAt`).
- **401-гейты без куки**: `/boards`, `/projects`, `/events` (SSE), `/demo/simulate-lead`.
- **Boot-группы фронта**: `/boards` `[]`, `/leads` `{items:[]}`, `/leads/counts` нули (плоская форма),
`/columns/state` `{}`, `/projects` `{items:[]}`, `/tg/status` idle-форма, `/settings`, `/rates`,
`/ml/status` — все 200.
- **Демо-карточки**: simulate ×14 (SSE-подписка открыта заранее): каждая — полный §4.1 в inbox;
после первых 6: `GET /leads?col=inbox` — 6 карточек DESC (первая = последняя созданная); counts
`inbox {count:6}`; psql 6 строк.
- **Доска + правила**: POST `/boards` (name) → PATCH rules `{mode:any, keywords:[тема]}` → в GET /boards
правила разобраны; move карточки → `matchHits` непусто (`"matchHits":[{`) и psql `MatchHitsJson`
содержит терм.
- **Полный цикл карточки**: trash → в корзине → restore → на доску (prevCol) → комментарий (by=«Вы») →
в `comments` карточки; mark-col-seen inbox → psql `is_new=true` в inbox = 0.
- **Поиск**: `GET /api/search?q=` (ASCII-маркер темы карточки) → карточка найдена в `leads`,
`messages:[]`.
- **ИИ-эвристика**: suggest-columns `{ok:true, created≥1}` (в ответе) → 1 доска `suggested:true` с note
«Эвристика (этап 3):…» и 7 карточками с matchHits; PATCH `suggested:false` принят (psql
`Suggested=false`); suggest-keywords `{ok:true, keywords:[…]}`.
- **Хранение**: age-lead `{ok:true}` → psql ровно 1 карточка в archive (старше 14 дн., `ArchivedAt`
выставлен); `POST /api/admin/tick``{storage:{…0}, reminders:[], pipeline:{}, queue:0}`; фоновый
**StorageTickScheduler** без ручного tick архивировал просроченную карточку (−20 дн.) за ~25 с.
- **Конверсии (Ruling 7)**: карточка 100 USD (psql-insert, как в T12) → после PATCH rateSource mock +
POST `/rates/refresh` conv = `9250|9250|RUB`; PATCH targetCurrency USD → `100|100|USD`; EUR →
`92.59|92.59|EUR`; восстановление исходных настроек (RUB/cbr/conversionOn=true) → ConvCur снова RUB;
на карточке в API `converted:{…cur:"RUB"}`.
- **SSE**: в открытом потоке `GET /api/events` — ровно 14 `new_lead` (simulate ×14) + тосты «Демо: новый
лид» (sparkles), «ИИ предложил колонок…» и toast(ы) с `icon:"clock"` (автоархив).
- **Logout** → 401 на boards/suggest/demo; лог Api без исключений.
- **Состояние dev-БД после приёмки**: демо-данные очищены (карточки/доски/комментарии/moves/MlOutbox +
служебные settings-ключи colState/lastSuggestAt/mlDecisions/aiDecisions), схема/таблицы и ключи
настроек конверсии (`targetCurrency`/`rateSource`/`conversionOn`/`ratesCache`) остались.
## Выводы/нюансы приёмки
1. Поиск работает (lower-LIKE по title/summary/contact/source_msg), но **кириллица в query-строке не
проходит через curl/MSYS** (ASCII `q=Python` находит карточку, `q=бота`/полный кириллический title —
нет); в сценарии использован ASCII-маркер темы карточки (`python`/`frontend`/`taxi_owner` по контакту).
Для фронта (браузер, корректный percent-encoding) это не ограничение.
2. Две ошибки были в самом acceptance-скрипте (не в коде): сравнение psql-булевых с `::text` (`true` vs
`t`) и неверная колонка сортировки досок `Pos` (реальная — `Position`) — исправлены в финальной версии.
3. age-lead и фоновый цикл архивируют через общий `StorageTickService`; автоархив фоновым циклом
подтверждён отдельно (без ручного tick), т.к. age-lead тикает сразу внутри себя.
4. Поведение 1:1 с прототипом подтверждено на живых ответах: matchHits, flat-counts, SSE-события,
storage-форма admin/tick, заглушки projects/tg-status — как в api-map/self-review.
## Изменения доков
- `docs/technical/Техническая-документация-Дейл.md`: §11 — блок «Выполнено на этапе 3» (миграция
TenantKanban, таблицы/колонки, эндпоинты, SSE, демо-режим, ML, 410 PASS) и актуализирован TODO
(boot() удовлетворён); §13 — заголовок «актуально для этапа 3», вводный абзац, новый §13.4c «Эндпоинты
этапа 3 (канбан/дашборд)» (доски/карточки/поиск/SSE new_lead+toast/admin/demo/ai-suggest/конверсии),
§13.5 «Проверка схем» дополнен таблицами канбана и колонками Cards, §13.6 — ожидание 410 PASS.
- `docs/superpowers/plans/2026-09-05-deal-roadmap.md`: этап 3 вынесен в «Выполнено» (с ограничениями:
pipeline/отсев/FTS — этап 4, projects/файлы/reminder_due — этап 5, реальные ai/tg/ml/discovery —
этап 6; фронт boot'ится, дашборд работает на демо-данных этапа 4+); заголовок — «на конец этапа 3»;
из «Оставшихся этапов» блок этапа 3 удалён.
- `.superpowers/sdd/deal-stage3-kanban/progress.md`: строка «Task 15: complete (review pending).
Отчёт: task-15-report.md.» + todo `[x]`.
## Итоги build/test
- `dotnet build Deal.sln` — 0 предупреждений / 0 ошибок.
- `dotnet test Deal.sln` — 410 PASS, 0 failed, 0 skipped.
- `sh scripts/build.sh` и `sh scripts/test.sh` — успешны (0/0; 410 PASS).
## Concerns для следующих этапов
- Кириллица в curl-query (пункт 1) — ограничение тестового окружения, не продукта.
- suggest-эвристика и демо-пул дают детерминированный результат на окне ≥6 карточек; при маленьких
выборках возможны мягкие `ok:false` — это контракт прототипа (Ruling 3).
- reclassify, /admin/fts/rebuild, /projects, /tg/status — согласованные заглушки (этапы 4/5/6);
реальные события boards_changed/leads_reclassified не публикуются (фронт их не слушает) — как в
Self-Review L530532.
@@ -0,0 +1,96 @@
# Task 2 — «Модуль Kanban: DTO, порт IKanjStore, реестр» — отчёт
Статус: **DONE** (build 0/0; тесты 176/176 PASS — добавлен маркер-тест Kanban; модуль чист: grep
EF/Infrastructure/Npgsql/HTTP по коду — 0 совпадений, упоминания только в XML-doc).
План: `docs/superpowers/plans/2026-09-05-deal-stage3-kanban.md` Task 2 (L207229), Ruling 12 (L172177),
Global Constraints (L4950). Сверено с api-map §4.1/§4.2, `leads.py`, `pipeline.py lead_to_dict` L540586.
## Файлы (все в `src/core/Deal.Modules.Kanban/`, 1 тип = 1 файл, XML-doc, record'ы)
### Application/Models — record-DTO
| Файл | Тип | Назначение (wire §4.1/§4.2) |
|---|---|---|
| `BoardDto.cs` | record (init) | Доска: id/name/description/color/width/collapsed/keywords/prompt/visibleFields/suggested/rules/note; `Position` — внутренняя (JsonIgnore: палитра/порядок, Ruling 10). |
| `BoardRulesDto.cs` | record | Правила доски `rules`: mode/direction/keywords/stack/grade/exclude/budget. |
| `BudgetRangeDto.cs` | record | `budget` правил: from/to/cur (Task 3 BudgetInRange). |
| `BoardPatchDto.cs` | record | Допустимые поля PATCH (11 шт., все nullable; null = не меняется). |
| `CardDto.cs` | record (init) | Карточка §4.1: все поля фронта; `Channel``ch`, `ReceivedAtMs``receivedAt` (JsonPropertyName), `Time` — human-метка. |
| `CardBudgetDto.cs` | record | `budget`/`converted` карточки: from/to/cur. |
| `CardContactDto.cs` | record | `contacts[]`: type/value. |
| `CardChannelDto.cs` | record | `ch`: name/handle/hue. |
| `CardCommentDto.cs` | record | `comments[]` (= LeadComment): id/by/text/time (time от CreatedAt, Ruling 10). |
| `MatchHitDto.cs` | record | `matchHits[]`: label/term/word? (Ruling 2). |
| `CardColumnCountDto.cs` | record | Значение счётчика колонки `{count, new}`. |
| `CardCountsDto.cs` | record (init) | GET /leads/counts: New + Columns (col→{count,new}) + Learning/Ml/Ai (ml/ai/learning из IMlClient — Task 5). |
| `CardsQuery.cs` | record | Фильтр списка карточек: Col? (null = все, кроме taken). |
| `CardSnapshot.cs` | record (init) | «Сырая» запись создания карточки (write-модель; демо Task 13/этап 4): полный набор полей Cards, id `l_` готовый (Ruling 12), CreatedAt ставит хранилище. |
| `StorageTickStatsDto.cs` | record | `storage` тика: archived/purgedArchive/purgedTrash/purgedRejected (Ruling 8). |
| `CardMoveDto.cs` | record | Запись журнала CardMoves (id/leadId/action/fromCol/toCol; add-параметр порта). |
| `CardColumnUpdateDto.cs` | record | Перенос/смена состояния карточки (col/isNew/prevCol/archivedAt/matchHits). |
### Application — порт и реестр
| Файл | Содержание |
|---|---|
| `KanbanColumns.cs` | Реестр служебных колонок: Inbox/Archive/Trash/Taken (constants.py SERVICE_COLS). |
| `KanbanIdPrefixes.cs` | Префиксы id Ruling 12: Board `b_`, Card `l_`, Comment `cm_`, CardMove `lm_`, MlOutbox `mle_`. Генератор (PrefixId + hex) — Task 7. |
| `IKanjStore.cs` | Порт хранилища канбана (см. ниже). |
| `KanbanModuleRegistrar.cs` | `AddKanbanModule()` — пустой каркас + TODO: сервисы Tasks 6/7/10/12 и `AddScoped<IRatesChangedListener, ConversionRecomputer>` (Ruling 7); вызов из Program.cs — Task 4. |
### Изменены
- `Deal.Modules.Kanban.csproj` — ProjectReference на `Deal.Modules.Settings` (+ остаются SharedKernel/Contracts;
цикла нет: Settings → Kanban не ссылается); PackageReference `Microsoft.Extensions.DependencyInjection.Abstractions` 10.0.11 (как Settings).
- `tests/Deal.Tests.Unit/MarkerTests.cs` — добавлен `KanbanModuleMarker_IsPublicAndSealed` (Acceptance: «маркер Kanban в MarkerTests»).
## Порт `IKanjStore` (все методы — `…Async(…, CancellationToken ct)`, оперируют DTO)
- **Boards:** `ListBoardsAsync` (ORDER BY suggested, pos) · `GetBoardAsync(id)→BoardDto?` · `CreateBoardAsync(BoardDto)`
· `UpdateBoardAsync(BoardDto)` · `DeleteBoardAsync(id)→int moved` (карточки → inbox isNew, prevCol=inbox) · `ReorderBoardsAsync(order)`.
- **Cards:** `ListCardsAsync(CardsQuery)→IReadOnlyList<CardDto>` (received_at DESC; комментарии приложены, time посчитан)
· `GetCardAsync(id)→CardDto?` · `AddCardAsync(CardSnapshot)` · `UpdateColumnAsync(CardColumnUpdateDto)`
· `UpdateSeenAsync(cardId?, col?)` (id|col|all) · `DeleteForeverAsync(id)` (Cards+LeadComments cascade; журнал/outbox не трогаем)
· `ClearColAsync(col)→int` · `CountCardsByColAsync()→IReadOnlyDictionary<string, CardColumnCountDto>`.
- **Comments:** `ListCommentsAsync(cardId)` · `AddCommentAsync(commentId, cardId, by, text)`.
- **CardMoves:** `AddMoveAsync(CardMoveDto)` · `CountMovesAsync()→int` (счётчик learning).
- **StorageTick (Ruling 8):** `ListArchiveCandidatesAsync(receivedBefore)` (автоархив: доски∪inbox) ·
`ListExpiredArchiveCandidatesAsync(archivedBefore)` (очистка архива) · `ListTrashCandidatesAsync(receivedBefore)`
(очистка корзины) · `PurgeAsync(ids)→int` (жёсткое удаление пачкой).
- **Conversion (Ruling 7):** `ListCardsForConversionAsync()` (budgetCur≠'' и col NOT IN archive/trash/taken) ·
`UpdateConversionAsync(cardId, convFrom, convTo, convCur)`.
- **Suggest (Ruling 3):** `ListInboxWithSourceAsync()` (col=inbox с непустым source_msg).
## Обоснование границ и отклонения
1. **Порт оперирует готовыми API-DTO** (BoardDto/CardDto), а не JSON-строками: так задан список моделей Task 2;
маппинг строк↔DTO (JSON-поля, human-age Ruling 10, прикладывание комментариев) — ручная работа адаптера Task 4
(эталон SettingsStore.cs); `CardMapper` (Task 7) остаётся чистым помощником модуля.
2. **`BoardDto.Position` — внутреннее поле с `[JsonIgnore]`** (в плане-моделях отсутствует): без него Task 6 не
вычислит pos = MAX+1 и палитру PALETTE[pos % 8] (Ruling 10) — wire §4.2 не нарушен (поле не выходит в JSON).
3. **Добавлены write-DTO `CardMoveDto` и `CardColumnUpdateDto`** (в файл-листе плана их нет): сигнатуры
CardMoves.Add и Cards.UpdateColumn требуют типизированного параметра («сигнатуры на DTO»). Семантика
`CardColumnUpdateDto`: PrevCol=null — не менять (автоархив тика prev_col не трогает); ArchivedAt пишется как есть
(null → NULL — возврат из архива/корзины); matchHits пишется целиком (пересчёт — в модуле, Ruling 2).
4. **`UpdateConversionAsync` добавлен к порту** (план называл только ListForConversion): без записи пересчитанных
conv-полей ConversionRecomputer (Task 12, Ruling 7) нереализуем.
5. **StorageTick-список расширен `ListExpiredArchiveCandidatesAsync`** (план: «ListArchiveCandidates/ListTrashCandidates/
Purge»): очистка архива фильтруется по ArchivedAt, очистка корзины — по ReceivedAt, автоархив — по ReceivedAt на
колонках досок∪inbox (tick_storage L454493); три разных условия не сводятся к двум методам. `PurgeAsync`
массовое жёсткое удаление (Task 10: «удаление = DeleteForever» — оставлен и единичный метод).
6. **Поиск (Ruling 6) в порт НЕ добавлен** (план его не называл): Task 7 реализует LIKE-дополнение поверх
`ListCardsAsync` фильтрацией в модуле; если потребуется SQL-LIKE — метод добавится в Task 7/8.
7. **colState/«BoardState» DTO не создавался**: состояние колонок (collapsed/width) — KV `colState`
(`SettingsKeys.ColState`, Ruling 10), читается/пишется через `ISettingsStore` модуля Settings (Task 6); отдельный
тип не нужен фронту и плану (YAGNI). `taken`-колонка в реестре есть (валидация/исключение из списков), но карточки
в неё в этапе 3 не кладутся.
8. **CardCountsDto**: колонки — словарь `Columns` (col → {count,new}); плоскую wire-форму
`{new, <col>: {…}, learning, ml, ai}` соберёт CardsService/эндпоинт (Task 7/8) из New+Columns+Learning/Ml/Ai —
фронт читает только learning/ml/ai (store.js L586592).
9. **Registrar пуст** (каркас + TODO): сервисы Tasks 6/7/10/12 ещё не существуют; регистрировать нечего. Пакет
DI.Abstractions добавлен сразу (регистрация появится с Tasks 6/7/10/12).
## Валидация
- `dotnet build Deal.sln`: 0 предупреждений / 0 ошибок (TreatWarningsAsErrors).
- `dotnet test tests/Deal.Tests.Unit`: 176/176 PASS (было 175 → +маркер Kanban; MarkerTests PASS).
- Чистота модуля: grep по `Deal.Modules.Kanban/**/*.cs` `EntityFramework|Npgsql|Deal.Infrastructure|HttpClient|System.Net.Http|Microsoft.AspNetCore`
2 совпадения, оба в XML-doc-прозе (допустимо); ссылок в коде нет.
- Цикла зависимостей нет: Settings/Infrastructure/Tenants на Kanban не ссылаются (проверены все .csproj).
@@ -0,0 +1,72 @@
# Task 3 — «Чистые правила колонок — ColumnRules + BudgetParser + unit-тесты» — отчёт
Статус: **DONE** (build 0/0; тесты 245/245 PASS — добавлено 69: ColumnRulesTests 35, AmountParserTests 16,
BudgetNormalizerTests 18; MarkerTests PASS). Модуль чист: новых упоминаний EF/Infrastructure/HTTP в коде нет
(только XML-doc проза IKanjStore/Registrar из Task 2).
План: `docs/superpowers/plans/2026-09-05-deal-stage3-kanban.md` Task 3 (L231250), Ruling 2 (L7684),
Ruling 7 (L123133). Референс: `backend/app/services/rules.py` целиком (L15368), `ai.py` L316352,
`leads.py` (текст карточки для правил L167/L210, matchHits на переносе L163174).
## Файлы (1 тип = 1 файл, XML-doc, статические чистые классы)
### `K/Application/ColumnRules/` — чистые правила колонок (namespace …ColumnRules)
| Файл | Тип | Поведение (референс) |
|---|---|---|
| `AmountRange.cs` | record | Одна распознанная сумма: from/to/cur (элемент результата парсера). |
| `ContentNormalizer.cs` | static | `ContentText(text)` — вырезает markdown-ссылки `[текст](url)` и голые URL (rules.py L4754). |
| `AmountParser.cs` | static | `Parse(text)` — суммы из текста (rules.py extract_amounts L93144, _norm_amount L6273, _cur_from_tail L7690): «от A до B», «до B», «A–B», одиночные; «к/К» → ×1000; символы/слова валют; суммы без валюты игнорируются; занятые диапазоны не дублируются (5 проходов 1:1). |
| `GradeAliases.cs` | static | `ExpandTerms(tags)` — синонимы грейдов (rules.py _GRADE_ALIASES L2027, _grade_terms L3040); неизвестный тег — как есть в lower. |
| `BudgetInRange.cs` | static | `IsInRange(amounts, budget, rates)` — попадание суммы в диапазон с конвертацией валюты (rules.py _amount_in_range L147173). |
| `ColumnMatcher.cs` | static | `MatchText` (mode all/any, пустые группы не участвуют), `ScoreText` (число совпавших термов), `HasActiveRules` (L176227, L322338). |
| `ColumnExclusions.cs` | static | `ExcludedTerms`/`IsExcluded` — veto-слова (L230248). |
| `MatchHitBuilder.cs` | static | `BuildHits(rules, text, rates)` — MatchHitDto label/term/word? (L271296): «Направление»/«Слова»/«Стек»/«Грейд/уровень» (word — найденный синоним)/«Бюджет» (term — «от X до Y CUR», _budget_label L299308). |
| `RulesDescriber.cs` | static | `Describe(rules)` — «все условия · стек: …» / «без правил (решает ИИ/ML)» (L341368). |
| `ColumnRules.cs` | static facade | Единая точка входа: `BoardAccepts` (veto → нет активных правил → MatchText, L251268), `Matches`, `HasActiveRules`, `ComputeHits` (hits_for_board L311319: нет активных правил → `[]`), `Describe`. |
### `K/Application/BudgetNormalizer.cs` (namespace …Application)
| Член | Поведение (референс) |
|---|---|
| `Normalize(BudgetRangeDto?)` | clean_budget (ai.py L316326): одна сумма → from=to; from=0 → null («от 0 до X» == «до X»); to=0 → null; валюта через алиасы к коду (ai.py _CUR_ALIASES L271276, _norm_currency L279291); нет валюты/обе границы null → null. |
| `ToTarget(CardBudgetDto?, conversionOn, targetCurrency, rates)` | budget_to_target (ai.py L342352): conversionOn=false/нет валюты → null (= convCur «»); конвертация по курсам (USDT=USD через RatesService.ConvertAmount); целевая валюта пуста → RUB; валюта без курса → границы null, convCur сохраняется. |
### Изменены
- Ничего: `.csproj` не трогали (зависимость Kanban → Settings уже была из Task 2, туда и ходит конвертация).
## Ключевые решения и расхождения с rules.py (и почему)
1. **«Интерфейс курсов» = словарь + чистая функция Settings, без нового интерфейса/адаптера.** Курсы
(`IReadOnlyDictionary<string,double>` «код→курс к рублю») — параметр `BudgetInRange.IsInRange`/
`BudgetNormalizer.ToTarget`, конвертация делегируется уже существующей чистой
`RatesService.ConvertAmount` (Settings, покрыта тестами этапа 2: USDT=USD, rates.py L86103). Чтение
`ratesCache` (Ruling 7) остаётся за вызывающим (CardsService Task 7, демо Task 13) — модуль не ходит в БД.
Отдельный `ICurrencyConverter` не вводили (YAGNI; Ruling 12 не регистрирует такой порт).
2. **Фасад `ColumnRules` добавлен к файл-листу плана** (в плане он только в названии Task/Ruling 2): без
него сервисам Tasks 6/7/13 некуда повесить «страховку» `BoardAccepts` и `ComputeHits` c []-логикой
(аналог модульных функций rules.py). Тонкая обёртка над файлами плана, логики не дублирует.
3. **`BoardAccepts` принимает `BoardRulesDto?`, а не id доски** (хранилища у чистого модуля нет): правила
грузит вызывающий; `null` = «правил нет» → принимает любой текст (в python для отсутствующей доски —
False, но там rules читаются из БД по id; проверку существования доски делает сервис Task 7, как
leads.py move/restore перед вызовом). Семантика «доска с активными правилами/без них» — 1:1.
4. **Воспроизведены особенности прототипа** (тесты это фиксируют): слово «usdt» после числа → USD
(первый startswith «usd», L82); «евро» в словаре валют нет; символ перед числом («$50–100», «$1 200»)
НЕ распознаётся — окно head (L86–89) смотрит ≤3 символа ДО КОНЦА суммы, где стоят цифры (комментарий
прототипа L56 шире, чем реализация); суммы без валюты игнорируются (L98).
5. **`BudgetNormalizer.Normalize` принимает числовые границы (`double?`)**, а не сырые значения ИИ
(«150000», «2к», «2000₽» — ai.py _budget_num L294313): строки разбирает вызывающий (демо — через
AmountParser, этап 4 — своим маппингом). Числовая семантика (0 → null и т.п.) — 1:1.
6. **`ToTarget` возвращает `null` при выключенной конверсии/отсутствии валюты** вместо dict
`{convFrom:null, convTo:null, convCur:""}`: null кодирует «conv-поля пустые» (ConvCur "") — меньше
веток у вызывающего; при валюте без курса возвращается объект с Cur=target и null-границами (1:1 L351357).
7. **Формат чисел в label/describe**: hits-терм бюджета — .NET «G6» (6 значащих, как python %g, E→e);
describe — кратчайший double.ToString (python float дал бы «1000.0»). Косметика на «красивых» числах UI.
8. `GradeAliases.ExpandTerms` принимает `IEnumerable<string?>`, `AmountRange` (record) добавлен как
результат парсера — оба не были в файл-листе плана, но требуются сигнатурами (аналог write-DTO Task 2).
## Валидация
- `dotnet build Deal.sln`: 0 предупреждений / 0 ошибок (TreatWarningsAsErrors).
- `dotnet test tests/Deal.Tests.Unit`: 245/245 PASS (было 176 → +69; MarkerTests PASS).
- Чистота модуля: grep EF/Infrastructure/HTTP по `Deal.Modules.Kanban/**/*.cs` — только XML-doc Task 2.
- В ходе тестов найден и исправлен баг первой версии AmountParser: подпаттерны суммы не были обёрнуты в
захватывающие группы, поэтому Groups[1]/[2] были пусты и диапазонные проходы не срабатывали
(одиночные — срабатывали); после обёртки `(``)` все проходы 1:1 с прототипом.
@@ -0,0 +1,90 @@
# Task 4 — «EF-адаптер KanbanStore + DI» — отчёт
Статус: **DONE** (build 0/0, тесты 245/245 PASS, функциональная проверка адаптера на дефолтном тенанте зелёная,
psql-проверка строк в tenant-схеме — зелёная, схема возвращена в пустое состояние для Task 8).
План: `docs/superpowers/plans/2026-09-05-deal-stage3-kanban.md` Task 4 (L252266), Ruling 1(а), Ruling 10, Ruling 12;
эталоны `SettingsStore.cs`/`AuthStore.cs`; фактические сигнатуры порта — `Deal.Modules.Kanban/Application/IKanjStore.cs` (Task 2).
## Файлы
### Создан
- `src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.cs` — реализация `IKanjStore` на
`TenantDbContext` (primary constructor, как `SettingsStore`). Все 25 методов порта: Boards (6), Cards (9),
Comments (2), CardMoves (2), StorageTick (4), Conversion (2), Suggest (1). EF — только здесь (Infrastructure).
### Изменены
- `src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs``AddDealPersistence()` дополнен
`AddScoped<IKanjStore, KanbanStore>()` (Ruling 12) + using модуля Kanban.
- `src/core/Deal.Infrastructure/Deal.Infrastructure.csproj` — ProjectReference → `Deal.Modules.Kanban`.
- `src/core/Deal.Api/Deal.Api.csproj` — ProjectReference → `Deal.Modules.Kanban` (как у Settings/Tenants).
- `src/core/Deal.Api/Program.cs` — вызов `builder.Services.AddKanbanModule()` (после `AddSettingsModule`,
регистратор пока пустой каркас — сервисы Tasks 6–14; см. `KanbanModuleRegistrar`).
## Реализация
- **Чтения** — `AsNoTracking()`; сортировки по порту/прототипу: доски `ORDER BY Suggested, Position`; карточки
`ReceivedAt DESC` (col-фильтр либо «все, кроме taken»); комментарии `CreatedAt, Id` (детерминированно).
- **Маппинг** выполняется вручную (порт не видит EF-сущности): `ToBoardDto/ToBoardEntity`, `ToCardDto/ToCardEntity`,
`ToCommentDto`. Карточки пачкой читаются вместе с комментариями одним запросом (`ToCardDtosAsync`: `WHERE CardId IN
(...)`, группировка в lookup) — без N+1 в `ListCardsAsync`.
- **JSON-поля** (KeywordsJson/VisibleFieldsJson/RulesJson, StackJson/ContactsJson/MatchHitsJson) — text с JSON
camelCase (конвенция `value_json`). Статические опции `JsonOptions { PropertyNamingPolicy = CamelCase,
PropertyNameCaseInsensitive = true }` (как `SettingsService`/`RatesService`). Запись: `ToJson<T>`; Rules:
`null` («правил нет») хранится как `{}` (Ruling 1), при чтении `{}`/пустая/битая строка → `null`.
Разбор массивов терпим к битому JSON → пустой список (как `json.loads(... or "[]")` прототипа).
- **Времена**: хранятся `timestamptz` (DateTimeOffset). Наружу карточки — `ReceivedAtMs = ToUnixTimeMilliseconds()`
(JsonPropertyName `receivedAt`). Human-метка `time` («только что»/«N мин»/«N ч»/«N дн») считается в маппинге
на лету от `ReceivedAt`/`CreatedAt` (Ruling 10) — локальный `HumanAge` 1:1 с `pipeline.py human_age` L528537
(delta зажат в 0; «только что» при < 1 мин).
- **Транзакции** (зафиксированное решение): одиночные записи — `SaveChangesAsync` (Create/UpdateColumn/AddComment/
AddMove/AddCard); одиночные UPDATE/DELETE — `ExecuteUpdateAsync`/`ExecuteDeleteAsync` (один statement, атомарно);
методы с несколькими изменениями — явная транзакция: `DeleteBoardAsync` (карточки → inbox + удаление доски,
L124130) и `ReorderBoardsAsync` (позиции 0..N-1, L133135). «Карточка + card_moves» одним методом в порту
не представлены (сервис Task 7 пишет их отдельными вызовами) — транзакция на уровне адаптера не нужна.
- **Удаление карточки** (`DeleteForeverAsync`/`ClearColAsync`/`PurgeAsync`) — DELETE по Cards; комментарии чистит
каскад БД (FK `LeadComments.CardId` ON DELETE CASCADE, Ruling 1); `CardMoves`/`MlOutbox` не трогаются
(прототип `_hard_delete`). Возврат «сколько удалено» — число затронутых строк ExecuteDelete.
- **`AddComment` при несуществующей карточке**: порт void — возвращать нечего; поведение — запись в LeadComments
с FK, целостность держит БД (нарушение FK → `DbUpdateException`). 404-семантику даёт сервис (Task 7 читает
карточку перед добавлением), адаптер тихого no-op не делает (иначе «комментарий-сирота» при гонке с удалением).
- **`UpdateColumnAsync`** (CardColumnUpdateDto): чтение со слежением + SaveChanges, потому что `PrevCol = null`
означает «не менять» (Ruling 8: автоархив не трогает prev_col), а `ArchivedAt` пишется как есть (null → NULL);
условный UPDATE через ExecuteUpdate потребовал бы двух запросов. Нет карточки — no-op (сервис валидирует).
- **`DeleteBoardAsync`**: перенос карточек доски в inbox c `is_new=TRUE, prev_col='inbox'` 1:1 с прототипом.
- **Кандидаты автоархива** (`ListArchiveCandidatesAsync`): колонки досок ∪ inbox — id досок читаются реестром
(колонки динамические), условие `ReceivedAt < граница`; очистка архива — `ArchivedAt` (с явным `!= null`),
корзины — `ReceivedAt` (tick_storage L462483).
- **Конверсии**: `ListCardsForConversionAsync``BudgetCur != '' AND Col NOT IN (archive, trash, taken)`
(Ruling 7); `UpdateConversionAsync` — только conv-поля одним UPDATE.
- **Suggest**: `ListInboxWithSourceAsync``Col = 'inbox' AND SourceMsg != ''`.
## Сознательные упрощения (зафиксированы)
- **Contacts**: адаптер разбирает только `ContactsJson`; fallback «квалифицировать строку `contact` при пустом
массиве» (lead_to_dict L553555) не дублируется — это доменная логика пайплайна (`qualify_contact`), владелец —
этап 4; на этапе 3 карточки пишутся сразу с заполненным ContactsJson.
- **`ListCardsForConversionAsync`/`ListInboxWithSourceAsync`** возвращают карточки без подгрузки комментариев
(пустой список) — потребителям (пересчёт конверсий, эвристика suggest) нужны только бюджетные/текстовые поля.
## Проверка
1. **Build**: `dotnet build Deal.sln` — Ошибок: 0, Предупреждений: 0 (TreatWarningsAsErrors).
2. **Тесты**: `dotnet test tests/Deal.Tests.Unit` — 245/245 PASS (без изменений — для адаптера unit-тестов
в плане нет; проверка функциональная).
3. **Функциональная проверка адаптера** (dev-харнесс, временный проект вне sln, удалён после прогона): на
дефолтном тенанте через методы KanbanStore созданы доска (keywords/rules JSON), карточка (stack/contacts/
matchHits JSON, budget, ch), комментарий, запись журнала move, выполнен перенос inbox → доска
(isNew=false, prevCol, matchHits), пересчёт конверсии (conv поля), проверены кандидаты автоархива, счётчики
counts, каскадное удаление комментариев, «журнал переживает удаление карточки». 28/28 проверок ok.
4. **psql** (схема `tenant_00000000000000000000000000000001`): строки в `Boards`/`Cards`/`LeadComments`/`CardMoves`
подтверждены — JSON camelCase в text-полях, `Col = b_...`, `IsNew = f`, `PrevCol = inbox`, `ConvCur = RUB`,
`ReceivedAt`/`CreatedAt` timestamptz. После проверки все таблицы тенанта очищены (`TRUNCATE ... CASCADE`) —
Task 8 ждёт пустые чтения (GET /boards → [], counts → 0).
5. Диагностики по `KanbanStore.cs` — нет ошибок/предупреждений.
## Чистота
- `KanbanStore` — единственное место EF-кода новых таблиц (Infrastructure); модуль Kanban не тронут (кроме
csproj-ссылок Infrastructure/Api); циклов зависимостей нет (Kanban не ссылается на Infrastructure).
- Стиль: 1 тип = 1 файл, XML-doc, комментарии на русском, именованные константы, без регионов/магических строк,
Allman, явные модификаторы.
@@ -0,0 +1,98 @@
# Task 5 — «IMlClient.PushAsync + LocalMlClient (outbox/learning/status/reset)» — отчёт
Статус: **DONE** (build 0/0, тесты 255/255 PASS, функциональная dev-проверка на реальном Postgres 24/24 зелёная).
План: `docs/superpowers/plans/2026-09-05-deal-stage3-kanban.md` Task 5 (L266286), Ruling 4(г) (L97107);
эталон — `backend/app/services/ml_client.py` (push L4049, reset_model L110124, snapshot L138150) и
`leads.py` (движки сигналов move/trash/restore L177222). Контекст «Готово» подтверждён: MlOutboxEntity
(Id/Text/Label/Delta/CreatedAt), таблица создана миграцией TenantKanban (T1) — миграция НЕ требовалась.
## Файлы
### Изменён — `src/core/Deal.Contracts/Integrations/IMlClient.cs`
`PushAsync(string text, string label, double delta, CancellationToken ct)` — сигнатура по плану
(L269271); без record-DTO (прототип push — без ответа; ml/learn-обёртка с `{ok,outbox}` в этап 3 не
входит, api-map п.9). Обновлены remarks: обучение (PushAsync) добавлено этапом 3 (Ruling 4), отправка
outbox в ML-сервис — фоновый воркер этапа 6. Документированы метки (id доски | `spam` |
`t:hire|t:order`) и веса (1.0 / −1.0, ИИ-сигналы 0.4/0.6 — этапы 4/6).
### Изменён — `src/core/Deal.Contracts/Integrations/Models/MlStatsDto.cs`
Remarks приведены к этапу 3: learning = count(CardMoves), outbox = count(MlOutbox) (текст «в этапе 2
всегда 0» устарел).
### Создан — `src/core/Deal.Modules.Kanban/Application/IMlLearningStore.cs`
Чистый порт хранилища обучения ML: `CountLearningAsync` (count(CardMoves)), `CountOutboxAsync`
(count(MlOutbox)), `AddOutboxAsync(id, text, label, delta)` (id приходит готовым — Ruling 12; CreatedAt
проставляет хранилище), `ClearOutboxAsync` (reset_model L122).
### Создан — `src/core/Deal.Infrastructure/Persistence/Repositories/MlLearningStore.cs`
EF-адаптер порта на `TenantDbContext` (эталон KanbanStore): запись — SaveChanges (CreatedAt = UTC-now),
очистка — `ExecuteDeleteAsync` одним statement'ом; журнал CardMoves не трогается.
### Изменён — `src/core/Deal.Infrastructure/Integrations/LocalMlClient.cs`
Ctor: `(ISettingsStore store, IMlLearningStore learningStore)`. `PushAsync` — 1:1 с push L4049 (trim
text/label; пустые после trim — тихий no-op; `text[:6000]`; id `mle_` + 12 случайных hex);
`StatusAsync` — learning/outbox из порта (Ruling 4), ml/ai и mlEnabled — KV (как было), модель не готова
(ready=false до этапа 4); `ResetAsync` — чистит только MlOutbox (KV и CardMoves не трогает);
`PredictAsync` — без изменений. KV `mlDecisions`/`aiDecisions` не инкрементируются (этап 3 — всегда 0).
### Изменён — `src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs`
`AddDealPersistence()`: `AddScoped<IMlLearningStore, MlLearningStore>()`; remarks AddDealIntegrations
актуализированы (этап 3, зависимости LocalMlClient — порты).
### Изменён — `src/core/tests/Deal.Tests.Unit/LocalMlClientTests.cs` + создан `FakeMlLearningStore.cs`
`CreateClient` принимает оба порта (learning-фейк опционален). Новые кейсы: StatusAsync из таблиц
(learning/outbox, ml/ai = 0), PushAsync пишет строку (id mle_+12 hex, trim text/label, delta), пустые
text/label — no-op (4 case'а), text>6000 → ровно 6000, срез не разбивает суррогатную пару, delta −1.0
сохраняется, ResetAsync чистит только outbox (learning/KV целы). Итог: 245 → 255 PASS.
## Зафиксированные решения (исполнитель, план L277–280: «финальное решение за исполнителем»)
1. **LocalMlClient НЕ получает TenantDbContext** — держит чистый порт `IMlLearningStore` (модуль Kanban,
как и планировавшийся `IMlLearningCounters`): план требует «LocalMlClient и тесты остаются
unit-чистыми», а репозиторий не использует EF-harness в unit-тестах (везде fake'и). Состав порта —
не только подсчёты, но и запись/очистка очереди (иначе Push/Reset не покрыть unit-тестами);
счётчики learning/outbox, о которых говорит план, входят в него же. Адаптер в Infrastructure.
2. **Порт объявлен в модуле Kanban** (не Contracts): таблицы CardMoves/MlOutbox — владение этапа
Kanban (Ruling 1/4), план Task 5 прямо указывает «(модуль Kanban)»; Infrastructure уже зависит от
Kanban (KanbanStore).
3. **Id `mle_`+12 hex генерирует LocalMlClient** (префикс — `KanbanIdPrefixes.MlOutbox`, 6 байт RNG → hex,
1:1 с `store.uid` = uuid4().hex[:12]): утилита PrefixId модуля появится только в Task 7, а outbox-пуш —
ответственность адаптера интеграции; хранилище id не создаёт (Ruling 12).
4. **CreatedAt = UtcNow проставляет адаптер** (как AddMoveAsync/AddCommentAsync — хранилище), DTO с
временем в порт не вводится.
5. **Срез до 6000 с защитой суррогатной пары**: .NET режет по UTF-16 и может разбить пару на границе;
Python `text[:6000]` режет по code points — хвостовой high-surrogate убирается (покрыто тестом).
6. **CardMoves.count через порт, а не IKanjStore**: у IKanjStore есть `CountMovesAsync`, но тянуть весь
канбан-порт в LocalMlClient (и его fake в тесты) нецелесообразно — счётчик learning это часть
«снимка ML» (snapshot L144), а не канбан-операция.
## Проверка
1. **Build**: `dotnet build Deal.sln` — Ошибок: 0, Предупреждений: 0 (TreatWarningsAsErrors).
2. **Тесты**: `dotnet test tests/Deal.Tests.Unit` — 255/255 PASS (+10 к 245; MarkerTests PASS).
3. **Функциональная проверка** (dev-харнесс `task-5-devcheck`, временный проект вне sln на реальном
Postgres `deal-postgres` :5433, схема `devcheck_t5` создана tenant-миграциями и удалена после
прогона): DI (AddDealPersistence + AddDealIntegrations) → IMlClient реально LocalMlClient:
(1) пустая схема — stats {learning:0, outbox:0, ml:0, ai:0}, ready=false, reachable=true, enabled=true;
(2) 3×PushAsync — outbox=3, SELECT из таблицы `MlOutbox` (psql-эквивалент, т.к. бинарь psql в PATH
отсутствует): id `mle_`+12 hex, label b_alpha/spam, delta 1.0/1.0, text trim-нут, 7000-символьный
текст → ровно 6000; (3) AddMoveAsync (журнал CardMoves, как это сделает CardsService Task 7) →
learning=1, outbox не изменился; (4) ResetAsync → outbox=0, learning=1 (журнал и KV не тронуты).
24/24 PASS.
4. Диагностики по изменённым файлам `src/core` — нет ошибок/предупреждений (только pre-existing
ошибки Python-прототипа `backend/`, вне зоны задачи).
## Чистота
- Модуль Kanban чист (только интерфейс); EF — только в Infrastructure; обратной зависимости
(Kanban → Infrastructure) нет. Contracts — только контракт+DOC. Стиль: 1 тип = 1 файл, XML-doc,
комментарии на русском, именованные константы (6000/12/6 байт), без регионов/магических чисел.
## Concerns / заметки
1. Имя порта `IMlLearningStore` (а не `IMlLearningCounters` из плана) — план оставил финальное решение
исполнителю; «счётчики» не описывали бы запись/очистку очереди.
2. Строки `learning:1`/`outbox:1` в curl-приёмке плана появятся после Task 7/8 (перенос карточки
эндпоинтом) — на уровне адаптера сценарий подтверждён (журнал + пуш из шага 2–3 харнесса).
3. Прогресс `localMlClientTests`/`LocalMlClient` — теперь 255 PASS; дальнейшие этапы (фоновый воркер
flush'а outbox, реальная модель) — этап 6.
@@ -0,0 +1,93 @@
# Task 6 — «BoardsService — колонки-доски и colState + unit-тесты» — отчёт
Статус: **DONE** (build 0/0, тесты 282/282 PASS: 255 → 282, +27 новых).
План: `docs/superpowers/plans/2026-09-05-deal-stage3-kanban.md` Task 6 (L288305), Ruling 10 (L151159);
эталоны — `backend/app/services/leads.py` (L49146: list/create/patch/delete/reorder/col_state),
`backend/app/constants.py` PALETTE L1216, `dashboard_routes.py` (L101149), api-map §3.2 L6277, §4.2.
Контекст «Готово» подтверждён: модуль Kanban (DTO, `IKanjStore`, `KanbanIdPrefixes`), Settings
(`ISettingsStore`, `SettingsKeys.ColState`), адаптер KanbanStore (Task 4) — сервис только оркестрирует порты.
## Файлы
### Создан — модуль Kanban
- `src/core/Deal.Modules.Kanban/Application/BoardsService.cs` — чистый сервис (первичный конструктор,
как SettingsService): `IKanjStore` + `ISettingsStore`. Методы:
`ListBoardsAsync`, `CreateBoardAsync(BoardCreateDto) → BoardDto` (дефолты/палитра),
`PatchBoardAsync(id, BoardPatchDto) → BoardDto?` (null = доски нет → эндпоинт 404 «Доска не найдена»),
`DeleteBoardAsync(id) → int moved` (карточки→inbox новыми делает адаптер, T4),
`ReorderBoardsAsync(order)`, `GetColStateAsync() → весь объект`,
`PatchColStateAsync(colId, patch) → состояние одной колонки после merge`.
- `src/core/Deal.Modules.Kanban/Application/Models/BoardCreateDto.cs` — вход создания (1:1 с параметрами
create_board L74104; Suggested/Note — для эвристики Task 14, POST /boards их не шлёт).
- `src/core/Deal.Modules.Kanban/Application/Models/ColumnStateDto.cs` — состояние колонки в colState
(`{collapsed?, width?}`, camelCase, null-поля не пишутся — как `exclude_none=True` прототипа).
- `src/core/Deal.Modules.Kanban/Application/PrefixId.cs`**вынесен из Task 7 заранее** (см. решения):
`prefix + 12 hex` (6 байт CSPRNG, 1:1 `store.uid` = uuid4().hex[:12]).
### Изменён
- `src/core/Deal.Modules.Kanban/Application/KanbanModuleRegistrar.cs``AddScoped<BoardsService>()`
(TODO-каркас из Task 2 заменён первой реальной регистрацией).
### Создан — тесты (`src/core/tests/Deal.Tests.Unit/`)
- `FakeKanjStore.cs` — in-memory `IKanjStore`: реализованы операции досок + перенос карточек в inbox при
удалении (1:1 с T4-адаптером: col=inbox, isNew=true); неиспользуемые методы порта бросают
`NotSupportedException` (тест сразу ловит неожиданный доступ сервиса).
- `BoardsServiceTests.cs` — 27 тестов на фейках `FakeKanjStore` + `FakeSettingsStore`.
## Реализация и зафиксированные решения
1. **Список досок — БЕЗ счётчиков карточек** (сверка с фронтом из плана). Фронт рисует «+N», бейджи и
виджеты свёрнутых колонок из ПОЛНОГО списка карточек `GET /api/leads` (`store.js leadsOf/colCount/
newCount`, Column.vue L147/L179184), а `GET /api/leads/counts` собирает CardsService (Task 7,
`CardColumnCountDto`/`CardCountsDto`) — в объекте доски counts нет и в прототипе
(list_boards L4967). `GET /boards` остаётся голым массивом BoardDto; BoardDto не расширялся.
2. **Create 1:1 с create_board L74104**: pos = MAX(pos)+1 по списку досок порта (`ListBoardsAsync`;
выделенного MAX-запроса в порте нет — список уже читается, гонка двух create — как в прототипе,
не атомарна); цвет `PALETTE[pos % 8]` (8 hex зафиксированы в сервисе: #818cf8/#fbbf24/#22d3ee/
#e879f9/#34d399/#fb7185/#a78bfa/#f97316); width='md'; visibleFields ["budget","stack","contacts"];
collapsed=false; `name.Trim() or «Новая колонка»`; description trim; keywords/prompt/note дефолты.
id = `PrefixId.New(KanbanIdPrefixes.Board)`.
3. **PATCH 1:1 с patch_board L107121**: 404-семантика «через результат» — метод возвращает `null` при
отсутствии доски (роутер Task 8 мапит в 404), не бросает. Меняются только не-null поля; JSON-поля
(keywords/visibleFields/rules) заменяются целиком; полный набор полей = BoardPatchDto (name/
description/color/width/collapsed/prompt/keywords/visibleFields/suggested/rules/note). Реализация —
read-modify-write через `GetBoardAsync` + `UpdateBoardAsync` (порт без «полевого» UPDATE), эффект тот же.
4. **Delete**: тонкая прокладка над `DeleteBoardAsync` порта (адаптер T4 уже делает карточки→inbox новыми
и возвращает moved). Доски нет → 0, БЕЗ 404 (прототип delete_board L124130 не отличает).
5. **colState (Ruling 10, L138149)**: KV-ключ `SettingsKeys.ColState`; JSON camelCase, null-поля при
записи опускаются; повреждённый JSON при чтении → пустое состояние (не роняет GET/PATCH). PATCH
колонки — merge в ТЕКУЩЕЕ значение колонки, запись всего объекта, ответ — состояние только этой
колонки (api-map §3.2 L77). Колонка не валидируется (как прототип: ключ может быть любым).
Ограничение типизированной модели: неизвестные ключи ВНУТРИ значения колонки не сохраняются при
PATCH этой колонки (в реальных потоках фронта их нет — пишется только collapsed).
6. **NormalizeRules**: «пустые правила» (все группы пусты, mode="") нормализуются в null — адаптер
хранит каноничное `{}` (как `json.dumps(rules or {})` прототипа), чтение даёт «правил нет». Применено
в create и patch (правила `{}` из диалога = сброс правил).
7. **PrefixId вынесен из Task 7 вперёд**: BoardsService первый сервис модуля, которому нужны id по
Ruling 12 («утилита в модуле Kanban»). Task 7 найдёт файл готовым (CardsService/комментарии/журнал
используют его же; кандидат на рефакторинг — инлайновая генерация в LocalMlClient, T5).
## Проверка
1. **Build**: `dotnet build Deal.sln` — Ошибок: 0, Предупреждений: 0 (TreatWarningsAsErrors).
2. **Тесты**: `dotnet test tests/Deal.Tests.Unit` — 282/282 PASS (база 255 + 27 новых):
создание (pos 0/MAX+1/цикл палитры на pos 8, цвет/width/visibleFields, «Новая колонка», trim,
префикс id b_+12hex, keywords/rules/note/suggested, пустые правила → null), патч (все поля,
null-поля не меняют, неизвестная доска → null, пустые keywords, очистка правил),
reorder (позиции 0..N-1), delete (карточки→inbox новыми + moved + доска удалена, неизвестная → 0),
colState (GET пустой/сохранённый, PATCH новой/существующей колонки merge, null-поля, пустой патч
сохраняет чужие колонки и пишет `{}`, битый JSON → {}).
3. Диагностики по новым файлам — нет ошибок/предупреждений (питоновские диагностики prototype-файлов
backend/ — pre-existing, к .NET-коду отношения не имеют).
## Чистота
- Модуль чист: BoardsService не знает про EF/HTTP; зависимости — порты `IKanjStore` и `ISettingsStore`
(Settings-зависимость Kanban разрешена, реверса нет). 1 тип = 1 файл, XML-doc, русские комментарии,
именованные константы (без магических чисел/строк), без регионов, Allman, явные модификаторы.
- `KanbanModuleRegistrar` регистрирует только сервисы модуля; адаптеры остаются в Infrastructure.
## Concerns для Task 8
- PATCH /api/boards/{id}: маппинг `null` результата сервиса → 404 «Доска не найдена»; ответ `{id}`.
- POST /api/boards: тело (BoardCreate) — name/description/color/keywords/prompt/rules (без suggested/note),
ответ `{id: created.Id}`. Reorder/delete — обёртки `{ok:true}`/`{ok:true, movedToInbox}`.
- Счётчики колонок для виджетов фронта наполняет эндпоинт Task 7 (`/api/leads/counts`) — BoardsService
их не отдаёт (см. решение 1).
@@ -0,0 +1,127 @@
# Task 7 — «CardsService — карточки: чтение, переносы, архив/корзина, комментарии, counts» — отчёт
Статус: **DONE** (build 0/0, тесты 326/326 PASS: 282 → +44 новых).
План: `docs/superpowers/plans/2026-09-05-deal-stage3-kanban.md` Task 7 (L305334), Ruling 2 (matchHits), Ruling 4
(обучение move/trash/restore), Ruling 6 (поиск LIKE), Ruling 10 (сортировка/форматы/prev_col), Ruling 12 (id);
эталоны — `backend/app/services/leads.py` (L151279, L509551), `dashboard_routes.py` (L152256),
`backend/app/services/ml_client.py` (push L4049, snapshot L138150), `store.js` (как фронт читает leads/counts/
поиск/комментарии). Контекст «Готово» подтверждён: IKanjStore/модели (T2), ColumnRules (T3), KanbanStore (T4),
PushAsync+LocalMlClient (T5), BoardsService+PrefixId (T6) — CardsService только оркестрирует порты.
## Файлы
### Создан — модуль Kanban
- `src/core/Deal.Modules.Kanban/Application/CardsService.cs` — чистый сервис (первичный конструктор):
зависимости `IKanjStore` + `ISettingsStore` (кэш курсов ratesCache для бюджетных правил — Ruling 7) +
`IMlClient` (PushAsync — обучение, StatusAsync — счётчики counts, план L321). Методы:
`ListCardsAsync(col?)`, `GetCardAsync(id) → CardDto?` (null → 404), `MoveLeadAsync(id, to) → LeadMoveResultDto`,
`TrashLeadAsync(id) → CardDto?`, `RestoreLeadAsync(id) → string?` (col возврата; null → 404),
`DeleteForeverAsync(id) → bool` (false → 404), `ClearColAsync(col) → ClearColResultDto`,
`AddCommentAsync(id, text) → AddCommentResultDto`, `MarkSeenAsync(cardId?, col?)`,
`CountsAsync() → CardCountsDto`, `SearchCardsAsync(q) → IReadOnlyList<CardDto>` (Ruling 6).
400-тексты прототипа — public-константы класса (`MoveTargetInvalidDetail`/`ClearColInvalidDetail`/
`EmptyCommentDetail`), их же читают тесты и эндпоинты Task 8.
- `src/core/Deal.Modules.Kanban/Application/Models/LeadMoveResultDto.cs` — {Error, Lead}: 400-текст |
Lead=null (404) | карточка после переноса (no-op при той же колонке — как была).
- `src/core/Deal.Modules.Kanban/Application/Models/ClearColResultDto.cs` — {Error, Cleared}.
- `src/core/Deal.Modules.Kanban/Application/Models/AddCommentResultDto.cs` — {Error, Comments}: «Пустой
комментарий» (400) | Comments=null (404) | список после добавления (ответ `{comments: [...]}`).
### Изменён
- `src/core/Deal.Modules.Kanban/Application/KanbanModuleRegistrar.cs``AddScoped<CardsService>()`.
### Создан — тесты (`src/core/tests/Deal.Tests.Unit/`)
- `FakeMlClient.cs` — in-memory `IMlClient`: `Status` задаётся сценарием (по умолчанию пустой), `Pushed`
фиксирует (text, label, delta) в порядке вызовов; Predict/Reset — `NotSupportedException`.
- `CardsServiceTests.cs` — 44 теста (см. ниже).
- `FakeKanjStore.cs`**расширен** до операций карточек, нужных CardsService (были только доски для T6):
`GetCardAsync/ListCardsAsync` (фильтр/порядок received_at DESC, taken исключён), `UpdateColumnAsync`
(1:1 с адаптером: PrevCol=null — не менять), `UpdateSeenAsync`, `DeleteForeverAsync`, `ClearColAsync`,
`CountCardsByColAsync`, комментарии (живут приложенным массивом карточки, как маппинг адаптера),
журнал CardMoves (`Moves` — для проверок); карточки хранятся полными `CardDto` (`SeedCard`),
тройки `Cards`/`AddCard` сохранены для BoardsServiceTests. Тик/конверсии/suggest — по-прежнему
`NotSupportedException` (CardsService их не трогает).
## Реализация (1:1 с leads.py)
- **move** (L177191 + `_move` L163–174): цель валидируется ДО чтения карточки — не inbox и нет доски →
400 «Переносить можно только на доски или в «Неразобранное»»; «в ту же колонку» — ранний выход без
журнала/обучения (guard T4-note); реальный перенос: col=to, isNew=false, prev_col=прежняя колонка,
matchHits через `ColumnRules.ComputeHits(правила доски, текст, курсы)` (Ruling 2; inbox/без правил — []),
журнал action=move (id `lm_`), PushAsync(text, `<b_…>`, 1.0) при to≠inbox и непустом тексте (Ruling 4).
Текст = `source_msg.strip() or title` (L167, L189191). Ответ — обновлённая карточка (перечитывание).
- **trash** (L194201): col=trash, isNew=false, prev_col=прежняя, matchHits=[]; журнал action=trash;
push spam 1.0 только если карточка НЕ была в trash/archive (L198); уже в trash — no-op.
- **restore** (L204222): куда — prev_col, если inbox или доска существует, иначе inbox (L209);
col=back, isNew=true, prev_col='inbox', archived_at=null (Ruling 10), matchHits пересчитаны; журнал
action=restore; возврат ИЗ корзины — push(text, "spam", 1.0) (L218221); из архива — без сигнала.
- **delete_forever** (L225234): существование → Cards+комментарии (FK cascade), журнал/outbox не трогаем.
- **clear_col** (L237247): только trash|archive, иначе 400 «Очищать можно только корзину или архив»; ответ — счётчик.
- **mark_seen** (L250256): карточка | колонка | все (пустые параметры = «не задан», как truthiness python).
- **add_comment** (L259265): пустой после Trim → 400 «Пустой комментарий»; карточки нет → 404-сигнал;
вставка LeadComments (id `cm_`, by «Вы», text.trim(), time «только что» — маппинг адаптера) + журнал
action=comment; ответ — полный список комментариев после добавления (фронт затирает массив карточки).
- **counts** (L268279): по Cards (col + isNew) + learning/ml/ai из `IMlClient.StatusAsync` (план L321):
Learning = count(CardMoves), Ml/Ai — KV-счётчики решений (этап 3 — 0, Ruling 4). Новое = сумма по колонкам.
- **search** (L509551, LIKE-вариант Ruling 6): q.trim().lower() короче 2 → пусто; подстрока в
title/summary/contact/source_msg (эквивалент `lower LIKE %q%`), col≠taken (адаптер), порядок received_at DESC,
лимит 12. Реализация — поверх `ListCardsAsync(null)` (порт поиска не имеет — YAGNI T2), messages:[] — на
совесть эндпоинта Task 8.
## Зафиксированные решения и расхождения с планом
1. **`CardMapper.cs` (план L324) НЕ создавался** — расхождение зафиксировано. Маппинг «строка → CardDto»
(JSON-поля, comments, human-метка time, receivedAt ms) уже живёт в EF-адаптере `KanbanStore` (T4; порт
возвращает готовые CardDto, IKanjStore doc «маппинг DTO ↔ строки выполняет адаптер вручную»). В чистый
модуль CardMapper не переносится: он бы дублировал адаптер и требовал EF-сущность (CardEntity) — модуль
их не видит. «Полная карточка» собирается методами хранилища (GetCardAsync/ListCardsAsync).
2. **Итоговые 400-тексты — константы CardsService**, а 404 «Карточка не найдена» — null-результатами методов
(эндпоинт Task 8 мапит, как BoardsService.PatchBoardAsync → null). Строки 1:1 с прототипом.
3. **Обучение ML — ВСЕГДА; `mlEnabled` сервис не читает** (контекст-вопрос «обучение вкл/выкл» закрыт):
ml_client.py L6–7 «обучение идёт всегда», выключатель управляет только использованием ML в пайплайне
(L160162); IMlClient.PushAsync doc (T5) тоже «идёт всегда и синхронно».
4. **`board_accepts` в move НЕ вызывается** — ручной перенос пользователя не фильтруется правилами
(прототип move_lead L177–191 доски-вето не проверяет); страховка BoardAccepts — для ИИ/ML путей
(этапы 4/6, Ruling 2). Существование доски при переносе валидируется (400-текст).
5. **Курсы для бюджетных правил** при move/restore читаются CardsService из кэша ratesCache (ISettingsStore,
Ruling 7: «чтение кэша — за вызывающим», см. BudgetInRange doc); пустой/битый кэш → мок-курсы
(семантика RatesService.LoadCacheAsync). Без правил/для inbox курсы не читаются.
6. **Result-DTO с текстом ошибки** (не исключения): конвенция кодовой базы — сервисы возвращают результат
(LoginResultDto/ChangePasswordResultDto/MlResetResultDto), эндпоинты мапят {detail}.
7. **archived_at при move/trash из archive обнуляется** (порт CardColumnUpdateDto не умеет «не трогать»:
null = сброс; в прототипе `_move` archived_at не трогает). На поведение не влияет: карточка уходит из
archive, очистка архива смотрит только col='archive' (T4 адаптер ListExpiredArchiveCandidates).
Карточка+журнал пишутся отдельными вызовами порта без транзакции (решение T4 L42–43).
8. **Move в колонку «в ту же»** при несуществующей цели и карточке: 400 (валидация раньше 404, L183184);
карточки нет при валидной цели — 404-сигнал (Lead=null).
9. FakeKanjStore не моделирует archived_at (в CardDto его нет, CardsService его не читает) — заметка в файле.
## Проверка
1. **Build**: `dotnet build Deal.sln` — Ошибок: 0, Предупреждений: 0 (TreatWarningsAsErrors).
2. **Тесты**: `dotnet test tests/Deal.Tests.Unit`**326/326 PASS** (база 282 + 44 новых):
чтение (список без taken/порядок, по колонке, get/404), move (matchHits со Стек и с Грейд+Бюджет
(word «мидл»), журнал lm_+push b_+1.0, inbox без push, неизвестная доска → 400-текст даже при
отсутствии карточки, no-op «в ту же колонку», доска без правил → hits [] + push, доска→доска
(prev_col+новая метка), title-fallback, пустой текст — журнал без push), trash (журнал+push spam 1.0,
no-op в корзине, из archive без push, 404), restore (в prevCol-доску с hits и 1.0, из archive без push,
удалённая доска → inbox, prevCol inbox, 404), delete_forever (карточка+комментарии удалены, журнал жив),
clear_col (trash счётчик, пустая → 0, доска/inbox → 400-текст), mark_seen (id/колонка/все),
комментарии (пустой → 400, валидный — trim/by «Вы»/cm_/журнал comment, 404), counts-форма
(Columns+New+learning/ml/ai из статуса, пустое хранилище), search (min-2, 4 поля+регистр+порядок,
taken исключён, лимит 12). MarkerTests PASS.
3. Диагностики по новым/изменённым файлам — нет ошибок/предупреждений.
## Concerns для Task 8 (эндпоинты)
- Маппинг null/результатов сервиса: GetCard/Trash/Restore/DeleteForever/AddComment-null → 404
«Карточка не найдена»; LeadMoveResultDto.Error/AddCommentResultDto.Error/ClearColResultDto.Error → 400
с текстом константы CardsService; MoveLead успех → 200 телом CardDto (обновлённая карточка).
- `GET /api/leads?col=` — проверку «Неизвестная колонка» (inbox/archive/trash/доски) делал роутер прототипа
(dashboard_routes L156) — её место в эндпоинте, в CardsService не клалась.
- counts: плоская wire-форма `{new, <col>: {…}, learning, ml, ai}` собирается из CardCountsDto
(Columns-словарь разворачивается в корневые ключи) на уровне эндпоинта.
- search: обёртка `{leads: SearchCardsAsync(...), messages: []}` (Ruling 6); move/trash/restore/comment
статические сегменты (`counts`, `clear-col`, `reclassify`, `mark-all-seen`, `mark-col-seen`) — ДО `{leadId}`
(Ruling 10). restore → `{ok: true, col}`.
- `/leads/{id}/seen` не входит в этап 3 (Ruling 11) — метод MarkSeenAsync(cardId, col) покрывает mark-all/col-seen.
@@ -0,0 +1,348 @@
#!/usr/bin/env sh
# Task 8 curl-приёмка /api/boards|/columns|/leads|/search на :5080 (план Task 8 L357-360; Rulings 10/11;
# dashboard_routes.py L92-256, api-map §3.2 L62-101, §4.1/§4.2). Сценарий: 401 без куки → login →
# пустые boards/leads/counts/colState → POST доски (sparse rules {mode,stack} — null-устойчивость) →
# GET /boards (голый массив) → PATCH width/collapsed → reorder → PATCH /columns/inbox/state →
# 400-ветки (null-name T6-note, неизвестная колонка, clear-col не trash/archive, пустой комментарий) →
# 404-ветки карточек (get/move/trash/restore/delete/comments на несуществующей) → reclassify-заглушка →
# search (пуст) → mark-col/all-seen → logout → 401. Полный цикл карточек (move/trash/restore/clear-col) —
# после Task 13 демо-карточек (T15 финальная приёмка).
set -u
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
BASE_URL="http://localhost:5080"
API_DIR="C:/telbase/src/core/Deal.Api"
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
JAR="/tmp/task8-jar.txt"
OUT="/tmp/task8-out.txt"
LOG="/tmp/task8-api.log"
PSQL_BASE="docker exec deal-postgres psql -U deal -d deal"
SCHEMA="tenant_00000000000000000000000000000001"
PASS_COUNT=0
FAIL_COUNT=0
check() {
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
desc=$1
shift
ok=1
for pat in "$@"; do
if ! grep -qF -- "$pat" "$OUT"; then
ok=0
fi
done
if [ "$ok" = 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не найдено: $*"
fi
}
cleanup() {
echo
echo "== Завершение: останавливаем Deal.Api (pid $APP_PID) =="
kill "$APP_PID" 2>/dev/null
sleep 2
if netstat -ano 2>/dev/null | grep -q ':5080'; then
taskkill //F //PID "$APP_PID" 2>/dev/null
fi
rm -f "$JAR" "$OUT"
}
trap cleanup EXIT INT TERM
rm -f "$JAR" "$OUT" "$LOG"
echo "== 0. Очистка kanban-таблиц дефолтного тенанта (повторяемость приёмки) =="
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"CardMoves\";"
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"LeadComments\";"
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Cards\";"
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Boards\";"
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".settings WHERE \"Key\" IN ('colState','mlDecisions','aiDecisions');"
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT (SELECT count(*) FROM \"$SCHEMA\".\"Boards\") + (SELECT count(*) FROM \"$SCHEMA\".\"Cards\") + (SELECT count(*) FROM \"$SCHEMA\".\"CardMoves\");")
if [ "$ROWS_LEFT" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] kanban-таблицы пусты"
else
echo " [FAIL] после очистки осталось строк: $ROWS_LEFT"
exit 1
fi
echo "== 0a. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) =="
cd "$API_DIR" || exit 1
ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
APP_PID=$!
i=0
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
i=$((i + 1))
if [ "$i" -ge 40 ]; then
echo " [FAIL] сервер не поднялся за 40 с (лог: $LOG)"
tail -n 20 "$LOG"
exit 1
fi
sleep 1
done
echo " [PASS] health: $(curl -s "$BASE_URL/api/health")"
echo
echo "== 1. GET /api/boards без куки — ожидаем 401 =="
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/boards" > "$OUT"
cat "$OUT"
echo
check "401 без сессии" '[HTTP:401]' '"detail":"Требуется авторизация"'
echo
echo "== 2. POST /api/auth/login admin/admin =="
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT"
cat "$OUT"
echo
check "login 200 ok" '[HTTP:200]' '"ok":true' '"login":"admin"'
echo
echo "== 3. Пустые boards/leads/counts/colState (после очистки) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/boards" > "$OUT"
cat "$OUT"
echo
check "GET boards — голый массив []" '[HTTP:200]' '[]'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/leads" > "$OUT"
cat "$OUT"
echo
check "GET leads — {items:[]}" '[HTTP:200]' '"items":[]'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/leads/counts" > "$OUT"
cat "$OUT"
echo
check "GET counts — плоская форма new/learning/ml/ai = 0" '[HTTP:200]' '"new":0,"learning":0,"ml":0,"ai":0'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/columns/state" > "$OUT"
cat "$OUT"
echo
check "GET columns/state — пустой объект {}" '[HTTP:200]' '{}'
echo
echo "== 4. POST /api/boards — Middle Python (sparse rules {mode,stack} — null-устойчивость) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/boards" \
-H "Content-Type: application/json" \
-d '{"name":"Middle Python","keywords":["python"],"rules":{"mode":"all","stack":["python"]}}' > "$OUT"
cat "$OUT"
echo
check "create 200 {id:b_...}" '[HTTP:200]' '"id":"b_'
BOARD_ID=$(grep -o '"id":"b_[0-9a-f]*"' "$OUT" | head -n1 | grep -o 'b_[0-9a-f]*')
echo " -> id: $BOARD_ID"
echo
echo "== 5. POST /api/boards — вторая доска (для delete/reorder) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/boards" \
-H "Content-Type: application/json" \
-d '{"name":"Vue Frontend","keywords":["vue"],"rules":{"mode":"any","stack":["vue","frontend"]}}' > "$OUT"
cat "$OUT"
echo
check "create 200 {id:b_...}" '[HTTP:200]' '"id":"b_'
BOARD2_ID=$(grep -o '"id":"b_[0-9a-f]*"' "$OUT" | head -n1 | grep -o 'b_[0-9a-f]*')
echo " -> id2: $BOARD2_ID"
echo
echo "== 6. GET /api/boards — голый массив из 2 досок с полями (name/keywords/rules) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/boards" > "$OUT"
cat "$OUT"
echo
check "boards 200, 2 доски" '[HTTP:200]' '"id":"' "$BOARD_ID" "$BOARD2_ID"
check "поле rules разобрано (mode+stack)" '"rules":{"mode":"all","direction":[],"keywords":[],"stack":["python"]'
check "keywords доски" '"keywords":["python"]'
echo
echo "== 7. PATCH /api/boards/{id} width/collapsed → {id} (quirk №10) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/boards/$BOARD_ID" \
-H "Content-Type: application/json" -d '{"width":"lg","collapsed":true}' > "$OUT"
cat "$OUT"
echo
check "PATCH 200 {id}" '[HTTP:200]' "\"id\":\"$BOARD_ID\""
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/boards" > "$OUT"
cat "$OUT"
echo
check "collapsed=true у доски в списке" '"collapsed":true' '"width":"lg"'
echo
echo "== 8. POST /api/boards/reorder {order:[id2,id1]} → {ok:true} =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/boards/reorder" \
-H "Content-Type: application/json" -d "{\"order\":[\"$BOARD2_ID\",\"$BOARD_ID\"]}" > "$OUT"
cat "$OUT"
echo
check "reorder 200 {ok:true}" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/boards/reorder" \
-H "Content-Type: application/json" -d '{}' > "$OUT"
cat "$OUT"
echo
check "reorder без order → 400" '[HTTP:400]' '"detail":"Не указан порядок колонок"'
echo
echo "== 9. GET /api/columns/state {} → PATCH /columns/inbox/state collapsed → {\"collapsed\":true} =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/columns/inbox/state" \
-H "Content-Type: application/json" -d '{"collapsed":true}' > "$OUT"
cat "$OUT"
echo
check "PATCH col-state — ответ одной колонки" '[HTTP:200]' '"collapsed":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/columns/state" > "$OUT"
cat "$OUT"
echo
check "GET col-state содержит inbox" '"inbox":{"collapsed":true}'
echo
echo "== 10. PATCH null-name → 400 (note task-6-review) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/boards/$BOARD_ID" \
-H "Content-Type: application/json" -d '{"name":null}' > "$OUT"
cat "$OUT"
echo
check "PATCH name:null → 400" '[HTTP:400]' '"detail":"Укажите название колонки"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/boards" \
-H "Content-Type: application/json" -d '{"name":null}' > "$OUT"
cat "$OUT"
echo
check "POST name:null → 400" '[HTTP:400]' '"detail":"Укажите название колонки"'
echo
echo "== 11. 404-ветки досок =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X PATCH "$BASE_URL/api/boards/b_000000000000" \
-H "Content-Type: application/json" -d '{"width":"md"}' > "$OUT"
cat "$OUT"
echo
check "PATCH несуществующей доски → 404" '[HTTP:404]' '"detail":"Доска не найдена"'
echo
echo "== 12. 400-ветки карточек без карточек (этап 3: карточки создаёт демо Task 13) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/leads?col=unknown_col" > "$OUT"
cat "$OUT"
echo
check "GET leads?col=unknown → 400 «Неизвестная колонка»" '[HTTP:400]' '"detail":"Неизвестная колонка"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/leads?col=$BOARD_ID" > "$OUT"
cat "$OUT"
echo
check "GET leads?col=<доска> → {items:[]}" '[HTTP:200]' '"items":[]'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/leads/l_000000000000/move" \
-H "Content-Type: application/json" -d '{"to":"not_a_board"}' > "$OUT"
cat "$OUT"
echo
check "move на неизвестную доску → 400 (текст move_lead)" '[HTTP:400]' '"detail":"Переносить можно только на доски или в «Неразобранное»"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/leads/clear-col" \
-H "Content-Type: application/json" -d '{"col":"inbox"}' > "$OUT"
cat "$OUT"
echo
check "clear-col inbox → 400" '[HTTP:400]' '"detail":"Очищать можно только корзину или архив"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/leads/l_000000000000/comments" \
-H "Content-Type: application/json" -d '{"text":" "}' > "$OUT"
cat "$OUT"
echo
check "пустой комментарий → 400 «Пустой комментарий»" '[HTTP:400]' '"detail":"Пустой комментарий"'
echo
echo "== 13. 404-ветки карточек (l_000000000000 не существует) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/leads/l_000000000000" > "$OUT"
cat "$OUT"
echo
check "GET leads/{id} → 404 «Карточка не найдена»" '[HTTP:404]' '"detail":"Карточка не найдена"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/leads/l_000000000000/move" \
-H "Content-Type: application/json" -d '{"to":"inbox"}' > "$OUT"
cat "$OUT"
echo
check "move карточки → 404 (карточки нет)" '[HTTP:404]' '"detail":"Карточка не найдена"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/leads/l_000000000000/trash" > "$OUT"
cat "$OUT"
echo
check "trash → 404" '[HTTP:404]' '"detail":"Карточка не найдена"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/leads/l_000000000000/restore" > "$OUT"
cat "$OUT"
echo
check "restore → 404" '[HTTP:404]' '"detail":"Карточка не найдена"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X DELETE "$BASE_URL/api/leads/l_000000000000" > "$OUT"
cat "$OUT"
echo
check "DELETE leads/{id} → 404" '[HTTP:404]' '"detail":"Карточка не найдена"'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/leads/l_000000000000/comments" \
-H "Content-Type: application/json" -d '{"text":"hello"}' > "$OUT"
cat "$OUT"
echo
check "comment на несуществующую → 404" '[HTTP:404]' '"detail":"Карточка не найдена"'
echo
echo "== 14. mark-col-seen / mark-all-seen (пустые колонки — ok) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/leads/mark-col-seen" \
-H "Content-Type: application/json" -d '{"col":"trash"}' > "$OUT"
cat "$OUT"
echo
check "mark-col-seen → {ok:true}" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/leads/mark-col-seen" \
-H "Content-Type: application/json" -d '{}' > "$OUT"
cat "$OUT"
echo
check "mark-col-seen без col → 400 (защита от «снять со всех»)" '[HTTP:400]'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/leads/mark-all-seen" > "$OUT"
cat "$OUT"
echo
check "mark-all-seen → {ok:true}" '[HTTP:200]' '"ok":true'
echo
echo "== 15. GET /api/search?q= (карточек нет) и reclassify-заглушка =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/search?q=python" > "$OUT"
cat "$OUT"
echo
check "search → {leads:[],messages:[]}" '[HTTP:200]' '"leads":[]' '"messages":[]'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/leads/reclassify" > "$OUT"
cat "$OUT"
echo
check "reclassify-заглушка Ruling 11" '[HTTP:200]' '"started":false,"busy":false,"attempted":0' 'ИИ недоступен — переклассификация требует сервиса ИИ'
echo
echo "== 16. DELETE доски (id2) → карточки→inbox (0) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -X DELETE "$BASE_URL/api/boards/$BOARD2_ID" > "$OUT"
cat "$OUT"
echo
check "DELETE board → {ok, movedToInbox:0}" '[HTTP:200]' '"ok":true' '"movedToInbox":0'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/boards" > "$OUT"
cat "$OUT"
echo
check "в списке осталась одна доска (id1)" '"id":"' "$BOARD_ID"
if grep -qF -- "$BOARD2_ID" "$OUT"; then
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] удалённая доска всё ещё в списке"
else
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] удалённой доски нет в списке"
fi
echo
echo "== 17. POST /api/auth/logout, затем GET /api/boards — 401 =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
cat "$OUT"
echo
check "logout 200" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/boards" > "$OUT"
cat "$OUT"
echo
check "после logout boards 401" '[HTTP:401]' '"detail":"Требуется авторизация"'
echo
echo "== 18. Очистка: удаляем kanban-данные и colState (dev-БД к исходному состоянию) =="
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"LeadComments\";"
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Cards\";"
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"Boards\";"
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".\"CardMoves\";"
$PSQL_BASE -c "DELETE FROM \"$SCHEMA\".settings WHERE \"Key\" IN ('colState','mlDecisions','aiDecisions');"
ROWS_LEFT=$($PSQL_BASE -t -A -c "SELECT (SELECT count(*) FROM \"$SCHEMA\".\"Boards\") + (SELECT count(*) FROM \"$SCHEMA\".\"Cards\") + (SELECT count(*) FROM \"$SCHEMA\".\"CardMoves\");")
if [ "$ROWS_LEFT" = "0" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] kanban-таблицы очищены (строк: $ROWS_LEFT)"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] kanban-таблицы не очистились (строк: $ROWS_LEFT)"
fi
echo
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
if [ "$FAIL_COUNT" != 0 ]; then
echo " [FAIL] есть проваленные проверки"
exit 1
fi
echo " [PASS] все проверки curl-приёмки прошли"
@@ -0,0 +1,88 @@
# Task 8 — «Эндпоинты досок/колонок/карточек/поиска + DI + curl-приёмка» — отчёт
Статус: **complete** (build 0/0, тесты 326/326 PASS, curl-приёмка :5080 — PASS=43 FAIL=0).
План: `docs/superpowers/plans/2026-09-05-deal-stage3-kanban.md` Task 8 (L334360), Rulings 10/11;
эталоны — `backend/app/routers/dashboard_routes.py` (L92256), `backend/app/services/leads.py`
(list_boards L4967, counts L268279), api-map §3.2 L62101/§4.1/§4.2, note из task-6-review (progress.md L19).
Контекст «Готово» подтверждён: BoardsService (T6) + CardsService (T7, результат-DTO с текстами 400,
counts CardCountsDto, search) — эндпоинты только мапят wire ⇄ сервисы.
## Файлы
### Создан — `src/core/Deal.Api/Endpoints/`
- `BoardsEndpoints.cs``MapBoardsEndpoints()`: GET /api/boards (голый массив), POST /api/boards → {id},
PATCH /api/boards/{boardId} → {id} (404 «Доска не найдена»), DELETE /api/boards/{boardId} → {ok, movedToInbox},
POST /api/boards/reorder → {ok}, GET /api/columns/state (объект), PATCH /api/columns/{colId}/state
(ответ — состояние только этой колонки, без null-полей, как exclude_none=True).
- `LeadsEndpoints.cs``MapLeadsEndpoints()`: GET /api/leads?col= (400 «Неизвестная колонка», валидация
inbox/archive/trash/существующая доска — dashboard_routes L156), GET /api/leads/counts (плоский
{new, <col>:{count,new}, learning, ml, ai} — разворачивание CardCountsDto здесь, note task-7 L122123),
GET /api/leads/{id} → лид (404 «Карточка не найдена»), POST mark-all-seen / mark-col-seen {col},
POST /{id}/move {to} → обновлённая карточка (400-текст move_lead / 404), POST /{id}/trash,
POST /{id}/restore → {ok, col}, DELETE /{id}, POST /leads/clear-col {col} → {ok, cleared}
(400-текст clear_col), POST /{id}/comments {text} → {comments} (400 «Пустой комментарий» / 404),
POST /leads/reclassify (заглушка Ruling 11), GET /api/search?q= → {leads, messages: []} (Ruling 6).
Статические сегменты зарегистрированы до /leads/{leadId} (Ruling 10). /leads/{id}/seen НЕ реализован (Ruling 11).
- `RequestModels/` (1 тип = 1 файл): `BoardCreateRequest`, `BoardPatchRequest`, `OrderBody`, `ColStateBody`,
`MoveBody`, `CommentBody`, `MarkColBody`, `ClearColBody`, `ReclassifyBody`.
### Изменён
- `src/core/Deal.Api/Program.cs``app.MapBoardsEndpoints(); app.MapLeadsEndpoints();` после
MapFilterTesterEndpoints. ProjectReference Kanban в Deal.Api.csproj уже был (T4) — без изменений.
- `.superpowers/sdd/deal-stage3-kanban/task-8-curl-acceptance.sh` (+ `task-8-curl-acceptance.log`).
## Реализация и зафиксированные решения
1. **401-гейт и резолв — эталон MlEndpoints/SettingsEndpoints**: `HasUser(context)` (GetCurrentUser) →
`EndpointResults.Unauthorized(AuthHelpers.UnauthorizedDetail)`; BoardsService/CardsService из
RequestServices ПОСЛЕ гейта (scoped на tenant-контекст запроса).
2. **400/404-маппинг**: 404 «Карточка не найдена»/«Доска не найдена» — null-результаты сервисов
(get/move/trash/restore/delete/comments; PatchBoardAsync); 400 — константы CardsService
(MoveTargetInvalidDetail/ClearColInvalidDetail/EmptyCommentDetail), «Неизвестная колонка» — у GET /leads.
3. **Note task-6-review «400 на null-name»**: POST /api/boards без name/явный null → 400 «Укажите название
колонки» (поле NOT NULL; прототип — pydantic 422); PATCH /api/boards/{id} с ЯВНЫМ «name":null → тот же 400.
PATCH читает тело как JsonElement именно чтобы различить «поля нет» (не меняем, как patch_board
patch[key] is not None) и «name: null» — STJ-биндинг record оба случая свёл бы к null.
4. **Note task-6-review «rules null-устойчивость»**: curl/фронт шлют правила неполными ({mode,stack} —
без direction/keywords/grade/exclude/budget); `NormalizeWireRules` в BoardsEndpoints приводит группы к
пустым спискам до вызова BoardsService (иначе NormalizeRules упал бы на null-списках). Wire-проверка
прошла: создание доски с {mode:"all", stack:["python"]} → правила сохранены и разобраны в GET /boards.
5. **counts**: плоская форма собирается словарём в порядке прототипа (new → колонки → learning/ml/ai);
`CardColumnCountDto` сериализуется {count, new}. Проверено на пустой БД: {"new":0,"learning":0,"ml":0,"ai":0}.
6. **colState wire**: ответы GET/PATCH строятся без null-полей ({collapsed:true}, а не {collapsed:true,width:null}) —
как exclude_none=True прототипа (PATCH /columns/inbox/state → {"collapsed":true}).
7. **Защитные 400 вместо pydantic-422** (у прототипа текста нет, зафиксированы как решения): mark-col-seen с
пустой/отсутствующей col → 400 «Неизвестная колонка» (иначе truthiness python снял бы «новое» со ВСЕХ);
reorder без order → 400 «Не указан порядок колонок» (иначе null-список → NRE в хранилище).
8. **reclassify** — заглушка Ruling 11 всегда {started:false, busy:false, attempted:0, reason:«ИИ недоступен —
переклассификация требует сервиса ИИ»}; тело ids опционально (фронт шлёт POST без тела) и игнорируется.
9. **Расхождение wire (принято, из T4/T6)**: GET /boards для доски без правил отдаёт "rules":null (python —
{}). Это следствие решения T6 (DB `{}` → DTO null); фронт терпим (BoardRulesDialog: b?.rules || {}).
В acceptance доски создаются с правилами — объект rules в ответе 1:1 с python.
## Проверка
1. **Build**: `dotnet build Deal.sln` — 0 ошибок / 0 предупреждений (TreatWarningsAsErrors).
2. **Тесты**: `dotnet test tests/Deal.Tests.Unit`**326/326 PASS** (новых не добавлялось: ветки эндпоинтов —
curl-приёмка, план L357).
3. **Curl-приёмка** (admin/admin, :5080, dev-БД очищена до/после): PASS=43 FAIL=0 (`task-8-curl-acceptance.log`):
boards/leads/counts/colState пусты; POST доски (sparse rules) → {id:"b_…"}; GET /boards — голый массив
(2 доски, keywords/rules); PATCH width/collapsed → {id}, состояние видно в списке; reorder → {ok:true} и
400 без order; PATCH /columns/inbox/state → {"collapsed":true}; null-name POST/PATCH → 400 «Укажите
название колонки»; PATCH несуществующей доски → 404; GET /leads?col=unknown → 400 «Неизвестная колонка»;
move на неизвестную доску → 400-текст move_lead; clear-col inbox → 400-текст; пустой комментарий → 400;
все 404-ветки карточек (get/move/trash/restore/delete/comments); mark-col-seen (ok + 400 без col);
mark-all-seen; search → {leads:[],messages:[]}; reclassify-заглушка; DELETE доски → {ok, movedToInbox:0};
logout → 401. Полный цикл карточек (move/trash/restore/clear-col/комментарий с данными) — после Task 13
(демо-карточки) в финальной приёмке T15, как предписывает план (L357–360).
4. Диагностики по новым файлам — без ошибок/предупреждений.
## Concerns для Task 9+
- Эндпоинты публикуют SSE-события НЕ будут (Ruling 5: публикации делает Task 10/13/14 из эндпоинтов
admin-tick/demo/suggest) — в BoardsEndpoints/LeadsEndpoints событий нет (в этапе 3 move/trash/restore
тостов не шлют, как прототип dashboard_routes L194221).
- Полный цикл карточек curl-приёмки — после T13: демо-карточки → move на доску (matchHits), trash/restore,
clear-col trash/archive, комментарий, counts/learning ненулевые.
- `POST /api/boards` с пустым name "" → «Новая колонка» (1:1 python); явный null — 400 (новая строка
«Укажите название колонки», в прототипе 422) — зафиксировано в п.3/7.
@@ -0,0 +1,142 @@
#!/usr/bin/env sh
# Task 9 curl-приёмка: SSE GET /api/events + boot-заглушки /api/projects и /api/tg/status на :5080
# (план Task 9 L376-378, Ruling 5/Ruling 9; events_routes.py L15-38; sse.py; api-map §4.9 L359).
# Сценарий: 401-гейты без куки (/events, /projects, /tg/status) → login admin/admin →
# /api/projects {items:[]} → /api/tg/status idle-форма §4.9 → SSE с кукой: заголовки
# text/event-stream + no-cache + X-Accel-Buffering:no, соединение держится, ping-комментарий ~15 с →
# logout → снова 401. Публикация событий проверяется в Tasks 10/13/14 (Ruling 5).
set -u
BASE_URL="http://localhost:5080"
API_DIR="C:/telbase/src/core/Deal.Api"
APP_EXE="$API_DIR/bin/Debug/net10.0/Deal.Api.exe"
JAR="/tmp/task9-jar.txt"
OUT="/tmp/task9-out.txt"
SSE_BODY="/tmp/task9-sse-body.txt"
SSE_HDRS="/tmp/task9-sse-hdrs.txt"
LOG="/tmp/task9-api.log"
PASS_COUNT=0
FAIL_COUNT=0
check() {
# $1 — описание; остальные аргументы — фиксированные подстроки ответа ($OUT)
desc=$1
shift
ok=1
for pat in "$@"; do
if ! grep -qF -- "$pat" "$OUT"; then
ok=0
fi
done
if [ "$ok" = 1 ]; then
PASS_COUNT=$((PASS_COUNT + 1))
echo " [PASS] $desc"
else
FAIL_COUNT=$((FAIL_COUNT + 1))
echo " [FAIL] $desc — не найдено: $*"
fi
}
cleanup() {
echo
echo "== Завершение: останавливаем Deal.Api (pid $APP_PID) =="
kill "$APP_PID" 2>/dev/null
sleep 2
if netstat -ano 2>/dev/null | grep -q ':5080'; then
taskkill //F //PID "$APP_PID" 2>/dev/null
fi
rm -f "$JAR" "$OUT" "$SSE_BODY" "$SSE_HDRS"
}
trap cleanup EXIT INT TERM
rm -f "$JAR" "$OUT" "$SSE_BODY" "$SSE_HDRS" "$LOG"
echo "== 0. Запуск Deal.Api на :5080 (ASPNETCORE_ENVIRONMENT=Development) =="
cd "$API_DIR" || exit 1
ASPNETCORE_ENVIRONMENT=Development "$APP_EXE" --urls "$BASE_URL" > "$LOG" 2>&1 &
APP_PID=$!
i=0
until curl -s -m 2 "$BASE_URL/api/health" | grep -q '"ok":true'; do
i=$((i + 1))
if [ "$i" -ge 40 ]; then
echo " [FAIL] сервер не поднялся за 40 с (лог: $LOG)"
tail -n 20 "$LOG"
exit 1
fi
sleep 1
done
echo " [PASS] health: $(curl -s "$BASE_URL/api/health")"
echo
echo "== 1. 401-гейты без куки: /api/events, /api/projects, /api/tg/status =="
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/events" > "$OUT"
cat "$OUT"
echo
check "/api/events без сессии → 401" '[HTTP:401]' '"detail":"Требуется авторизация"'
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/projects" > "$OUT"
cat "$OUT"
echo
check "/api/projects без сессии → 401" '[HTTP:401]' '"detail":"Требуется авторизация"'
curl -s -w "\n[HTTP:%{http_code}]" "$BASE_URL/api/tg/status" > "$OUT"
cat "$OUT"
echo
check "/api/tg/status без сессии → 401" '[HTTP:401]' '"detail":"Требуется авторизация"'
echo
echo "== 2. POST /api/auth/login admin/admin =="
curl -s -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT"
cat "$OUT"
echo
check "login 200 ok" '[HTTP:200]' '"ok":true' '"login":"admin"'
echo
echo "== 3. Boot-заглушка GET /api/projects → {items:[]} (этап 5) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/projects" > "$OUT"
cat "$OUT"
echo
check "/api/projects 200 {items:[]}" '[HTTP:200]' '{"items":[]}'
echo
echo "== 4. Boot-заглушка GET /api/tg/status → idle-форма §4.9 (этап 6) =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/tg/status" > "$OUT"
cat "$OUT"
echo
check "/api/tg/status 200 + все поля §4.9" '[HTTP:200]' \
'"phase":"idle"' '"connected":false' '"listener":false' '"account":""' \
'"monitored":0' '"keysSet":false' '"error":null' '"qrUrl":null'
echo
echo "== 5. GET /api/events с кукой: поток открыт ~20 с (curl -N -m 20), ping ~15 с =="
curl -s -N -m 20 -D "$SSE_HDRS" -b "$JAR" "$BASE_URL/api/events" > "$SSE_BODY" 2>&1
echo " -> curl завершился по -m 20 (соединение держалось, публикаций нет)."
echo " -> получено байт: $(wc -c < "$SSE_BODY")"
OUT="$SSE_HDRS"
check "SSE: Content-Type text/event-stream" 'Content-Type: text/event-stream'
check "SSE: Cache-Control no-cache" 'Cache-Control: no-cache'
check "SSE: X-Accel-Buffering no" 'X-Accel-Buffering: no'
OUT="$SSE_BODY"
check "SSE: ping-комментарий \": ping\" пришёл (~15 с тишины)" ': ping'
OUT="/tmp/task9-out.txt"
echo
echo "== 6. POST /api/auth/logout, затем GET /api/events — 401 =="
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" -c "$JAR" -X POST "$BASE_URL/api/auth/logout" > "$OUT"
cat "$OUT"
echo
check "logout 200" '[HTTP:200]' '"ok":true'
curl -s -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/events" > "$OUT"
cat "$OUT"
echo
check "после logout /api/events 401" '[HTTP:401]' '"detail":"Требуется авторизация"'
echo
echo "== Итог: PASS=$PASS_COUNT FAIL=$FAIL_COUNT =="
if [ "$FAIL_COUNT" != 0 ]; then
echo " [FAIL] есть проваленные проверки"
exit 1
fi
echo " [PASS] все проверки curl-приёмки прошли"
@@ -0,0 +1,75 @@
# Task 9 — «SSE-брокер + GET /api/events + boot-заглушки /projects и /tg/status» — отчёт
Статус: **complete** (build 0/0, тесты 332/332 PASS — +6 новых, curl-приёмка :5080 PASS=12 FAIL=0).
План: `docs/superpowers/plans/2026-09-05-deal-stage3-kanban.md` Task 9 (L362378), Ruling 5 (L108116),
Ruling 9 (L146150); эталоны — `backend/app/sse.py` целиком, `backend/app/routers/events_routes.py`
(L1538), `src/frontend/src/api.js` openEvents (L62104), `store.js` boot (L571581), api-map §4.9 (L359).
## Файлы
### Создан — `src/core/Deal.Api/Events/`
- `SseBroker.cs` — singleton per-tenant брокер: `Subscribe(tenantId)` → bounded-канал ≤200
(DropOldest = вытеснение старых, 1:1 sse.py get_nowait+put_nowait L3644); `Unsubscribe`;
`Publish(tenantId, eventType, payload)` (сериализация camelCase без \u) и `Publish(tenantId, SseEvent)`.
Публикация без подписчиков — no-op (не падает, Ruling 5). Потокобезопасность: словарь под гейтом,
запись в каналы — неблокирующий TryWrite вне гейта. `SseBroker.SubscriberQueueCapacity = 200` — public.
- `SseEvent.cs` — record (тип + JSON) + `RenderFrame()`: `event: <type>\ndata: <json>\n\n` (sse.py L30).
- `SseSubscription.cs` — record (Id, TenantId, ChannelReader<SseEvent>) — 1 тип = 1 файл.
### Создан — `src/core/Deal.Api/Endpoints/`
- `EventsEndpoint.cs` (`MapEventsEndpoint`) — GET `/api/events`: 401 {detail} без сессии (Ruling 10,
паттерн BoardsEndpoints); заголовки text/event-stream, Cache-Control: no-cache, X-Accel-Buffering: no;
подписка на канал `CurrentUser.TenantId`; ping-комментарий `: ping` каждые 15 с (per-connection
`WaitToReadAsync` + linked-CTS `CancelAfter(15s)` — решение «per-connection таймер», как events_routes.py
wait_for timeout=15); отписка в finally; завершение по RequestAborted; IOException при сбросе соединения
клиентом — штатный выход (без 500-шума в логе).
- `BootStubEndpoints.cs` (`MapBootStubEndpoints`) — GET `/api/projects``{items:[]}` (этап 5);
GET `/api/tg/status` → idle-форма §4.9 (этап 6). Оба — с 401-гейтом (как остальные группы boot).
### Изменён
- `src/core/Deal.Api/Program.cs``builder.Services.AddSingleton<SseBroker>();` (using Deal.Api.Events);
`app.MapEventsEndpoint(); app.MapBootStubEndpoints();` после MapLeadsEndpoints.
- `src/core/tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj` — ProjectReference на `Deal.Api` (для unit-тестов
SseBroker; первый референс на Api в тестах — см. Concern 1).
- Создан: `tests/Deal.Tests.Unit/SseBrokerTests.cs` (6 тестов).
## Решения (зафиксированные)
1. **Ping — per-connection таймер** (не фоновый): ровно как events_routes.py (`wait_for(q.get(),
timeout=15)` → `": ping\n\n"`); без глобального таймера и лишней сложности отписки от него.
2. **Канал — `Channel<SseEvent>`** (bounded, DropOldest, SingleReader=true): очередь ≤200 и вытеснение
старых даны типом канала, а не ручным кодом; TryWrite не блокирует и не бросает (публикация вне
tenant-запроса/без подписчиков не падает).
3. **Ключ канала — Guid TenantId** из CurrentUser (тенант сессии при подписке); публикующим эндпоинтам
(Tasks 10/13) доступен тот же CurrentUser, фоновому StorageTickScheduler (Task 11) — системный
репозиторий (Guid). Пустой канал тенанта удаляется при отписке последнего (no-op публикации).
4. **401 SSE-эндпоинта** — `EndpointResults.Unauthorized(...).ExecuteAsync(context)` до старта потока
(единый формат {detail} с остальными эндпоинтами).
5. **Сериализация payload** — `JsonSerializerDefaults.Web` + `UnsafeRelaxedJsonEscaping` (как
ConfigureHttpJsonOptions Program.cs): data-json camelCase, не-ASCII не \u-экранируются (sse.py
json.dumps ensure_ascii=False).
6. **Тест csproj без FrameworkReference AspNetCore** — хватило ProjectReference: SseBroker/SseEvent/
SseSubscription используют только BCL (System.Threading.Channels в Microsoft.NETCore.App); сборка
Deal.Api.dll в тест-хост не тянет ASP.NET-типы (ленивая загрузка). Concern — ниже.
## Проверка
1. **Build**: `dotnet build Deal.sln` (из `src/core`) — 0 ошибок / 0 предупреждений.
2. **Тесты**: `dotnet test tests/Deal.Tests.Unit` — **332/332 PASS** (было 326; +6 SseBrokerTests:
очередь ≤200 с вытеснением старых; publish до подписки — no-op без буферизации; publish после
отписки последнего — не падает; изоляция тенантов; отписка одного из двух; формат frame SSE).
3. **Curl-приёмка** (:5080, admin/admin; `task-9-curl-acceptance.log`): PASS=12 FAIL=0 —
401 без куки на /events, /projects, /tg/status; login; /api/projects → `{"items":[]}`; /api/tg/status →
все поля §4.9 (`{"phase":"idle","connected":false,"listener":false,"account":"","monitored":0,
"keysSet":false,"error":null,"qrUrl":null}`); SSE с кукой: `curl -N -m 20` — соединение держалось,
получено 8 байт = `: ping` (ping пришёл ~15 с), заголовки Content-Type text/event-stream +
Cache-Control no-cache + X-Accel-Buffering no; logout → /events 401.
4. Диагностики по новым/изменённым файлам — без ошибок/предупреждений.
## Concerns для Task 10+
1. **Тесты впервые ссылаются на Deal.Api** (web/exe SDK) — до Task 9 unit-проект Api не покрывал.
Референс собран и отработал (332/332), AspNetCore FrameworkReference не понадобился (тестируются
только BCL-типы брокера). Если будущие Api-тесты коснутся ASP.NET-типов — добавить
`<FrameworkReference Include="Microsoft.AspNetCore.App" />` в Deal.Tests.Unit.csproj.
2. **Публикации намеренно не проверялись e2e**: по плану (Ruling 5, Task 9 L377) события публикуют
Tasks 10/13/14 (admin/tick, demo, suggest) — здесь брокер покрыт unit-тестами, поток — curl (ping).
3. `Connection: keep-alive` прототипа (events_routes.py L34) не отправляем: Kestrel держит HTTP/1.1
keep-alive сам; в HTTP/2 заголовок Connection запрещён. Клиенту (EventSource) не требуется.