# Аудит «Создание/изменение проектов и миграция к поставщику»

**Дата:** 23.05.2026
**Контекст:** Live-эксперимент на local dev-сборке (`127.0.0.1:8000`, demo-tenant 1, пользователь `admin@demo.local`), две вкладки браузера + анализ кода `SupplierQuotaAllocator`, `ProjectService`, `SyncSupplierProjectJob`, `NewProjectDialog.vue`, `StoreProjectRequest.php`.
**Скоуп:** UI-форма создания/редактирования проекта, серверная валидация, гонки между двумя сотрудниками одного клиента и между разными клиентами, формула заказа лидов у поставщика при N клиентах на одном источнике.
**Что НЕ проверено:** реальная синхронизация с боевым crm.bp-gr.ru (`Sync pending` не отрабатывает локально), `LeadRouter` (распределение пришедшего лида между конкурирующими клиентами), импорт CSV.

---

## Часть A — Зафиксированные баги (17 findings)

Шкала: **P0** — критичный (деньги/обман клиента) • **P1** — важный (UX/гонки/корректность) • **P2** — полировка • **P3** — минор.

| # | Prio | Краткое описание | Где |
|---|---|---|---|
| **B-01** | **P0** | При 4+ клиентах с равными лимитами на одном источнике каждый получит существенно меньше лидов, чем заявил в `daily_limit_target`. UI не предупреждает. | [SupplierQuotaAllocator.php:88-98](app/app/Services/Supplier/SupplierQuotaAllocator.php#L88-L98) + [NewProjectDialog.vue](app/resources/js/views/projects/NewProjectDialog.vue) |
| **B-02** | P1 | Тексты ошибок 422 показываются как `validation.required` (технический i18n-ключ), а не как человекочитаемое сообщение. | [NewProjectDialog.vue:328](app/resources/js/views/projects/NewProjectDialog.vue#L328) принимает `errors` от бэка как есть |
| **B-03** | P1 | Конфликты уникальности показываются поэтапно: сначала имя (`assertNameUnique`), потом источник (`assertSourceUnique`) — клиент гоняется ступенчато. | [ProjectService.php:461-462](app/app/Services/Project/ProjectService.php#L461-L462) |
| **B-04** | P1 | Список проектов не обновляется в реальном времени. Два сотрудника одного клиента не видят действий друг друга без F5. | `ProjectsView.vue` без polling/WebSocket |
| **B-05** | P1 | Нет блокировки одновременного редактирования одного проекта (last-write-wins). | `PATCH /api/projects/{id}` без `If-Match`/`updated_at` гарда |
| **B-06** | P1 | `assertNameUnique`/`assertSourceUnique` делают `SELECT exists` + `INSERT` без транзакции/lock и без DB-уровневого `UNIQUE` constraint на `(tenant_id, name)` — теоретическая гонка при идеальной одновременности двух сотрудников. | [ProjectService.php:393-441](app/app/Services/Project/ProjectService.php#L393-L441) |
| **B-07** | P1 | При создании проекта на источнике, где уже есть проекты других клиентов, клиент не получает предупреждение «вас уже N — фактический лимит будет ужат до X». | UI не показывает контекст supplier_projects group |
| **B-08** | P1 | `workdays` и `regions` в `supplier_projects` — UNION активных клиентов группы. Изменение дней/регионов одного клиента расширяет общую заявку у поставщика, затрагивая других клиентов незримо. | [SupplierQuotaAllocator.php:59-60](app/app/Services/Supplier/SupplierQuotaAllocator.php#L59-L60) |
| **B-09** | P2 | Кнопка «Создать» не disabled при невалидной форме — клиент может жать многократно вхолостую. | `NewProjectDialog.vue` нет `:disabled` гейта |
| **B-10** | P2 | Локальная валидация только для регионов. Пустые name/source/limit ловятся только сервером (422 после клика «Создать»). | [NewProjectDialog.vue:305-313](app/resources/js/views/projects/NewProjectDialog.vue#L305-L313) |
| **B-11** | P2 | UI разрешает выключить все 7 дней недели. Ошибка только от сервера (`delivery_days_mask` min:1 в [StoreProjectRequest:30](app/app/Http/Requests/StoreProjectRequest.php#L30)). | `v-btn-toggle` без `mandatory` |
| **B-12** | P2 | Подсказка «без keyword проект подключится только к B3» спрятана в hint поля `sms_keyword`, не видна без фокуса. | [NewProjectDialog.vue:62](app/resources/js/views/projects/NewProjectDialog.vue#L62) |
| **B-13** | P2 | Лимит тарифа `tenants.limits.max_projects` нигде не показан на странице. Клиент узнаёт об упоре только после 403 «Достигнут лимит проектов». | [ProjectService.php:443-451](app/app/Services/Project/ProjectService.php#L443-L451) |
| **B-14** | P2 | Bulk-операции толерантны к параллельно удалённым ID (`update` по несуществующей строке — 0 rows, без ошибки). Manager2 видит «Применено», хотя Manager1 успел удалить часть выборки. | [ProjectService.php:261-269](app/app/Services/Project/ProjectService.php#L261-L269) |
| **B-15** | P3 | `DevIndexBadge` (`18 NewProjectDialog`, `19 EditProjectDialog`) виден на dev-сборке — гарантировать, что на проде он скрыт. | `DevIndexBadge.vue` |
| **B-16** | P3 | Статус `Sync pending` на карточке не обновляется в реальном времени — клиент не знает, когда синк закончился. | `ProjectCard.vue` |
| **B-17** | P3 | Баннер «изменения вносите до 18:00 МСК» висит только на странице `/projects`, в самом диалоге создания/редактирования его нет — клиент в 17:55 может не вспомнить. | `ProjectsView.vue` vs `NewProjectDialog.vue` |

---

## Часть B — Формула распределения лидов между клиентами на одном источнике

Источник истины — [SupplierQuotaAllocator::computeOrder](app/app/Services/Supplier/SupplierQuotaAllocator.php#L88-L98):

```
order_y_postavshika = max( наибольший_лимит_клиента,  ceil( Σ_лимитов_клиентов / 3 ) )
```

**Где 3** = заявленная ёмкость шаринга (один лид может быть продан до 3 раз клиентам Лидерры). Это магический коэффициент в коде, не параметр.

После расчёта `order` он делится между площадками B1/B2/B3 ровно (`distributeForPlatform` largest-remainder, Σ_per_platform == order).

### Что это означает на практике (все клиенты с лимитом 50 на одном `okna-konkurent.ru`)

| Клиентов | Σ лимитов | max | order у поставщика | Фактический потолок на клиента в идеале¹ | Дрейф vs заявленных 50 |
|---|---|---|---|---|---|
| 1 | 50 | 50 | **50** | 50 | 0% |
| 2 | 100 | 50 | **50** (max=50 > ⌈100/3⌉=34) | 25 | **−50%** |
| 3 | 150 | 50 | **50** (max=50 = ⌈150/3⌉=50) | 17 | **−66%** |
| **4** | 200 | 50 | **67** (⌈200/3⌉=67 > 50) | 17 | **−66%** |
| 5 | 250 | 50 | **84** | 17 | **−66%** |
| 6 | 300 | 50 | **100** | 17 | **−66%** |
| 10 | 500 | 50 | **167** | 17 | **−66%** |

¹ Идеальный потолок = `order / клиентов`, если поставщик раздаёт равномерно. Фактическое распределение зависит от `LeadRouter` (вне scope этого аудита).

**Главные выводы про формулу:**

1. **Уже на двух клиентах** заявленный потолок 50 фактически становится потолком 25 на каждого (`order` остаётся 50 потому, что max=50, а лид шарится).
2. **Скачок происходит на 4-м клиенте** — `ceil(Σ/3)` обгоняет `max`, и `order` начинает расти, но всё равно медленнее, чем суммарный спрос.
3. **При большом неравенстве** (например, один клиент 1000 + ещё 9 по 50) — `order = max(1000, ⌈1450/3⌉=484) = 1000`. Маленькие клиенты технически могут получить лиды из 1000, но конкурируют с большим (это уже LeadRouter).
4. **UI клиенту обещает 50** в любой из этих ситуаций — клиент не видит других участников.

### Как клиенты влияют друг на друга через UNION (B-08)

- **`workdays`** объединяются — если клиент A выбрал только Пн-Пт, а клиент B добавил Сб-Вс, поставщик заказывает лиды семь дней в неделю на ОБОИХ. Клиент A в Сб-Вс лидов не получит (`is_active` через workdays на стороне портала), но supplier_project shape расширен.
- **`regions`** объединяются аналогично — Москва + СПб = supplier_project на оба субъекта.
- Удаление/пауза одного клиента → `order` пересчитывается с меньшей Σ → заказ у поставщика сужается → все оставшиеся получают больше.

---

## Часть C — Чек-лист ручной проверки

> **Подготовка:** на dev-сервере (`127.0.0.1:8000` — сейчас в фоне PID `b4uy22rzc`) нужны **минимум 5 разных tenant'ов** с разными пользователями. Если демо-сидер кладёт только tenant 1 — нужно через `php artisan tinker` создать `tenant_2..tenant_5` (`max_projects=10`, активный pricing tier) и юзера в каждом. Без этого раздел C нельзя пройти; разделы A и B можно пройти и в одном tenant'е.

### A. Один клиент — базовый сценарий

| # | Шаг | Ожидаемо | ✅ / ❌ |
|---|---|---|---|
| A1 | Зайти `/projects` → видна кнопка «+ Создать проект», баннер про 18:00 МСК. | Видно | |
| A2 | Нажать «Создать проект» → диалог открыт, вкладка «Сайт» по умолчанию. | Да | |
| A3 | Ввести домен `test-site-1.ru` + имя `Test-Site-1` + чекнуть «Вся РФ» → «Подтверждаю» + «Создать». | 201, карточка в списке, `0/50 лидов`, `Sync pending`. | |
| A4 | Переключиться на вкладку «Звонок» в новой попытке создания → ввести `79991234567` + имя `Test-Call-1`. | 201, в списке. | |
| A5 | Вкладка «СМС» → ввести минимум 1 отправителя через chip-input + оставить keyword пустым → создать `Test-SMS-1`. | 201; **проверить, что в supplier_projects этот проект попал ТОЛЬКО к площадке B3** (бэк-side, через tinker `\App\Models\SupplierProject::where('unique_key', '<sms-key>')->get(['platform'])`). | |
| A6 | Открыть редактирование `Test-Site-1` → попробовать сменить «Сайт» на «Звонок». | Вкладки **disabled** (immutable). | |
| A7 | Попытаться поставить `daily_limit_target = 0` → «Создать». | 422 от бэка («min:1») — но локально UI не блокирует (B-09/B-10). | |
| A8 | В новой форме выключить все 7 дней недели → «Создать». | 422 от бэка, локально не блокирует (B-11). | |
| A9 | Заполнить корректно → нажать «Создать» когда уже создано 10 проектов (упор тарифа). | 403 «Достигнут лимит проектов» (B-13). | |
| A10 | Удалить проект `Test-Call-1`. | DELETE 204; карточка пропала. **Бэк-side: проверить, что `SyncSupplierProjectJob::dispatch` сработал и в `supplier_projects` запись удалена (если других клиентов на источнике нет) или `order` пересчитан.** | |

### B. Два сотрудника одного клиента (одного tenant) — гонки

> Открыть 2 окна браузера в **разных incognito-сессиях** одного компьютера (или 2 разных браузера). Залогиниться обоими пользователями одного tenant (например, `admin@demo.local` + `manager1@demo.local`).

| # | Шаг | Ожидаемо | ✅ / ❌ |
|---|---|---|---|
| B1 | Оба зашли на `/projects`. User1 создал `Race-A` / `race-a.ru`. **User2 не нажимает F5.** | User2 в своём списке `Race-A` **не видит** (B-04). После F5 — видит. | |
| B2 | Оба готовят форму с одинаковым `Race-B` / `race-b.ru`. User1 жмёт «Создать» → 201. User2 через 5 сек жмёт «Создать». | User2 получает 422 «Проект с таким названием у вас уже есть» — **только** про имя; про источник промолчало (B-03). | |
| B3 | User2 меняет имя на `Race-B-2`, домен оставляет, жмёт «Создать». | 422 «У вас уже есть проект с этим источником: Race-B». Текст человекочитаемый (а не `validation.required` — это для других правил). | |
| B4 | Оба открыли редактирование `Race-B` одновременно. User1 поменял лимит на 100. User2 поменял имя на `Race-B-renamed`. | Нет блокировки. Last-write-wins (B-05). У User1 имя останется старым; у User2 лимит останется 50. После F5 каждый увидит итог. | |
| B5 | Оба выбрали 5 проектов в bulk. User1 → «На паузу». User2 → «Удалить» (один из общих). | User2's «Удалить» успешно удалит. User1's «На паузу» не покажет ошибку, но фактически 4 поставлены, один пропущен (B-14). | |
| B6 | Открыть `\App\Models\AuditLog::where('event','project.created')->latest()->first()` в tinker. | Записи есть, `user_id` и `tenant_id` корректные. | |

### C. 4+ разных клиентов на одном источнике — формула лидов

> Тут нужно 5 tenants с лимитом `max_projects ≥ 1`, каждый со своим пользователем. Все создают проект на **одном** домене `shared-okna.ru`.

| # | Шаг | Ожидаемо | ✅ / ❌ |
|---|---|---|---|
| C1 | Tenant1 создаёт проект `Okna-T1` / `shared-okna.ru` / лимит 50 / Вся РФ / все дни. | 201. **Бэк:** в `supplier_projects` появляется 3 записи (B1/B2/B3) с `unique_key` от `shared-okna.ru`, у каждой `limit ≈ 17` (50÷3 largest-remainder = 17/17/16). | |
| C2 | Tenant2 создаёт такой же проект `Okna-T2` / тот же домен / 50. | 201. **Бэк:** `supplier_projects` НЕ дублируются — pivot `project_supplier_links` приращивается. `order` пересчитан: `max(50, ⌈100/3⌉=34) = 50` → лимиты на B1/B2/B3 остаются 17/17/16. | |
| C3 | Tenant3 — то же. | 201. `order = max(50, ⌈150/3⌉=50) = 50`. Лимиты на платформах не меняются. | |
| C4 | **Tenant4** — то же. | 201. **`order` скачком меняется на 67** (⌈200/3⌉=67 > 50). Лимиты на B1/B2/B3 = 23/22/22. **Это и есть момент B-01.** Ни один клиент об этом не уведомлён. | |
| C5 | Tenant5 — то же. | `order = max(50, ⌈250/3⌉=84) = 84`. Лимиты 28/28/28. | |
| C6 | Открыть UI у Tenant1: его проект `Okna-T1` показывает `0 / 50 лидов`. | Бекенд знает, что фактический потолок ≈ 84÷5 ≈ 17 лидов в день. UI показывает 50 — **обман клиента** (B-01). | |
| C7 | Tenant3 ставит свой проект на паузу. | `SyncSupplierProjectJob` пересчитает: `order = max(50, ⌈200/3⌉=67) = 67`. Лимиты 23/22/22. Оставшиеся 4 клиента не уведомлены, но фактически получат больше. | |
| C8 | Tenant1 меняет дни приёма с «все» на «только Пн-Пт». | Бэк: `workdays` в `supplier_projects` остаётся `[1..7]` (UNION с другими). На стороне поставщика заказ всё ещё 7 дней. Tenant1 в Сб-Вс лидов не получит (фильтр на стороне портала), но supplier_project shape не сужается (B-08). | |
| C9 | Tenant1 меняет регионы с «Вся РФ» на «только Москва». | `regions` в supplier_projects = UNION ∪ [Москва] = всё ещё «Вся РФ» (другие клиенты держат). | |
| C10 | Tenant4 удаляет свой проект → `DeleteSupplierProjectJob` отрабатывает. | Pivot урезается; `supplier_projects` остаётся (есть другие); `order` пересчитан до 50. Лимиты на B1/B2/B3 → 17/17/16. | |
| C11 | Все 5 удаляют свои проекты подряд. | После последнего `supplier_projects` для `shared-okna.ru` удаляются у поставщика (3 записи B1/B2/B3 чистятся). Проверить через `\App\Models\SupplierProject::where('unique_key','LIKE','%shared-okna%')->count() == 0`. | |

### D. Стресс — 5+ клиентов одновременно создают на одном источнике

> Не обязательно ручной — можно через CLI-скрипт (см. ниже). Цель: убедиться, что нет race-condition на `assertSourceUnique` между tenant'ами (его и не должно быть — guard per-tenant), а на стороне `supplier_projects` нет дублей `unique_key`.

| # | Шаг | Ожидаемо | ✅ / ❌ |
|---|---|---|---|
| D1 | Подготовить 5 tenants. В tinker запустить параллельные dispatch'ы 5 проектов на один домен (`Parallel\Future` или 5 отдельных `php artisan tinker --execute` процессов). | Все 5 проектов создаются, в `supplier_projects` ровно 3 записи (B1/B2/B3) с одним `unique_key`. Дублей нет. | |
| D2 | `php artisan queue:work --once` пять раз, прогнать SyncSupplierProjectJob каждого. | Все 5 пройдут. `aggregate_daily_limit` (если есть колонка) = 250, `order` = 84. | |
| D3 | Поставить 20 проектов разных tenant'ов в очередь одновременно. Замерить, сколько уходит в очереди и за какое время отрабатывает (`Horizon` или `php artisan queue:monitor`). | На 2GB VPS с 5 workers — ~4-6 секунд на один dispatch (http к поставщику + retry). 20 проектов — 16-24 секунды. | |
| D4 | Проверить `failed_jobs` после стресса. | Пусто. Если что-то упало в retry-шторм (как было 22.05) — проверить, что `findOrFail` заменён на `find` + terminal (см. [[project_supplier_retry_storm]]). | |

### E. Миграция к поставщику — функциональная корректность

| # | Шаг | Ожидаемо | ✅ / ❌ |
|---|---|---|---|
| E1 | Создать проект → проследить, что `SyncSupplierProjectJob` в очереди (`Redis::lrange('queues:default', 0, -1)` или Horizon). | Job есть, payload содержит `project_id`. | |
| E2 | Прогнать queue → проверить `supplier_projects.synced_at`. | Заполнен timestamp. | |
| E3 | На стороне поставщика (через MCP `mcp__redis__get` для очередей или live API crm.bp-gr.ru `/projects/list` — если есть тестовый туннель) увидеть появление проекта с правильным `limit`. | Есть, лимит совпадает с distributeForPlatform. | |
| E4 | Изменить `daily_limit_target` существующего проекта → `needsResync` = true ([ProjectService.php:45-50](app/app/Services/Project/ProjectService.php#L45-L50)) → джоб должен бежать. | Бежит, поставщик видит новый лимит. | |
| E5 | Поменять только `name` (не источник). | `needsResync` = false, джоба нет (имя не уезжает к поставщику). | |
| E6 | Поменять `signal_identifier` (домен) у существующего сайт-проекта. | Через `detachOldSourceSupplierProjects` старая привязка чистится, `DeleteSupplierProjectJob` для сирот; новая привязка создаётся; oldSP удалится у поставщика, если других клиентов на старом домене нет. | |
| E7 | Поставить проект на паузу (toggleActive=false). | `SyncSupplierProjectJob` dispatch, на поставщике status=`paused` если последний активный в группе. | |
| E8 | Снять с паузы. | `SyncSupplierProjectJob` dispatch, статус=`active`. | |
| E9 | Сделать N проектов и быстро удалить N — проверить, что в `failed_jobs` ничего из supplier-jobs (нет retry-шторма). | Пусто. | |
| E10 | CSV reconcile cron (часовой) → ручной запуск `php artisan csv:reconcile`. | Дрейф ≤ 5%, лог в `supplier_csv_reconcile_log`. Если >5% — алерт `warning`. | |
| E11 | Проверить окно «до 18:00 МСК» — сделать изменение в 18:01 → проверить, что в очереди джоба нет до утра (если есть отдельный scheduled cron) или что джоба есть, но поставщик не примет (нужно понимать ваш back-end). | По коду я не нашёл явного 18:00 gate в `ProjectService.update` — джоб dispatch'ится сразу. **Это противоречит баннеру** (потенциально баг для записи). | |

### F. Деньги — корректность списания при гонках (cross-ref `billing-audit`)

| # | Шаг | Ожидаемо | ✅ / ❌ |
|---|---|---|---|
| F1 | После создания проекта баланс tenant не уменьшается (создание бесплатно). | Да. | |
| F2 | Создать сделку (лид пришёл) → `LedgerService` списал стоимость по pricing tier. | Списано, `lead_charges` запись с `charge_source` корректно. | |
| F3 | Two managers одного tenant одновременно создают сделки → нет double-charge (advisory-lock на tenant). | Да, две отдельные записи списания. | |
| F4 | Tenant с балансом 0 — создаёт сделку → `ZeroBalancePausedMail` + проект auto-pause (1/час/tenant). | Письмо отправлено (раз в час), проект на паузе. | |

---

## Часть D — Рекомендованные следующие шаги (вне scope этого аудита)

1. **B-01 (P0)** — самый болезненный. Минимум: добавить в UI создания/редактирования **счётчик «На этом источнике уже N других клиентов»** и **расчёт реального fair share** (`order ÷ участников`). Опционально: ужать UI-выбор `daily_limit_target` до realistic max. Корректное решение требует продуктового — что обещаем клиенту в оферте.
2. **B-06** — добавить DB constraint `UNIQUE(tenant_id, name)` и `UNIQUE(tenant_id, signal_type, signal_identifier)` на `projects` через миграцию (закроет теоретическую гонку).
3. **B-02** — fix i18n: бэк должен возвращать русские сообщения через `Lang::get('validation.required', [...])`, или фронт должен мапить ключ.
4. **B-03** — собрать все валидационные ошибки в один заход (не throw сразу): `assertNameUnique` и `assertSourceUnique` накапливают в массив, потом один `HttpResponseException` со всеми.
5. **B-04** — polling `/api/projects?since=...` каждые 30 сек или WebSocket-канал `project.created`/`updated`/`deleted` per-tenant.
6. **B-07** — отдельный endpoint `GET /api/projects/source-preview?signal_type=...&signal_identifier=...` который возвращает кол-во конкурирующих клиентов и расчётный fair share.
7. **B-11** — `<v-btn-toggle mandatory>` на дни недели.
8. **E11** — выяснить, реализован ли cutoff «до 18:00 МСК» или это только UX-обещание. Если только UX — баннер врёт.

---

## Часть E — Сводка для заказчика (что я фактически сделал)

- Поднял local dev-сервер `php artisan serve` на `127.0.0.1:8000` (PID b4uy22rzc, до сих пор работает).
- Залогинился `admin@demo.local`, прошёл форму создания проекта во всех трёх вкладках (Сайт/Звонок/СМС).
- Сэмулировал гонку через две вкладки одной сессии (получил 422 unique-name + 422 unique-source поэтапно).
- Проанализировал формулу `SupplierQuotaAllocator::computeOrder` и зафиксировал точку перелома (4 клиента с равными лимитами).
- Создал в demo-tenant тестовый проект `Race-Test-Project` / `race-test-okna.ru` — он остался в БД, локальный `Sync pending` (можно удалить вручную или `php artisan migrate:fresh`).
- Этот документ — итог.
