При InsufficientBalanceException в LedgerService::chargeForDelivery:
- DB::transaction откатывается (Deal/charge/balance не тронуты).
- Outer catch в createDealCopyForProject вызывает handleInsufficientBalance:
* UPDATE projects.is_active=false через pgsql_supplier (BYPASSRLS).
* Email ZeroBalancePausedMail через NotificationService::notifyZeroBalancePaused.
* Rate-limit 1/час/tenant через Redis SETNX (Cache::add).
* Log::warning с tenant_id/project_id/balance details.
- Возвращаем false (не rethrow), чтобы handle()-loop продолжал routing остальным tenant'ам.
5 тестов: project paused / email sent / rate-limit 1/h / 2nd email after 65min /
sharing-flow isolation (A paused, B receives).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Компоненты:
- SupplierQuotaAllocator: pure function distribution-логики
- site/call: B1=ceil(t/3), B2=ceil(r/2), B3=remainder
- sms-with-keyword: B2+B3 only (B1=0, spec §2.2 — B1 не поддерживает СМС)
- Workdays/regions union, weekday-фильтрация по Europe/Moscow
- Возвращает null когда нет projects на targetWeekday
- SyncSupplierProjectsJob: 20:30 МСК cron
- SupplierProject::on('pgsql_supplier') — cross-tenant видимость
- whereNull('inactive_since') — sync только активные
- Адаптер Project → stdClass: daily_limit_target → daily_limit,
delivery_days_mask bits → workdays, region_mask bits → regions
(mask=255 catch-all → regions=[])
- per-supplier_project failure-isolation (continue на one bad)
- mass-fail abort: 50 consecutive transient → SupplierCriticalAlertMail
+ Sentry + break
- sticky auth → email('sticky_auth') + Sentry + throw
- time budget cutoff 20:55 МСК (5-мин safety margin до 21:00)
- supplier_sync_log per action (action='create'/'update', http_status,
error_message)
- SupplierCriticalAlertMail: ShouldQueue Mailable + text template
- Unisender Go SMTP relay через config('services.supplier.alert_email')
NOTE про connection: следуем Task 3 learning — не используем public \$connection
(это queue connection, не DB). Queries через Model::on('pgsql_supplier').
NOTE про DB::transaction: НЕ оборачиваем syncOne, т.к. HTTP-call к supplier
выходит за границы транзакции (атомарности всё равно нет). Два DB-write
последовательно; ошибка между ними recoverable через retry на следующем cron-tick
(supplier_external_id уже записан, скип через SupplierProjectDto::equals()).
+18 тестов (10 allocator + 8 sync job).
phpstan-baseline.neon: +7 entries для PHPStan template-covariance issue в
SupplierQuotaAllocatorTest — \`Collection<int, object{...literal}&stdClass>\` не
suptype \`Collection<int, stdClass>\` per PHPStan invariance rule. Production
code clean (0 baseline entries).
- api/reports.ts: типизированные axios-helpers (listReportJobs/createReportJob/
retryReportJob/cancelReportJob/deleteReportJob) + ApiReportJob/Status/Format/
Counts/Quota interfaces. ensureCsrfCookie на mutating-вызовах.
- composables/reportsMapper.ts: mapApiReportJob (API → UI mock format с конверсией
pending→queued / processing→running). title строится на frontend'е (тип + period
с RU-месяцами «апр 2026» или диапазон «мар 2026 — апр 2026»). sizeText форматирует
bytes (B/KB/MB). timeText зависит от status (в очереди / в работе · Nс / N мин назад).
uiTypeToApi (deals → deals_export и т.д.).
- ReportsView.vue полностью переписан под API:
- onMounted → loadJobs (replace MOCK_JOBS на data из listReportJobs).
- usePolling 30 сек (фоновый авто-refresh).
- Submit → createReportJob → reload + success-alert + error-alert (validation+
общие ошибки извлекаются через extractValidationErrors/extractErrorMessage).
- canSubmit computed: disable если квота заполнена (active >= max).
- Reset-btn возвращает форму к defaults.
- Reload-btn (manual fast-path).
- Retry/Cancel/Download/Delete-кнопки → соответствующие API-вызовы;
Delete через v-dialog persistent confirm.
- fetch-error-alert на listReportJobs reject.
- Empty-state «Нет отчётов» когда jobs.length=0.
- canRetry проверяет retry_count<3 (max attempts CTO-6).
- Vitest +24 (всего 393/393, +24 от 369):
- reportsMapper.spec.ts +14: status mapping (pending/processing/done/failed) /
title (один месяц / диапазон) / format / sizeText (B/KB/MB/null) / attempt /
error / timeText (pending / processing / done «10 мин назад» / «только что») /
uiTypeToApi 4 slug'а / progress=50 для running.
- ReportsView.spec.ts переписан с MOCK_JOBS на vi.mock('api/reports') +12:
mount + listReportJobs called on mount / 4 type cards / default Сделки active /
4 формата / quota-banner из API / empty-state / done с Готов+Скачать /
failed с Ошибка+Повторить / failed retry_count=3 НЕ показывает Повторить /
pending с Отменить / Submit вызывает createReportJob+reload / Submit error →
submit-error-alert / Submit-btn disabled при квоте 3/3 / Reset / Reload-btn /
fetch-error-alert / Retry-btn / Cancel-btn / Delete confirm-dialog +
deleteReportJob.
Этап 4/4 эпика Reports backend ЗАКРЫТ. Эпик закрыт целиком.
Backend: 1 type (deals_export) × 4 формата (CSV/XLSX/JSON/PDF-stub).
Этап 2b (3 оставшихся типа: managers_summary/sources_summary/billing_summary)
— расширение через добавление 3 новых Provider-классов без изменений в архитектуре,
вынесено в Post-MVP backlog.
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>