Defense-in-depth: secret (≥32 chars system_setting) + IP allowlist (CIDR).
Несовпадение → 404. UNIQUE vid → 200 OK на дубль (idempotency).
Тесты пока FAIL (route регистрируется в Task 7 — пишем "красные" тесты заранее
для TDD-цикла).
Spec §5.1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code-review Important: один сбой Project не должен абортить routing для
остальных tenant'ов (sharing-model). + try/catch + Log::warning +
RuntimeException только если ВСЕ projects упали.
+ 2 новых теста: mixed routing (1 dup из 3 + 2 clean) и partial failure
(soft-delete tenant в середине loop'а).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Распределяет supplier_lead по eligible Liderra-проектам через LeadRouter.
Для каждого: транзакция с SET LOCAL app.current_tenant_id, lockForUpdate,
DuplicateDetector check, balance_leads--, delivered_today/month++,
BalanceTransaction, ActivityLog, NotificationService::notifyNewLead.
Spec §5-§6.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code-review Important: Carbon::now() resolved against process TZ (UTC quirk
из memory). Reset cron в 00:00 МСК — mismatch вызвал бы off-by-one на
рубеже полуночи. Тест синхронизирован (now('Europe/Moscow')) — иначе
mismatch test/service near midnight. + комментарий про unreachable default
match arm (защищён на DB-уровне через chk_supplier_projects_platform).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plan 2/5 Task 4 — sharing-model routing (spec §6): для входящего лида
возвращает Collection<Project> учитывая platform FK + active + workdays +
region (PhonePrefixService::phoneMatchesRegions) + delivered_today <
COALESCE(effective_daily_limit_today, daily_limit_target) +
tenant.balance_leads > 0. Сортировка created_at ASC, id ASC (детерминированно).
Параллельно расширил Project model fillable/casts на delivered_today
(колонка добавлена в schema v8.18 Plan 2 Task 1, но Project::class не
обновлён — без этого тесты Mass-Assignment'а ломались).
Покрытие: 9 it-blocks (sharing across tenants, paused, workdays, daily quota,
fallback to daily_limit_target, region filter, balance_leads zero, FK routing
по platform, deterministic sort). DatabaseTransactions context + set_config
(session-scoped) для очистки app.current_tenant_id — sharing-flow работает
поверх N tenant'ов, RLS bypass через postgres BYPASSRLS на dev.
PHPStan: 0 errors. Pint: clean. Pest: 9/9 PASS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SaaS-level модель для supplier_leads (Plan 2/5 Task 2).
belongsTo(SupplierProject) + array cast на raw_payload + datetime *_at.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add it-block для UNIQUE INDEX idx_supplier_leads_vid_unique:
две INSERT с одинаковым vid → вторая бросает QueryException
(прямой behavioral тест webhook-идемпотентности).
- Replace tautological strlen($secret->value) >= 16 на toBe('__SET_ON_DEPLOY__')
— было проверкой литерала, который мы сами и записали; теперь intent-clear
assertion того, что seed кладёт placeholder. Реальная strength-валидация
secret'а — дело deploy-time validator'а, вне scope Plan 2.
- Add uses(DatabaseTransactions::class) — приводит файл к проектному
pattern (см. WebhookReceiveTest, TenantModelsTest, SetTenantContextTest).
Без него новый INSERT с vid=999000111 коллидил бы при re-run, т.к.
Pest.php применяет RefreshDatabase глобально не делает (закомментирован).
Code-review fixes for Plan 2 Task 1.
Code-review subagent (CV.12 в Plan 1) нашёл 1 BLOCKER + 2 actionable WARNINGs:
1. **BLOCKER** — projects.supplier_b{1,2,3}_project_id были голыми BIGINT без
REFERENCES, вопреки явному комментарию «FK добавятся в Task 2». Task 2
создал supplier_projects, но FK на projects не вернул. Можно было записать
произвольный BIGINT в эти колонки.
Fix: ALTER TABLE projects ADD CONSTRAINT … FOREIGN KEY … ON DELETE SET NULL
для всех трёх + 3 partial index (WHERE NOT NULL) для FK lookup.
2. **WARNING** (Project-level B1+SMS guard) — CHECK существовал только на
supplier_projects; Project::create(['signal_type'=>'sms','supplier_b1_project_id'=>…])
проходил вопреки spec §2.2 «B1 не поддерживает СМС».
Fix: ADD CONSTRAINT chk_projects_b1_not_for_sms
CHECK (signal_type <> 'sms' OR supplier_b1_project_id IS NULL).
3. **WARNING** (resolver collision) — SupplierProjectResolver::resolveOrStub
firstOrCreate на (platform, unique_key) без signal_type → при коллизии
unique_key возвращал чужую запись с другим signal_type без ошибки.
Fix: после firstOrCreate проверяется match signal_type, иначе DomainException.
+1 тест на collision.
Schema bumped v8.16 → v8.17. Метрики: 60 таблиц / 111 индексов (+3) / 39 RLS.
Pest: 500/498 passed (+1 collision test). Larastan 0 errors. Pint clean.
Spec: §2.1, §2.2
Plan: Task 2 (закрытие code-review CV.12)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plan 1/5 Task 2 — SaaS-level агрегатная сущность для проектов у поставщиков.
Несколько Лидерра-tenant'ов могут шарить один supplier_project (sharing-model
spec §2.3): для site/call — по domain/phone; для sms — по (sender, keyword)
на B2 или (sender) на B3. RLS НЕ применяется (таблица не tenant-scoped),
defense-in-depth через REVOKE ALL FROM crm_app_user.
Колонки: platform, signal_type, unique_key (TEXT), supplier_external_id,
current_limit, current_workdays/regions (jsonb), sync_status (pending/ok/failed),
last_synced_at, inactive_since (TTL 180 дней), timestamps.
CHECK constraints (chk_supplier_projects_*):
- platform IN (B1, B2, B3)
- signal_type IN (site, call, sms)
- sync_status IN (pending, ok, failed)
- NOT (platform=B1 AND signal_type=sms) — B1 не поддерживает СМС
Indexes: UNIQUE(platform, unique_key); btree на sync_status, inactive_since.
Тесты: 6/6 (table+columns / unique / platform CHECK / sync_status CHECK / no RLS / no privileges).
Schema: v8.12 → v8.13. Метрики: 56→57 таблиц / 98→101 индексов; RLS/функции/триггеры без изменений.
Опциональный query-параметр `cursor` (base64-encoded JSON {r:received_at, i:id}).
При cursor — keyset через PG row constructor `(received_at, id) < (?, ?)`
с использованием существующего индекса (received_at DESC, id DESC).
O(1) на любой глубине, без COUNT(*) (total не возвращается в keyset-режиме).
Без cursor — backward-compat OFFSET-путь: total + offset для существующего
frontend. Оба режима возвращают next_cursor (NULL = последняя страница).
Trick "+1 fetch" — узнаём про следующую страницу одним SELECT'ом без COUNT.
3 новых Pest-теста: keyset-навигация через cursor, 422 на невалидный cursor,
next_cursor flow. Pest 421/421 (419 + 2 skipped browser, +3 от 418 baseline).
phpstan-baseline.neon регенерирован: +2 occurrences pattern
`received_at?->toIso8601String()` (cursor build) + 7 occurrences тестовых
helper-properties Pest TestCall — все известные ignored patterns, не реальные ошибки.
Frontend integration в useDealsList/DealsView — отдельным шагом
(не блокирует backend deploy, OFFSET путь жив).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Закрытие аудита 2026-05-09 (b6ae8dd):
- P0-01: применён 'tenant' middleware (alias уже в bootstrap/app.php:17) к 3 auth:sanctum-группам:
/api/notifications, /api/reminders, /api/reports/jobs (web.php:44/52/63).
/api/deals и /api/admin/* остаются без auth (P1-10/Б-1) — в реестр Спринта 1 Phase F.
- O-refactor-03: HasPasswordRules trait извлекает rules + messages, подключён в Login/Register.
- P2-01: bcrypt('test') → bcrypt('test1234') в AdminIncidentsIndexTest (≥8 chars).
- bonus-fix: SetTenantContext::resolveTenantId — property_exists() заменён на isset() для
Eloquent magic-attributes (auth-путь резолюции tenant_id никогда не работал из-за этого
бага; тесты-смоки middleware покрывали только X-Tenant-Id header / subdomain). Без фикса
P0-01 ломает 58 тестов в /api/notifications + /api/reminders + /api/reports/jobs.
Pest: 416/416 PASS.
Larastan: 0 errors.
Pint: clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
P0 этап 6 — 4 оставшихся email-события. Авто-план P0 (6 этапов) закрыт
полностью: все 8 schema-default событий имеют рабочую интеграцию
(new_lead/reminder/low_balance/zero_balance/topup_success/invoice_paid +
заглушки для new_device_login/marketing).
Backend:
- 4 новых Mailable: LowBalanceNotification (threshold), ZeroBalanceNotification,
TopupSuccessNotification (amountRub, amountLeads?), InvoicePaidNotification
(amountRub, invoiceNumber?, tariffName?).
- 4 blade-шаблона в emails/ (Forest-палитра, таблицы balance/amount/invoice).
- NotificationService +4 методов: notifyLowBalance / notifyZeroBalance /
notifyTopupSuccess / notifyInvoicePaid. Все шлют email + inapp по prefs.
Интеграция в ProcessWebhookJob:
- chargeNewLead после lead_charge: notifyLowBalance при пересечении порога
сверху-вниз (balance_after <= threshold AND (balance_after+1) > threshold).
Иначе спам при каждом lead_charge при balance < threshold.
- logRejection(zero_balance): notifyZeroBalance ТОЛЬКО если в последний час
не было другого RejectedDealsLog с тем же reason (anti-spam 1 email/час).
Защита от self-just-inserted через id!= (timestamp-сравнение ненадёжно
из-за PG microsecond precision).
- topup_success / invoice_paid — service-методы готовы, integration после
появления endpoints для пополнения (ЮKassa-webhook) и оплаты тарифа.
- lowBalanceThreshold() читает system_settings.low_balance_threshold_leads
(default 10, schema seed).
Pest +12 в BalanceNotificationsTest (359/359 за 41.37 сек, 1233 assertions):
- low_balance: пересечение порога / уже < threshold / > threshold /
prefs.email=false (только inapp).
- zero_balance: первое отклонение / 2-е в час не дублирует / >1ч снова шлёт.
- topup_success / invoice_paid: notify создаёт email+inapp / prefs=email:false.
- balance events изолированы между tenants.
NewLeadNotificationTest: «balance=0 не шлёт» обновлён —
Mail::assertNotSent(NewLeadNotification) вместо Mail::assertNothingSent
(ZeroBalanceNotification теперь шлётся при balance=0 — новое поведение).
PHPStan baseline регенерирован.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Закрыт пункт «Reminders ⏸ no-view» из AppLayout nav-tree. Schema-таблица
reminders уже была в v8.10 §17.5 — теперь работает целиком backend-side.
Backend:
- App\Models\Reminder — Eloquent с casts/relations + isCompleted/isOverdue.
- ReminderFactory с states overdue/completed/sent.
- App\Http\Controllers\Api\ReminderController под auth:sanctum:
GET ?filter=&deal_id=&limit= (active/today/upcoming/overdue/completed,
окно ±1 день, counts для UI badges);
POST {deal_id, text?, remind_at, assignee_id?} (FK guard на assignee);
PATCH {id} (при смене remind_at сбрасывает is_sent+sent_at для retrigger);
POST {id}/complete (idempotent);
DELETE {id}.
RLS-обёртка + defense-in-depth where('tenant_id').
- App\Mail\ReminderDueNotification + emails/reminder.blade.php (Forest,
TZ из recipient.timezone).
- NotificationService::notifyReminder(Reminder) — recipient = assignee_id
?? created_by (если active+!deleted). Каналы email+inapp по prefs.
payload {reminder_id, deal_id} для UI deep-link.
- App\Console\Commands\RemindersDispatchDue — cron reminders:dispatch-due
{--dry-run} {--limit=500}. По одному reminder в DB::transaction (SET
LOCAL app.current_tenant_id нельзя переключать). После notifyReminder
ставит is_sent=true даже если recipient deactivated (защита от retry-spam).
Pest +32 (347/347 за 41.21 сек, 1203 assertions):
- ReminderControllerTest 21: 401 / RLS / 5 filter'ов / counts / deal_id /
store + FK guard / update text+remind_at сбрасывает is_sent / complete
idempotent / delete + 404 чужой.
- RemindersDispatchDueTest 11: due → email+inapp / future skip / completed
skip / уже sent / assignee вместо created_by / deactivated user (is_sent
всё равно) / только inapp при email=false / --dry-run / --limit / RLS.
PHPStan baseline регенерирован. IDE-helper для всех моделей.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Закрывает этап 2 P0 целиком (UI bell с unread badge + polling).
Backend:
- App\Http\Controllers\Api\InAppNotificationController под auth:sanctum:
GET /api/notifications?unread_only=&limit= (1..100 default 50);
PATCH /api/notifications/{id}/read (idempotent);
POST /api/notifications/mark-all-read (bulk + count);
DELETE /api/notifications/{id}.
- Route::middleware('auth:sanctum')->prefix('/api/notifications') в web.php.
- DB::transaction + SET LOCAL app.current_tenant_id для RLS.
- Защита от кражи чужого id через where('user_id', $auth->id).
- Pest +14 (305/305 за 34.71 сек, 1099 assertions).
Frontend:
- api/notifications.ts — типизированные axios-helpers + ensureCsrfCookie.
- stores/notifications.ts — Pinia: items/unreadCount/total/loading +
optimistic markRead/markAllRead/remove с revert на reject.
- AppLayout: bell-icon → v-menu offset=8 location=bottom-end:
pip badge показывает unreadDisplay (1..99 / 99+ / hidden);
v-list последних 10 из sortedItems с event-icon + formatRelative;
Mark-all-read btn только при unreadCount > 0;
click на item → markRead + router.push('/deals') если deal_id.
- usePolling(loadNotifications, {intervalMs: 30_000}) с Page Visibility.
- loadNotifications no-op без auth.user.
- Vitest +18 (339/339 за 20.03 сек): store 12 + AppLayout +6
(bell-btn / pip скрыт при 0 / pip count / 99+ / listNotifications
на mount с user / no-op без user).
PHPStan baseline регенерирован (50 Pest false-positives подавлены).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Старт closing «Notification delivery» из карты P0. Этап 1/6 плана:
NotificationService + Mailable + интеграция в ProcessWebhookJob::chargeNewLead.
- App\Services\NotificationService — диспетчер 8 событий × 3 каналов
(inapp/push/email) согласно schema.sql:699 users.notification_preferences.
Этап 1 реализует только email-канал для new_lead.
- App\Mail\NewLeadNotification + emails/new_lead.blade.php — HTML-письмо
в Forest-палитре с таблицей phone/contact_name/received_at/deal_id.
- ProcessWebhookJob::chargeNewLead — после ActivityLog вызывает
notifyNewLead. Throwable от Mail::send проглатывается + Log::warning
(отказ канала не должен валить транзакцию).
- Pest 11/11 в tests/Feature/Notifications/NewLeadNotificationTest.php:
email=true получает / email=false не получает / schema-default не шлёт /
inactive не получает / soft-deleted не получает / другой тенант не
получает / Биз-19 дубль не дублирует / повторный vid не дублирует /
balance=0 не шлёт / subject содержит project_name.
- IDE-helper регенерирован (4 модели получили @mixin docblocks).
- PHPStan baseline регенерирован (138 ignore.unmatched схлопнулись).
Pest 280/280 за 31.27 сек (+11 от 269, 1029 assertions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Закрывает gap из v1.66 — mock-форма имеет mrrRub, но API возвращал null.
Теперь AdminTenantsView показывает реальную колонку MRR.
Backend (AdminTenantsController::index):
- Добавлено tariff_plans.price_monthly as tariff_price_monthly в select.
- mrr_rub в response: price_monthly (string) если не-trial; иначе null.
- Aggregate-формат как у /admin/billing — string чтобы decimal не терял
точность при передаче через JSON.
Pest +3 (AdminTenantsIndexTest):
- mrr_rub='990.00' для активного тарифа не-trial.
- mrr_rub=null для trial (даже если тариф есть).
- mrr_rub=null если current_tariff_id отсутствует.
Frontend:
- ApiAdminTenant.mrr_rub: string | null в типе.
- mapApiAdminTenant: parseFloat(api.mrr_rub) или null (вместо hardcoded
null из v1.66).
- AdminTenantsView: formatRub(item.mrrRub) для консистентности с другими
₽-полями.
Vitest +2:
- mrr_rub строка → number.
- mrr_rub=null → mrrRub null.
PHPStan baseline регенерирован. cspell-glossary +консистентности.
Регресс:
- Lint+type-check+format passed.
- Vitest 313/313 за 18.83 сек (+2 от 311).
- Vite build 947 ms.
- Pint + PHPStan passed.
- Pest 266/266 за 28.39 сек (+3 от 263, 1001 assertion).
Реестр v1.70→v1.71 / CLAUDE.md v1.61→v1.62.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Заменяет static-снапшот LEAD_STATUSES в коде на live-данные из БД.
Custom slug'и (добавленные после deployment'а) теперь видны UI без rebuild'а.
Backend:
- LeadStatus model (PK=slug string, incrementing=false, timestamps=null).
- LeadStatusController::index — GET /api/lead-statuses, ORDER BY sort_order,
slug. Таблица глобальная (не tenant-aware), auth не требуется на MVP.
Pest +5 (LeadStatusesIndexTest):
- 200 + не пустой / все 14 системных slug'ов из seed / все нужные поля /
sort_order ASC / кастомный slug после INSERT появляется в endpoint'е.
Frontend:
- api/leadStatuses.ts::listLeadStatuses — GET helper.
- stores/leadStatuses.ts::useLeadStatusesStore — Pinia setup-store:
statuses default = LEAD_STATUSES snapshot (UI работает без fetch'а),
load(force=false) идемпотентен, bySlug computed Map, findBySlug helper.
На fail — snapshot остаётся, fetchError=true.
- DealsView/KanbanView/DealDetailDrawer переехали со static-импорта
LEAD_STATUSES на store. KanbanView использует safe-access
dealsByStatus[slug] || [] (защита от custom slug'а из API без seeded
column). load() в onMounted у обоих view'ов.
Vitest +7 (leadStatusesStore.spec.ts):
- initial snapshot / findBySlug existing & null / load success replace +
loaded / load reject — fetchError + snapshot fallback / load идемпотентен /
load(force=true) refetch.
- 2 spec'а DealDetailDrawer получили setActivePinia(createPinia()) в
beforeEach (без этого Pinia store-injection в jsdom падает).
PHPStan baseline регенерирован.
Регресс:
- Lint+type-check+format passed.
- Vitest 280/280 за 19.44 сек (+7 от 273).
- Vite build 1.17 сек.
- Pint + PHPStan passed.
- Pest 210/210 за 24.59 сек (+5 от 205, 840 assertions).
Реестр v1.63→v1.64 / CLAUDE.md v1.54→v1.55.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Закрывает gap «UI меняет статус, но изменения не сохраняются на backend»
из v1.51. Reload-btn заменяет polling/SSE до прихода long-poll'а на prod.
Backend (DealController::transition):
- POST /api/deals/transition {tenant_id, ids: [1..1000 ints], status}.
- Валидация status — exists в lead_statuses (глобальная таблица).
- RLS-обёртка SET LOCAL + defense-in-depth where(tenant_id) для
partial-update: чужие id остаются в исходном статусе.
- ActivityLog event=deal.status_changed с context={from, to, source: 'bulk'}
для каждой ИЗМЕНЁННОЙ сделки. NO-OP (старый==новый) не пишется в audit.
- Ответ: {updated, requested, status}.
Pest +7 (DealTransitionTest):
- 422 missing fields / 404 unknown tenant / 422 неизвестный slug + не апдейт /
batch update 3 сделок + 3 ActivityLog с правильным context /
NO-OP не пишет ActivityLog / defense-in-depth (2 tenant'а — обновляется
только свой) / 422 пустой массив ids.
Frontend:
- dealsApi.transitionDeals — типизированный helper с ensureCsrfCookie.
- applyBulkStatus в DealsView переписан async: optimistic local-update +
backend-вызов если auth.user.tenant_id. На success — toast «Обновлено
N из M.», на fail — warning toast + локальный update НЕ откатывается.
Без auth.user — только optimistic (legacy local-mode сохранён).
- reload-btn в DealsView и KanbanView — outlined «Обновить» mdi-refresh,
привязан к loadDeals. В DealsView :loading="loading" во время fetch'а.
Vitest +5:
- reload-btn (Deals + Kanban) — listDeals вызывается дважды.
- applyBulkStatus с tenant_id — transitionDeals + optimistic + toast.
- applyBulkStatus без tenant_id — НЕ вызывается transitionDeals.
- applyBulkStatus reject — toast warning + локальный update остаётся.
PHPStan baseline регенерирован. cspell-glossary +апдейт*.
Регресс:
- Lint+type-check+format passed.
- Vitest 266/266 за 18.16 сек (+5 от 261).
- Vite build 1.06 сек.
- Pint + PHPStan passed.
- Pest 193/193 за 23.27 сек (+7 от 186, 767 assertions).
Реестр v1.60→v1.61 / CLAUDE.md v1.51→v1.52.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Закрыт TODO (a) из v1.49: общая логика выбора активного supplier'а через
project_suppliers m2m была дублирована между ProcessWebhookJob (webhook-flow)
и DealController (manual-create) — 11 одинаковых строк query-builder'а на
2 файла. Теперь — единственный источник истины + DI через app() (тот же
паттерн, что у DuplicateDetector в v1.23).
App\Services\SupplierResolver:
- resolveForProject(Project): ?int — точная копия прежней query
(project_suppliers JOIN suppliers, is_active+is_active, ORDER BY
sort_order, id).
- costRubSnapshot(int $supplierId): string — вынесенный snapshot цены
для записи в supplier_lead_costs.
ProcessWebhookJob и DealController:
- Удалены private resolveSupplierId() (по 14 строк).
- Удалены локальные DB::table('suppliers')->value('cost_rub').
- Используют app(SupplierResolver::class) внутри handle()/store().
Pest +8 в tests/Feature/Services/SupplierResolverTest.php:
- null без связей / единственный активный / пропуск inactive supplier /
пропуск inactive m2m / ORDER BY sort_order / null если все inactive /
изоляция по project_id / costRubSnapshot формат '137.50'.
PHPStan baseline регенерирован.
Регресс:
- Pint + PHPStan passed (baseline регенерирован).
- Pest 174/174 за 21.46 сек (+8 от 166, 708 assertions).
- Vitest 247/247 за 17.53 сек (нетронут — backend-only refactor).
Реестр v1.58→v1.59 / CLAUDE.md v1.49→v1.50.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3 интеграционных доработки после backend-completion v1.57.
(1) GET /api/managers + /api/projects + manager FK guard:
- ManagerController::index — active users тенанта (is_active+deleted_at IS NULL).
Формат {id, email, first_name, last_name, name, initials} с
formatName/formatInitials helpers (fallback на email).
- ProjectController::index — active projects (is_active=true).
- Оба endpoint'а: tenant_id query-param, 422 без, 404 unknown, RLS-обёртка.
- DealController::store FK guard: manager_id должен принадлежать tenant'у +
is_active. Иначе 422 (закрывает security-gap чужого менеджера).
- Pest +8 в LookupsTest.
(2) Replace MOCK_MANAGERS / MOCK_PROJECTS на API в NewDealDialog:
- projectOptions/managerOptions ref'ы с MOCK fallback.
- loadLookups через Promise.all([listProjects, listManagers]) на open
диалога с tenantId.
- managerIdByName Map name→id для submit'а.
- Silent fallback на mock при network-error.
- Vitest +2.
(3) SupplierLeadCost для manual-leads:
- В DealController::store после Deal::create — resolveSupplierId (копия
логики ProcessWebhookJob: project_suppliers JOIN suppliers + ORDER BY
sort_order). Если supplier найден — SupplierLeadCost с snapshot cost_rub
+ supplier_lead_id=NULL (manual: нет внешнего id).
- Manual по-прежнему НЕ списывает баланс (Ю-2 reseller-модель — charge
только при webhook'е); cost-аналитика всё равно нужна.
- Pest +2.
- TODO: рефактор resolveSupplierId в App\Services\SupplierResolver чтобы
Job + Controller разделяли логику.
Старый тест manager_id=42 переписан под FK guard через User::factory.
PHPStan baseline регенерирован (+28 ignored Pest TestCall warnings).
Регресс: lint+type-check+format ✅; vitest 247/247 за 16.32 сек (+2);
vite build 951 ms; Pint+PHPStan passed; Pest 166/166 за 22.11 сек
(+10 от 156, 699 assertions). Реестр v1.57→v1.58, CLAUDE.md v1.48→v1.49.
Production TODO остаточные:
- resolveSupplierId → SupplierResolver service.
- XLSX-export через PhpSpreadsheet.
- GET /api/deals для replace MOCK_DEALS в DealsView/KanbanView.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>