Files
brain/docs/superpowers/audits/2026-05-23-projects-multi-client-audit.md
T

28 KiB
Raw Blame History

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

Дата: 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 этого аудита).

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

  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) → джоб должен бежать. Бежит, поставщик видит новый лимит.
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).
  • Этот документ — итог.