Files
portal/docs/superpowers/audits/2026-05-23-projects-multi-client-audit.md
T
Дмитрий 26999ca597 chore: working tree cleanup pre-llm-first-router merge
Три группы накопившихся auto-правок (НЕ ручные):

1. markdownlint --fix auto-format (~25 .md в docs/superpowers/, docs/security/marketing-vet.md, docs/adr/015, docs/deploy/lkomega-runbook): MD031/MD032 (blank lines around fence/list) + MD004 (bullet markers `+`→`-`). Содержательных текстовых правок 3: ADR-015 bullet, sprint5d-cleanup bullet, router-discipline trailing space.

2. lefthook 2.1.6 → 2.1.8 (package.json + lock): patch-bump, авто-резолвил npm.

3. Observer runtime (docs/observer/): episodes-2026-05.jsonl +420 строк (текущая активность мозга), STATUS.md regen, .pii-counters / .read-counter тики, +2026-05-24-brain-retro.md note.

Цель — разблокировать merge feat/llm-first-router → main (этап 0 плана постановки в боевой). Содержание ветки не трогает.
2026-05-25 14:23:11 +03:00

186 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Аудит «Создание/изменение проектов и миграция к поставщику»
**Дата:** 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`).
- Этот документ — итог.