# Балансовый блок проектов (preflight_blocked_at) — разбор и план закрытия

**Дата:** 2026-06-23 · **Где работали:** только докалка (локальный dev), боевой не трогали.
**Кодовая фраза стены:** «роутер-наставник».

Документ собран в конце длинной сессии, чтобы следующая сессия подцепила контекст и начала закрывать вопросы. Скриншоты-доказательства лежат рядом в этой же папке (`report-*.png`).

> **ПДн:** `report-C1` (кабинет поставщика) НЕ коммитится — в кадре реальный телефон-источник омеги. Остаётся только локально на докалке. Остальные `report-*` — тест-данные докалки (демо-проекты), безопасны.

---

## 0. С чего началось

На проде зарегистрировался новый клиент (tenant 25, почта `t***@gmail.com`), создал проект «omega» (звонок, номер-источник `79XXXXXXXXX`, регион 29, лимит 50) при балансе 300 ₽. Вопрос владельца: как при 300 ₽ заказались 50 лидов у поставщика. Разбор привёл к фиче «балансовый блок проекта» (`preflight_blocked_at`, Spec C §3.4) и серии находок.

## 1. Что уже сделано в этой сессии (мой фикс)

**Фикс:** при **создании** проекта, помеченного `preflight_blocked_at` (клиент продавил «Сохранить и приостановить»), НЕ слать заказ поставщику.
- Код: [app/app/Services/Project/ProjectService.php:530](../../../../app/app/Services/Project/ProjectService.php#L530) — гард `if ($project->preflight_blocked_at === null) { SyncSupplierProjectJob::dispatch(...) }`.
- Тесты (TDD, GREEN): [app/tests/Feature/Billing/ProjectPreflightTest.php](../../../../app/tests/Feature/Billing/ProjectPreflightTest.php) — 2 новых: «does not order blocked project at supplier on create», «orders unblocked project at supplier on create». Прогон: 6/6 в файле, 18/18 с ProjectsStore. Pint чисто.
- **Не закоммичено, на боевой не катилось.** Изменены ровно 2 файла кода.

## 2. ПОЛНЫЙ СПИСОК вопросов — статус + ГДЕ ДОКАЗАТЕЛЬСТВА

Легенда: ✅ сделано (не трогать) · 🟡 частично (инфраструктура есть, допилить) · 🔴 открыто (делать/чинить). «(глаза)» = проверено вживую в браузере.

### ✅ Уже сделано — проверено, работать НЕ надо

| # | Вопрос | Доказательство |
|---|---|---|
| A | Заблокированный проект НЕ уходит к поставщику при **создании** | Код `ProjectService.php:530`; тесты `ProjectPreflightTest.php`. Глаза: `report-A1/A2/A3`, БД `supplier_projects=0`, кабинет crm.bp-gr.ru пуст (oба прогона) |
| B | Нельзя потратить сверх баланса / уйти в минус | `app/app/Services/Billing/LedgerService.php:72-84` (bcmath-гейт, throw до изменений) |
| C | «Баланс покрывает весь заказ» при **создании** + понятный диалог | `app/app/Http/Controllers/Api/ProjectController.php:140-161` (store) + `:212-234` (runPreflight). Глаза: `report-A2` диалог «Лимит превышает баланс» |
| D | То же правило при **одиночной правке** объёма | `ProjectController.php:179-202` (update→409). Глаза: `report-F1` — панель справа вернула **409** (5000₽=10 лидов, запрошено 50). *Было моим ошибочным «нет», исправлено проверкой глазами* |
| I | Слепок 18:00 + сообщение «изменения вступят с…» | `SupplierSnapshotGuard::appliesFrom` (зовётся в `ProjectService::update`), баннер «вносите до 18:00 МСК» на /projects |
| K | CSV-сверка НЕ списывает за отбитые лиды | `app/app/Jobs/Supplier/CsvReconcileJob.php:162` — гонит восстановленные лиды через тот же `RouteSupplierLeadJob`→`chargeForDelivery` (gated) |

### 🟡 Частично — инфраструктура есть, ДОПИЛИТЬ

| # | Что | Доказательство + что именно допилить |
|---|---|---|
| F | Цикл восстановления по балансу | Полный цикл заморозки **клиента** уже есть: `app/app/Jobs/Billing/BalancePreflightSweepJob.php:73-114` (active↔frozen, письмо, ре-синк). **Допилить:** подключить к нему **проектный** блок (`preflight_blocked_at`), который сейчас sweep намеренно пропускает (`:138` `->whereNull('preflight_blocked_at')`) |
| G | Все пути синхронизации уважают/снимают флаг | create ✅ (`ProjectService.php:530`) + ночной sweep ✅ (`BalancePreflightSweepJob.php:138`). **Допилить 3 пути, шлют безусловно:** `ProjectService::update` (`:106`), `triggerSync` ручная «Синхронизировать» (`:223-225`), `toggleActive` пауза/возобновление (`ProjectController.php:281`) |
| H | Метка на карточке | Глобальный баннер есть: `resources/js/components/billing/BalanceFrozenBanner.vue:32` «Приём лидов приостановлен». **Допилить:** карточка `resources/js/components/projects/ProjectCard.vue:120` маппит только sync_status → «Ожидает синхр.»; для заблокированного показать «Приостановлен — не хватает баланса» |
| J | Два «замка» (клиент `frozen_by_balance_at` vs проект `preflight_blocked_at`) | Решить связь/иерархию — идёт вместе с F |

### 🔴 Открыто — ДЕЛАТЬ / ЧИНИТЬ

| # | Что | Доказательство |
|---|---|---|
| E | **Авто-снятие блока при пополнении** (политика «всё или ничего») | Греп: `preflight_blocked_at` нигде не ставится в null. Топ-ап (`app/app/Services/Billing/BillingTopupService.php`, `OnlineTopupService.php`), вебхук (`PaymentWebhookController.php`), sweep — флаг не трогают |
| M | **Молчаливый баг диалога «Редактировать»** (sms_senders) | `app/app/Http/Requests/UpdateProjectRequest.php:28` `['sometimes','array','min:1']` + фронт `resources/js/views/projects/NewProjectDialog.vue:353-354,385` шлёт весь объект, `sms_senders:null`. Глаза: PATCH `/api/projects/{id}` → **422** дважды, диалог висит молча. `report-E1` |
| N | **Массовое «изменить лимит» обходит баланс** | `ProjectService::bulkAction:264-271` → `bulkUpdateLimit:400-434` — префлайта НЕТ. Глаза: выставил двум проектам 50+50 при ёмкости 10 → POST `/api/projects/bulk` **200 OK**, `daily_limit_target=50`, `blocked=NULL`. Деньго-значимо |
| O | **Молчание при блокировке на правке** (панель/диалог 409 — клиент ничего не видит) | Глаза: `report-F1` панель вернула 409, но ни тоста, ни диалога; только **создание** показывает диалог |
| L | **Омега на проде** (tenant 25 уже «заблокирован-но-заказан») — разовая сверка | Прод live-запрос: project 188 `preflight_blocked_at=2026-06-22 15:05:34`, `supplier_b1/b2/b3=494/495/496`, в слепок не попадает. `report-D1` показывает тот же класс рассогласования на докалке |
| K+ | Внешний вопрос: берёт ли **поставщик** деньги в СВОЕЙ сверке за отбитое | Не наш код — контракт с поставщиком, отдельно |

## 3. Принятые решения владельца

- **E (триггер):** авто при пополнении. Политика мульти-проекта — **«всё или ничего»**: хватает на весь заказ (все проекты вкл. заблокированные) → снять блок со всех + синк; не хватает → не включать никого + показать сигнал «не хватает X ₽». (Закрывает бывший вопрос «какой проект выбрать».)
- **G:** чинить ВСЕ пути (update, toggle, ручная синхр), не только создание.
- **H:** метка «Приостановлен — не хватает баланса».
- **L:** омегу на проде привести в согласованное состояние (после фикса).
- **M, N, O** — новые находки, владелец просил включить в план.

## 4. Рабочий принцип (важно для следующей сессии)

**Прежде чем объявлять баг и тем более чинить — проверять И по коду, И глазами/руками, вместе**, что это действительно не реализовано (вдруг сделано в другом месте). В этой сессии принцип уже спас от ложного «правку нельзя/без баланса» — оказалось, правка через боковую панель баланс проверяет (D).

## 5. Заметки по окружению докалки (как поднять и проверять)

- DB: `pgsql`, `DB_DATABASE=liderra`, `DB_USERNAME=postgres` (superuser, RLS обходится). Тариф на докалке = **500 ₽/лид** (на проде T1=70 ₽) — балансы под тест считать от 500.
- Поднять: `php artisan serve --host=127.0.0.1 --port=8000` (фон) + `php artisan queue:work` (фон, нужен только когда реально шлём к поставщику). Redis обязателен (QUEUE=redis).
- **`public/hot`**: портал настроен на Vite dev-сервер; если его нет — `mv public/hot public/hot.bak` чтобы Laravel отдавал собранный билд (`public/build`). Вернуть в teardown.
- Капчи на ВХОДЕ нет (только на регистрации) → тест-клиента сидить через `tinker` фабриками (`Tenant::factory()->withRequisites()`, `User::factory()`, пароль в колонку `password_hash` = `bcrypt(...)`, не `password`).
- Режим поставщика: `system_settings.supplier_export_mode` (`batch`/`online`). Менять — вернуть в teardown.
- Сессия поставщика: `php artisan supplier:session:refresh` (логинится в crm.bp-gr.ru; «Supplier session refreshed» = мост жив). Кабинет: https://crm.bp-gr.ru/login (логин и пароль — в `.env` `SUPPLIER_LOGIN`/`SUPPLIER_PASSWORD`), проекты — `/admin/visit/rt`. **Прод и докалка ходят в ОДИН реальный аккаунт поставщика** — тест-проекты обязательно убирать (`DeleteSupplierProjectJob` после снятия ссылок/удаления локального проекта).
- **Квирк браузера:** Playwright держит старую сессию между прогонами — перед входом новым клиентом выходить (меню пользователя → «Выйти»).
- **Квирк tenant:** `Tenant` — SoftDeletes, `$t->delete()` не каскадит; чистить проекты `forceDelete()`; полностью стереть юзера мешает append-only `auth_log` (FK) — оставлять мягко-удалённым, это безвредно.
- Регрессия Pest на Windows-native PG: часть supplier-сьютов краснеет от env-квирка (не код, на CI Linux зелено) — проверял stash-сравнением, мой фикс чист.

---

## 6. ПРОМТ ДЛЯ СЛЕДУЮЩЕЙ СЕССИИ (скопировать целиком)

```
Кодовая фраза «роутер-наставник». Продолжаем работу по балансовому блоку проектов
(preflight_blocked_at). Контекст и ВСЕ доказательства — в
docs/superpowers/findings/2026-06-23-balance-block/HANDOFF.md (скриншоты там же).
Прочитай его ПЕРВЫМ делом, особенно §2 (список с доказательствами) и §3 (решения).

Работаем ТОЛЬКО на докалке (как поднять — §5 HANDOFF), на боевой ничего не катим.
Жёсткое правило (§4): прежде чем чинить пункт — подтверди И по коду, И глазами/руками,
что это реально не сделано в другом месте.

Закрываем по плану (решения владельца уже зафиксированы в §3):
  E — авто-снятие preflight_blocked_at при пополнении, политика «всё или ничего»
      (хватает на весь заказ всех проектов → снять блок со всех + синк; не хватает →
       никого + сигнал «не хватает X ₽»). Через TDD.
  G — чтобы флаг уважали ВСЕ пути синхронизации: update / toggle-active / ручная
      «Синхронизировать» (triggerSync) — как уже делают create и ночной sweep.
  H — метка на карточке заблокированного проекта «Приостановлен — не хватает баланса».
  M — починить молчаливый 422 диалога «Редактировать» (sms_senders) для site/call.
  N — массовое «изменить лимит» обходит префлайт баланса → добавить проверку.
  O — показывать клиенту блок при 409 на правке (панель/диалог), сейчас молчит.
  L — после фикса: разово привести омегу на проде (tenant 25, проект 188) в согласие.

Каждый пункт: сначала падающий тест (TDD), потом минимальная правка, потом GREEN,
плюс визуальная проверка в браузере (скриншот) по образцу из HANDOFF. После каждого
блока — escape на коммит у владельца. Начни с E (ядро) или с N (деньго-значимо) — спроси
у владельца, с чего начать.
```

## 7. Хвосты этой сессии (для закрытия)

- Код-фикс (2 файла) + этот HANDOFF + 8 скриншотов — закоммитить и запушить на gitea.
- Память: запись о сессии (см. memory).
- Тест-данные докалки убраны (tenants 2/3/4/5 мягко-удалены, supplier-проекты удалены, кабинет чист, `public/hot`/`supplier_export_mode` восстановлены, серверы остановлены).
