# Аудит «Создание/изменение проектов и миграция к поставщику» **Дата:** 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', '')->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** — `` на дни недели. 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`). - Этот документ — итог.