Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
28 KiB
Аудит «Создание/изменение проектов и миграция к поставщику»
Дата: 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 + NewProjectDialog.vue |
| B-02 | P1 | Тексты ошибок 422 показываются как validation.required (технический i18n-ключ), а не как человекочитаемое сообщение. |
NewProjectDialog.vue:328 принимает errors от бэка как есть |
| B-03 | P1 | Конфликты уникальности показываются поэтапно: сначала имя (assertNameUnique), потом источник (assertSourceUnique) — клиент гоняется ступенчато. |
ProjectService.php:461-462 |
| 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 |
| B-07 | P1 | При создании проекта на источнике, где уже есть проекты других клиентов, клиент не получает предупреждение «вас уже N — фактический лимит будет ужат до X». | UI не показывает контекст supplier_projects group |
| B-08 | P1 | workdays и regions в supplier_projects — UNION активных клиентов группы. Изменение дней/регионов одного клиента расширяет общую заявку у поставщика, затрагивая других клиентов незримо. |
SupplierQuotaAllocator.php:59-60 |
| B-09 | P2 | Кнопка «Создать» не disabled при невалидной форме — клиент может жать многократно вхолостую. | NewProjectDialog.vue нет :disabled гейта |
| B-10 | P2 | Локальная валидация только для регионов. Пустые name/source/limit ловятся только сервером (422 после клика «Создать»). | NewProjectDialog.vue:305-313 |
| B-11 | P2 | UI разрешает выключить все 7 дней недели. Ошибка только от сервера (delivery_days_mask min:1 в StoreProjectRequest:30). |
v-btn-toggle без mandatory |
| B-12 | P2 | Подсказка «без keyword проект подключится только к B3» спрятана в hint поля sms_keyword, не видна без фокуса. |
NewProjectDialog.vue:62 |
| B-13 | P2 | Лимит тарифа tenants.limits.max_projects нигде не показан на странице. Клиент узнаёт об упоре только после 403 «Достигнут лимит проектов». |
ProjectService.php:443-451 |
| B-14 | P2 | Bulk-операции толерантны к параллельно удалённым ID (update по несуществующей строке — 0 rows, без ошибки). Manager2 видит «Применено», хотя Manager1 успел удалить часть выборки. |
ProjectService.php:261-269 |
| 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:
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 этого аудита).
Главные выводы про формулу:
- Уже на двух клиентах заявленный потолок 50 фактически становится потолком 25 на каждого (
orderостаётся 50 потому, что max=50, а лид шарится). - Скачок происходит на 4-м клиенте —
ceil(Σ/3)обгоняетmax, иorderначинает расти, но всё равно медленнее, чем суммарный спрос. - При большом неравенстве (например, один клиент 1000 + ещё 9 по 50) —
order = max(1000, ⌈1450/3⌉=484) = 1000. Маленькие клиенты технически могут получить лиды из 1000, но конкурируют с большим (это уже LeadRouter). - 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— сейчас в фоне PIDb4uy22rzc) нужны минимум 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) → джоб должен бежать. |
Бежит, поставщик видит новый лимит. | |
| 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 этого аудита)
- B-01 (P0) — самый болезненный. Минимум: добавить в UI создания/редактирования счётчик «На этом источнике уже N других клиентов» и расчёт реального fair share (
order ÷ участников). Опционально: ужать UI-выборdaily_limit_targetдо realistic max. Корректное решение требует продуктового — что обещаем клиенту в оферте. - B-06 — добавить DB constraint
UNIQUE(tenant_id, name)иUNIQUE(tenant_id, signal_type, signal_identifier)наprojectsчерез миграцию (закроет теоретическую гонку). - B-02 — fix i18n: бэк должен возвращать русские сообщения через
Lang::get('validation.required', [...]), или фронт должен мапить ключ. - B-03 — собрать все валидационные ошибки в один заход (не throw сразу):
assertNameUniqueиassertSourceUniqueнакапливают в массив, потом одинHttpResponseExceptionсо всеми. - B-04 — polling
/api/projects?since=...каждые 30 сек или WebSocket-каналproject.created/updated/deletedper-tenant. - B-07 — отдельный endpoint
GET /api/projects/source-preview?signal_type=...&signal_identifier=...который возвращает кол-во конкурирующих клиентов и расчётный fair share. - B-11 —
<v-btn-toggle mandatory>на дни недели. - 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). - Этот документ — итог.