diff --git a/cspell-words.txt b/cspell-words.txt index 2c09da4c..d60a5e62 100644 --- a/cspell-words.txt +++ b/cspell-words.txt @@ -915,3 +915,10 @@ Sel overhead overhead'ный choco + +# Plan 4 spec — Billing + CSV Reconcile + Admin (2026-05-11) +декрементится +Инкрементится +Подписочный +bcdiv +TRUNC diff --git a/docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md b/docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md new file mode 100644 index 00000000..fdcfb432 --- /dev/null +++ b/docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md @@ -0,0 +1,736 @@ +# Plan 4 (Billing + CSV Reconcile + Admin) Implementation Design + +**Дата:** 2026-05-11 +**Статус:** черновик дизайна (brainstorming output) +**Заказчик:** Дмитрий (владелец Лидерры) +**Parent spec:** [docs/superpowers/specs/2026-05-10-supplier-integration-design.md](./2026-05-10-supplier-integration-design.md) §5.2, §7 +**Inherits from:** Plan 1 (Foundation `001d781`) + Plan 2 (Webhook+Routing `d5aa972`) + Plan 2.5 (concurrency hotfix `c1ae195`+`1ba1df8`) + Plan 2.6 (cleanup `7899071`) + Plan 3 (Supplier Sync `734b0ab`). + +## 1. Контекст, инварианты, что уже есть в коде + +### 1.1. Цель Plan 4 + +Активировать ступенчатый биллинг клиентов Лидерры (`pricing_tiers` / `lead_charges`), закрыть TODO «Биллинг per Plan 4» в [RouteSupplierLeadJob.php:48](../../../app/app/Jobs/RouteSupplierLeadJob.php#L48), реализовать резервный CSV-канал приёма лидов и Admin UI для конфигурации. + +### 1.2. Что уже есть в коде (после Plans 1+2+3) + +| Сущность | Где | Состояние | +|---|---|---| +| `pricing_tiers` (7 ступеней, копейки, `effective_from`) | [db/schema.sql:962](../../../db/schema.sql#L962) | Таблица создана (v8.14), **никем не читается** | +| `lead_charges` (append-only ledger, RLS, FK на partitioned deals) | [db/schema.sql:1001](../../../db/schema.sql#L1001) | Таблица создана (v8.15), **никто не пишет** | +| `tenants.balance_rub` (DECIMAL 12,2 без CHECK), `tenants.balance_leads` (INT) | [db/schema.sql:629](../../../db/schema.sql#L629) | Существуют; `balance_leads` декрементится в RouteSupplierLeadJob | +| `projects.delivered_in_month` (INTEGER) | [db/schema.sql:786](../../../db/schema.sql#L786) | Инкрементится в RouteJob, **нет cron'а сброса** | +| `BalanceTransaction` (type=`lead_charge`, amount_leads=-1) | [RouteSupplierLeadJob:271](../../../app/app/Jobs/RouteSupplierLeadJob.php#L271) | Пишется при доставке | +| `SupplierLeadCost` (per-deal закупочный snapshot, partitioned) | [db/schema.sql:2201](../../../db/schema.sql#L2201), [ProcessWebhookJob:216](../../../app/app/Jobs/ProcessWebhookJob.php#L216) | Пишется только в старом `ProcessWebhookJob`, в новом `RouteSupplierLeadJob` НЕ пишется (gap) | +| `suppliers.cost_rub` (per-platform B1/B2/B3) | [db/schema.sql:352](../../../db/schema.sql#L352) | Seed B1/B2/B3 в [schema.sql:2505](../../../db/schema.sql#L2505) | +| `SupplierPortalClient` + `RefreshSupplierSessionJob` + `PlaywrightBridge` | `app/app/Services/Supplier/*`, Plan 3 | Готово, переиспользуется для CSV | +| `SupplierCriticalAlertMail` (email через Unisender Go) | `app/app/Mail/*`, Plan 3 | Готово, расширим алертами биллинга и drift'а | + +### 1.3. Бизнес-инварианты Plan 4 + +1. **Dual balance:** при доставке лида сначала пытаемся списать 1 единицу `balance_leads` (prepaid pool); если 0 — списываем из `balance_rub` по текущей tier-цене. +2. **Tier-lookup per-tenant:** ступень определяется накопительной суммой `tenants.delivered_in_month` за месяц (новая колонка — см. §2). +3. **Атомарность:** charge + deal-INSERT + counter increment — одна транзакция. `lead_charges` пишется **всегда** при успешной доставке: `price_per_lead_kopecks=0` для prepaid, фактическая tier-цена для рублёвого списания. `charge_source ENUM('prepaid','rub')` для прозрачности. +4. **Pause-on-insufficient:** если оба источника пусты — лид НЕ доставляется этому проекту, статус Лидерра-проекта → `is_active=false` + email уведомление с rate-limit 1/час/tenant. +5. **Pricing tier change → effective 1-го числа след. месяца:** UI принимает изменения в любой день, но `effective_from` всегда auto-set = `DATE_TRUNC('month', NOW() + INTERVAL '1 month')`. Текущая активная ступень = `MAX(effective_from) WHERE effective_from <= CURRENT_DATE AND is_active=true`. +6. **`SupplierLeadCost` для sharing-flow:** при каждой созданной deal-копии пишем `supplier_lead_costs(deal_id, supplier_id, cost_rub=suppliers.cost_rub)` — закрывает существующий gap (Plan 2/3 не писали). +7. **CSV reconcile:** hourly. Сравнение по `supplier_leads.vid` за окно 25h. Drift > 5% → email админу. Recovered лиды идут через тот же `RouteSupplierLeadJob` с `recovered_from_csv_at` маркером. +8. **Admin UI:** SaaS-level `/admin/pricing-tiers` (CRUD 7 ступеней) + `/admin/supplier-prices` (B1/B2/B3 cost_rub editor) + tenant-level «Списания» tab в существующем `BillingView`. + +### 1.4. Out of scope Plan 4 + +1. Balance top-up UI для admin/tenant — Plan 4.5 или Plan 5. +2. Auto-resume project при пополнении баланса — Plan 4.5+. +3. Подписочный биллинг / payments / счета-фактуры — Plan 5+. +4. Per-tenant pricing override — out of MVP (spec §1.2). +5. Replay сделок при изменении tier'а задним числом — нет (`effective_from` + append-only `lead_charges`). +6. `Schedule::onOneServer()` для новых cron'ов — требует `cache_locks` таблицу, gated на Б-1 / Managed PG в Yandex Cloud. +7. SaaS-admin auth middleware — gated на Б-1 + SSO заказчика; на MVP все `/api/admin/*` без middleware (паритет с Plans 1/2/3). +8. Discovery CSV-схемы через credentials поставщика (паритет с Plan 3 Tasks 1+2 BLOCKED). +9. Polling балансов из платёжного шлюза. + +## 2. Schema delta v8.18 → v8.19 + +### 2.1. Изменения в существующих таблицах + +#### `tenants` — +1 колонка `delivered_in_month` + +```sql +ALTER TABLE tenants ADD COLUMN delivered_in_month INTEGER NOT NULL DEFAULT 0 + CHECK (delivered_in_month >= 0); + +COMMENT ON COLUMN tenants.delivered_in_month IS + 'Накопительный счётчик доставленных лидов в текущем календарном месяце (Europe/Moscow). ' + 'Используется PricingTierResolver для определения текущей ступени pricing_tiers ' + 'на горячем пути RouteSupplierLeadJob. Сбрасывается в 0 cron-командой ' + 'ResetMonthlyCountersCommand 1-го числа в 00:00 МСК (Plan 4).'; +``` + +Обоснование: SUM-by-projects на каждом charge — N+1 + `lockForUpdate`-race; денормализованный счётчик читается O(1) и обновляется в той же транзакции, что `lead_charges` INSERT. + +#### `tenants.balance_rub` — CHECK НЕ добавляем + +Намеренно: проверка `< price` происходит в `LedgerService::canCharge()`, в БД оставляем свободу для chargeback writedown (Ю-3, может уйти в плюс) и админских корректировок. + +#### `lead_charges` — +1 колонка `charge_source` + 1 CHECK + +```sql +ALTER TABLE lead_charges + ADD COLUMN charge_source VARCHAR(8) NOT NULL DEFAULT 'rub' + CHECK (charge_source IN ('prepaid','rub')); + +ALTER TABLE lead_charges + ADD CONSTRAINT chk_lead_charges_prepaid_zero_price + CHECK (charge_source = 'rub' OR price_per_lead_kopecks = 0); + +COMMENT ON COLUMN lead_charges.charge_source IS + 'Источник списания: prepaid (balance_leads -1, price_per_lead_kopecks=0) ' + 'или rub (balance_rub минус price). Plan 4.'; +``` + +`DEFAULT 'rub'` безопасен: до Plan 4 строк в таблице нет. + +#### `supplier_leads` — +1 колонка `recovered_from_csv_at` + +```sql +ALTER TABLE supplier_leads + ADD COLUMN recovered_from_csv_at TIMESTAMPTZ; + +CREATE INDEX supplier_leads_recovered_from_csv_partial + ON supplier_leads(recovered_from_csv_at) + WHERE recovered_from_csv_at IS NOT NULL; + +COMMENT ON COLUMN supplier_leads.recovered_from_csv_at IS + 'NULL для лидов, пришедших через webhook (основной канал). Заполняется CsvReconcileJob ' + 'при восстановлении лида, который webhook пропустил. Plan 4.'; +``` + +### 2.2. Новая таблица: `supplier_csv_reconcile_log` + +```sql +CREATE TABLE supplier_csv_reconcile_log ( + id BIGSERIAL PRIMARY KEY, + started_at TIMESTAMPTZ NOT NULL, + finished_at TIMESTAMPTZ, + window_start TIMESTAMPTZ NOT NULL, + window_end TIMESTAMPTZ NOT NULL, + total_csv_rows INTEGER, + matched_count INTEGER, + recovered_count INTEGER, + drift_ratio NUMERIC(5,4), + status VARCHAR(16) NOT NULL DEFAULT 'running' + CHECK (status IN ('running','ok','drift_alert','failed')), + error_message TEXT, + alert_email_sent_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX supplier_csv_reconcile_log_started_at_index + ON supplier_csv_reconcile_log(started_at DESC); +CREATE INDEX supplier_csv_reconcile_log_status_index + ON supplier_csv_reconcile_log(status) + WHERE status IN ('drift_alert','failed'); +``` + +SaaS-level (не tenant-scoped), без RLS. Аналог `supplier_sync_log`. **GRANT-policy:** REVOKE/GRANT для `crm_supplier_worker` (BYPASSRLS) **не идут в `schema.sql`** — `schema.sql` остаётся DDL-only под dev (postgres superuser). GRANT SELECT,INSERT,UPDATE на `supplier_csv_reconcile_log` для `crm_supplier_worker` добавляется в [db/02_grants.sql](../../../db/02_grants.sql) (паттерн Plan 2.6 #iv `7899071`). На dev таблица доступна суперпользователю по умолчанию. + +### 2.3. Seed-данные (отдельно от schema.sql) + +7 ступеней `pricing_tiers` + системные настройки. **НЕ идут в schema.sql** (тот — DDL only), а в: + +- `database/seeders/PricingTierSeeder.php` — 7 строк с `effective_from='1970-01-01'`. +- `system_settings` row seed в schema.sql §INSERTS — TBD ключ `supplier_lead_price_default_kopecks` (если потребуется fallback). + +**Дефолтные tier-цены** (`[?]` для заказчика — открытый вопрос #1): + +| tier_no | leads_in_tier | price_per_lead (руб) | price_per_lead_kopecks | +|---|---|---|---| +| 1 | 100 | 500.00 | 50000 | +| 2 | 200 | 450.00 | 45000 | +| 3 | 400 | 400.00 | 40000 | +| 4 | 800 | 350.00 | 35000 | +| 5 | 1500 | 300.00 | 30000 | +| 6 | 3000 | 270.00 | 27000 | +| 7 | NULL | 250.00 | 25000 | + +### 2.4. Метрики после Plan 4 + +| Метрика | До (v8.18) | После (v8.19) | Δ | +|---|---|---|---| +| Базовые таблицы | 61 | 62 (+`supplier_csv_reconcile_log`) | +1 | +| Партиции | 12 | 12 | 0 | +| Индексы | 114 | 117 | +3 | +| RLS-политики | 39 | 39 | 0 | +| CHECK constraints | (без явного подсчёта) | +2 (`tenants.delivered_in_month >= 0`, `chk_lead_charges_prepaid_zero_price`) | +2 | +| Добавлено колонок | — | `tenants.delivered_in_month`, `lead_charges.charge_source`, `supplier_leads.recovered_from_csv_at` | +3 | + +### 2.5. Patch order в schema.sql + +1. `tenants.delivered_in_month` — после строки 644 (`desired_daily_numbers`), в секции «Биллинг». +2. `lead_charges.charge_source` + CHECK — внутри `CREATE TABLE lead_charges` после строки 1008. +3. `supplier_csv_reconcile_log` — новая секция после `supplier_sync_log`. +4. `supplier_leads.recovered_from_csv_at` — внутри `CREATE TABLE supplier_leads` (v8.18). +5. Bump version header v8.18 → v8.19 + entry в `db/CHANGELOG_schema.md`. + +## 3. Billing flow в `RouteSupplierLeadJob` + +### 3.1. Новые сервисы + +**`App\Services\Billing\PricingTierResolver`** — pure resolver: + +```php +final class PricingTierResolver +{ + /** + * @param Collection $tiers активные ступени, отсортированные по tier_no + * @return PricingTier ступень, в которую попадает N-й лид (1-based) + */ + public function resolveForCount(Collection $tiers, int $deliveredInMonth): PricingTier; +} +``` + +Логика: tier 1 покрывает 1..`leads_in_tier`; tier 2 — следующие `leads_in_tier`; ... tier 7 с `leads_in_tier=NULL` ловит всё свыше. Текущая активная сетка = `MAX(effective_from) WHERE effective_from <= CURRENT_DATE AND is_active=true` через `PricingTierRepository::activeAt(Carbon $date)`. + +**`App\Services\Billing\LedgerService`** — командный сервис, инжектится в job: + +```php +final class LedgerService +{ + public function __construct( + private PricingTierResolver $resolver, + private PricingTierRepository $tiers, + ) {} + + /** + * Выполняется ВНУТРИ открытой DB-транзакции под lockForUpdate(Tenant). + * Возвращает ChargeResult с source ('prepaid'|'rub'); throws InsufficientBalanceException. + */ + public function chargeForDelivery(Tenant $lockedTenant, Deal $deal): ChargeResult; +} +``` + +**`App\Exceptions\Billing\InsufficientBalanceException`** — несёт `priceKopecks`, `balanceRub`, `balanceLeads` для логирования. + +### 3.2. Точка интеграции — diff в `createDealCopyForProject` + +Замена строк [RouteSupplierLeadJob.php:265-279](../../../app/app/Jobs/RouteSupplierLeadJob.php#L265-L279): + +**Было:** + +```php +$tenant->decrement('balance_leads'); +$tenant->refresh(); +$project->increment('delivered_today'); +$project->increment('delivered_in_month'); + +BalanceTransaction::create([ + 'tenant_id' => $tenant->id, + 'type' => BalanceTransaction::TYPE_LEAD_CHARGE, + 'amount_leads' => -1, + 'balance_leads_after' => (int) $tenant->balance_leads, + 'related_type' => Deal::class, + 'related_id' => $deal->id, + 'created_at' => now(), +]); +``` + +**Станет:** + +```php +try { + $chargeResult = $ledger->chargeForDelivery($tenant, $deal); +} catch (InsufficientBalanceException $e) { + Log::warning('billing.insufficient_balance.deal_rolled_back', [ + 'tenant_id' => $tenant->id, + 'project_id' => $project->id, + 'supplier_lead_id' => $lead->id, + 'price_kopecks' => $e->priceKopecks, + 'balance_rub' => $e->balanceRub, + 'balance_leads' => $e->balanceLeads, + ]); + throw $e; // вылетает из DB::transaction → rollback Deal/Tenant lock/ActivityLog +} + +$project->increment('delivered_today'); +$project->increment('delivered_in_month'); +``` + +### 3.3. Поток внутри `LedgerService::chargeForDelivery` + +``` +1. Считать активные tiers через PricingTierRepository::activeAt(today). +2. Resolve currentTier = PricingTierResolver::resolveForCount(tiers, + $lockedTenant->delivered_in_month + 1). +3. priceKopecks = currentTier->price_per_lead_kopecks. +4. Decide chargeSource (все денежные сравнения через bcmath — НЕ через PHP float): + - if $lockedTenant->balance_leads >= 1: source='prepaid' + - else if bccomp(bcmul((string) $lockedTenant->balance_rub, '100', 0), + (string) $priceKopecks, 0) >= 0: source='rub' + - else: throw InsufficientBalanceException(...) +5. Apply: + if source='prepaid': + - $lockedTenant->decrement('balance_leads', 1) + - $lockedTenant->increment('delivered_in_month', 1) + - $lockedTenant->refresh() + - INSERT lead_charges (charge_source='prepaid', tier_no=current, + price_per_lead_kopecks=0, charged_at=now) + - INSERT balance_transactions (type='lead_charge', amount_leads=-1, + balance_leads_after, related=Deal) + if source='rub': + - amount_rub = bcdiv($priceKopecks, '100', 2) (string-math, не float) + - $lockedTenant->decrement('balance_rub', $amount_rub) + - $lockedTenant->increment('delivered_in_month', 1) + - $lockedTenant->refresh() + - INSERT lead_charges (charge_source='rub', tier_no=current, + price_per_lead_kopecks=$priceKopecks, charged_at=now) + - INSERT balance_transactions (type='lead_charge', amount_rub=-$amount_rub, + balance_rub_after, related=Deal) +6. INSERT supplier_lead_costs (deal_id, received_at, supplier_id, cost_rub) + — закрывает gap (Plan 2/3 не писали в sharing-flow). supplier_id резолвится через + $lead->supplierProject->supplier_id (FK supplier_projects → suppliers); если null + (stub-проект без resolved supplier) — fallback в suppliers WHERE code = strtolower(platform). + cost_rub = $supplier->cost_rub (snapshot на момент INSERT'а). +7. return new ChargeResult(source, currentTier, priceKopecks) +``` + +**Атомарность:** все INSERT'ы — внутри одного `DB::transaction` родительского closure. Композитный DEFERRABLE FK `lead_charges → deals(id, received_at)` проверяется на commit. + +**Денежная арифметика:** `price_per_lead_kopecks` (INTEGER) → `balance_rub` (DECIMAL 12,2) через `bcdiv($priceKopecks, '100', 2)`, не PHP float. `Tenant::decrement('balance_rub', $amount)` использует SQL UPDATE (Eloquent), PG корректно держит в DECIMAL. + +### 3.4. Concurrency и порядок locks + +Текущий код держит lock'и в порядке (после Plan 2.5 fix #2): + +1. `Tenant::lockForUpdate` ([RouteSupplierLeadJob.php:188-191](../../../app/app/Jobs/RouteSupplierLeadJob.php#L188-L191)) +2. `Project::lockForUpdate` ([RouteSupplierLeadJob.php:199-202](../../../app/app/Jobs/RouteSupplierLeadJob.php#L199-L202)) + +Plan 4 не меняет порядок. `LedgerService::chargeForDelivery` принимает **уже locked** `$tenant` — не делает повторный SELECT. + +## 4. Monthly reset + balance-insufficient → auto-pause + +### 4.1. `ResetMonthlyCountersCommand` (cron 1-го числа в 00:00 МСК) + +Расширенный аналог `ResetDeliveredTodayCommand`: + +```php +namespace App\Console\Commands; + +final class ResetMonthlyCountersCommand extends Command +{ + protected $signature = 'projects:reset-monthly'; + protected $description = 'Сброс tenants.delivered_in_month + projects.delivered_in_month = 0 (1-го числа в 00:00 МСК)'; + + public function handle(): int + { + DB::connection('pgsql_supplier')->transaction(function () { + $tenants = DB::connection('pgsql_supplier') + ->update('UPDATE tenants SET delivered_in_month = 0 WHERE delivered_in_month <> 0'); + $projects = DB::connection('pgsql_supplier') + ->update('UPDATE projects SET delivered_in_month = 0 WHERE delivered_in_month <> 0'); + $this->info("Monthly reset: {$tenants} tenants, {$projects} projects."); + }); + return self::SUCCESS; + } +} +``` + +Schedule entry в [routes/console.php](../../../app/routes/console.php) — после `reset-delivered-today`: + +```php +Schedule::command('projects:reset-monthly') + ->monthlyOn(1, '00:00') + ->timezone('Europe/Moscow'); +``` + +Идемпотентность: `WHERE delivered_in_month <> 0` → повторный запуск даёт 0 affected. + +### 4.2. Auto-pause при insufficient balance + +`InsufficientBalanceException` ловится в `createDealCopyForProject` **после** rollback'а транзакции: + +```php +try { + return DB::transaction(function () use (...) { /* существующий код + LedgerService */ }); +} catch (InsufficientBalanceException $e) { + $this->handleInsufficientBalance($lead, $project, $e); + return false; +} +``` + +```php +private function handleInsufficientBalance( + SupplierLead $lead, + Project $project, + InsufficientBalanceException $e, +): void { + // UPDATE через pgsql_supplier (BYPASSRLS-роль crm_supplier_worker) — паттерн + // ResetDeliveredTodayCommand. Не используем SET LOCAL app.current_tenant_id, + // т.к. queue worker может работать под non-tenant context'ом. + DB::connection('pgsql_supplier') + ->update('UPDATE projects SET is_active = false WHERE id = ?', [$project->id]); + + $cacheKey = "billing:zero_balance_alert:{$project->tenant_id}"; + if (Cache::add($cacheKey, true, now()->addHour())) { + app(NotificationService::class)->notifyZeroBalance($project->tenant, $project); + } + + Log::warning('billing.project_paused_insufficient_balance', [ + 'tenant_id' => $project->tenant_id, + 'project_id' => $project->id, + 'supplier_lead_id' => $lead->id, + 'price_kopecks' => $e->priceKopecks, + 'balance_rub' => $e->balanceRub, + 'balance_leads' => $e->balanceLeads, + ]); +} +``` + +### 4.3. Семантика паузы + +- **Что пауза делает:** `projects.is_active = false`. `LeadRouter::matchEligibleProjects` уже фильтрует `WHERE is_active = true` (Plan 1/2 паттерн — подтверждается тестом). +- **Что НЕ делает:** не отменяет связь с supplier_projects, не сбрасывает счётчики, не трогает `SyncSupplierProjectsJob` (но союз active-tenant'ов пересчитается на след. SyncJob run). +- **Re-activation:** через UI пополнения баланса (Plan 4.5+) или manual toggle. Auto-resume — out of Plan 4. + +### 4.4. Notification — `notifyZeroBalance` + +`NotificationService::notifyZeroBalance(Tenant $tenant, Project $project)`. Матрица `notification_preferences` ([schema.sql:716](../../../db/schema.sql#L716)) `zero_balance` дефолтно через email. Email через Unisender Go. + +Mailable `App\Mail\ZeroBalancePausedMail` + blade `resources/views/emails/zero_balance_paused.blade.php`: + +- Subject: «Проект "{name}" приостановлен — недостаточно средств» +- Body (русский): имя проекта, текущий баланс (rub и leads), требуемая ступень + цена, ссылка `/billing/topup`. + +### 4.5. Rate-limit алертов 1/час/tenant + +Через `Cache::add($key, true, now()->addHour())` — atomic Redis SETNX. Открытый вопрос #2: возможна корректировка. + +## 5. CSV reconcile архитектура + +### 5.1. Расширение `SupplierPortalClient` + +Один новый публичный метод в [SupplierPortalClient.php:65](../../../app/app/Services/Supplier/SupplierPortalClient.php#L65): + +```php +public function downloadLeadsCsv(CarbonInterface $from, CarbonInterface $to): string +{ + $response = $this->request('GET', '/admin/report/index', [ + 'type' => 49, + 'from' => $from->format('Y-m-d H:i:s'), + 'to' => $to->format('Y-m-d H:i:s'), + ]); + + return $response->body(); +} +``` + +`request()` ([line 70](../../../app/app/Services/Supplier/SupplierPortalClient.php#L70)) уже поддерживает GET + query-string + 401 retry через `RefreshSupplierSessionJob` + 5xx → `SupplierTransientException` + 4xx → `SupplierClientException`. + +### 5.2. `SupplierCsvParser` + +Pure парсер, streaming через generator: + +```php +namespace App\Services\Supplier; + +final class SupplierCsvParser +{ + /** + * Ожидаемые столбцы (placeholder — открытый вопрос #4): + * vid;project;tag;phone;phones;time + * + * @return iterable + */ + public function parse(string $rawCsv): iterable; +} +``` + +### 5.3. `CsvReconcileJob` + +```php +namespace App\Jobs\Supplier; + +final class CsvReconcileJob implements ShouldQueue +{ + public int $tries = 1; + public int $timeout = 300; + + public function handle( + SupplierPortalClient $portal, + SupplierCsvParser $parser, + Mailer $mailer, + ): void; +} +``` + +**Алгоритм:** + +``` +1. Cache::lock('supplier:csv_reconcile', 600) — защита от overlap'а. +2. INSERT supplier_csv_reconcile_log (status='running', started_at=now, + window_start=now-25h, window_end=now). +3. csv = $portal->downloadLeadsCsv(window_start, window_end). +4. rows = $parser->parse(csv) → собрать ['vid' => row, ...]. +5. existing = SELECT vid FROM supplier_leads WHERE received_at BETWEEN window_start AND window_end + (через pgsql_supplier BYPASSRLS). +6. missing = array_diff_key(csvRows, existing). +7. drift_ratio = count(missing) / max(count(rows), 1). +8. Для каждой missing row: + - INSERT supplier_leads (vid, raw_payload=json_encode(row), received_at=row.time, + recovered_from_csv_at=now, supplier_project_id=null). + - dispatch(new RouteSupplierLeadJob($newLead->id)). + - recovered_count++. +9. UPDATE supplier_csv_reconcile_log: + total_csv_rows, matched_count, recovered_count, drift_ratio, finished_at, status. +10. if drift_ratio > 0.05: + - status='drift_alert', $mailer->send(CsvDriftAlertMail), alert_email_sent_at=now. +11. На исключение: status='failed', error_message, throw. +``` + +Окно — 25h (запас 1h над hourly cron'ом). Дубли через `supplier_leads.vid UNIQUE` — INSERT упадёт `unique_violation`, ловим, считаем как matched. + +### 5.4. Schedule entry + +После Plan 3 entries в [routes/console.php](../../../app/routes/console.php): + +```php +Schedule::job(new CsvReconcileJob)->hourly(); +``` + +Без `withoutOverlapping()` (нет `cache_locks`). Защита от overlap — внутренний `Cache::lock` (шаг 1). + +### 5.5. Recovery flow + +`RouteSupplierLeadJob` уже идемпотентен (Plan 2.5 fix #3 `processed_at` guard). `recovered_from_csv_at` маркер нужен для: + +1. Отчётности «webhook vs CSV» (spec §5.2). +2. ActivityLog tag: `'source' => $lead->recovered_from_csv_at !== null ? 'supplier_csv_recovery' : 'supplier_webhook'`. + +### 5.6. `CsvDriftAlertMail` + +Mailable + `resources/views/emails/csv_drift_alert.blade.php`: + +- Subject: «Лидерра ↔ Поставщик: расхождение CSV > 5% за {window}» +- Body (русский): drift %, total_csv_rows, missing_count, recovered_count, window, ссылка на `supplier_csv_reconcile_log.id`. +- Адресат: `config('services.supplier.alert_email')` — **не верифицировал** существование ключа в config из Plan 3; при impl грепнем и при необходимости добавим. + +## 6. Admin UI экраны + +### 6.1. `/admin/pricing-tiers` (SaaS-admin) + +**Frontend:** `resources/js/views/admin/AdminPricingTiersView.vue`. Маршрут в `resources/js/router/index.ts`: + +```ts +{ path: '/admin/pricing-tiers', + name: 'admin-pricing-tiers', + component: () => import('@/views/admin/AdminPricingTiersView.vue'), + meta: { layout: 'app', requiresAdmin: true } }, +``` + +**UI:** Vue 3 + Vuetify 3, Forest-палитра, Inter + JetBrains Mono для цифр (`font-feature-settings:'tnum'`). + +- `` «Текущая активная сетка (с {effective_from})»: `` 7 строк (tier_no, leads_in_tier — `NULL → «всё свыше»`, price отображается через computed «{руб},{коп}»). +- `` «Запланированные изменения»: будущие pricing_tiers (`effective_from > today`) + «отменить запланированное». +- `` «Редактировать сетку» → `` с 7-строчным редактором: `` для `leads_in_tier` (последний disabled = «NULL»), `` для `price_rub` с 2 десятичными. +- `effective_from`: read-only, auto = «1-е след. месяца». +- POST `/api/admin/pricing-tiers` (создаёт 7 новых строк с одинаковым `effective_from`). + +**Backend:** `App\Http\Controllers\Api\AdminPricingTiersController`: + +- `GET /api/admin/pricing-tiers` — active + scheduled. +- `POST /api/admin/pricing-tiers` — 7 новых rows с `effective_from = DATE_TRUNC('month', NOW() + INTERVAL '1 month')`. Validation: ровно 7 tiers, `tier_no` 1..7 unique, `leads_in_tier > 0` для 1..6, `leads_in_tier IS NULL` для 7, `price_per_lead_kopecks >= 0`. Single transaction. +- `DELETE /api/admin/pricing-tiers/scheduled/{effective_from}` — отмена будущей сетки. + +Маршрут в [routes/web.php:99](../../../app/routes/web.php#L99): + +```php +Route::prefix('/api/admin/pricing-tiers')->group(function () { + Route::get('/', 'App\Http\Controllers\Api\AdminPricingTiersController@index'); + Route::post('/', 'App\Http\Controllers\Api\AdminPricingTiersController@store'); + Route::delete('/scheduled/{effective_from}', + 'App\Http\Controllers\Api\AdminPricingTiersController@deleteScheduled') + ->where('effective_from', '\d{4}-\d{2}-\d{2}'); +}); +``` + +Без auth middleware (паритет с другими `/api/admin/*`). Audit trail — `saas_admin_audit_log`. + +### 6.2. `/admin/supplier-prices` (SaaS-admin) + +**Frontend:** `resources/js/views/admin/AdminSupplierPricesView.vue` + маршрут `/admin/supplier-prices`. + +**UI:** `` + `` 3 строки (B1/B2/B3): code, name, cost_rub (editable inline ``), quality_score (editable inline), is_active toggle. Кнопка «Сохранить». + +**Backend:** `App\Http\Controllers\Api\AdminSuppliersController`: + +- `GET /api/admin/suppliers` — список 3 строк. +- `PATCH /api/admin/suppliers/{id}` — обновление `cost_rub` / `quality_score` / `is_active`. Audit log. + +```php +Route::get('/api/admin/suppliers', 'App\Http\Controllers\Api\AdminSuppliersController@index'); +Route::patch('/api/admin/suppliers/{id}', 'App\Http\Controllers\Api\AdminSuppliersController@update') + ->where('id', '[0-9]+'); +``` + +### 6.3. Tenant-side: «Списания» tab в `BillingView` + +Не отдельный route — **новый tab** в существующем `BillingView`. Tabs: «Баланс» + «Списания» (новый) + «Тариф» (read-only показ pricing_tiers — открытый вопрос #3: всё или только текущая ступень). + +**UI** `resources/js/views/billing/ChargesTab.vue`: + +- Фильтры: `` период (текущий месяц / прошлый / 90 дней / диапазон), `` charge_source. +- ``: charged_at, deal_id (clickable → `/deals/{id}`), tier_no, charge_source (chip), price_rub, balance_rub_after. +- Footer: «Всего за период: {N} лидов, {sum} ₽» + кнопка «Скачать CSV». +- Pagination server-side. + +**Backend:** `App\Http\Controllers\Api\TenantChargesController` под `auth:sanctum + tenant`: + +```php +Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/billing/charges')->group(function () { + Route::get('/', 'App\Http\Controllers\Api\TenantChargesController@index'); + Route::post('/export', 'App\Http\Controllers\Api\TenantChargesController@export'); +}); +``` + +- `GET /api/billing/charges` — paginated lead_charges WHERE tenant_id = current (RLS защищает через `SetTenantContext`). JOIN `pricing_tiers` для leads_in_tier. +- `POST /api/billing/charges/export` — CSV через OpenSpout + `StreamedResponse` (паттерн из DealExport). + +### 6.4. Nav-tree + +В [AppLayout.vue](../../../app/resources/js/layouts/AppLayout.vue) — добавить пункты под admin-секцию (если `user.is_saas_admin`): «Тарифы» (`/admin/pricing-tiers`), «Поставщики» (`/admin/supplier-prices`). + +**Не верифицировал** различение saas-admin vs tenant-user в nav-tree (связано с Б-1 / SSO). Грепнем при impl. + +### 6.5. Stories для Histoire + +- `AdminPricingTiersView.story.vue` — 4 variants: пустая сетка / активная / scheduled / dialog open. +- `AdminSupplierPricesView.story.vue` — 2 variants: default / editing row. +- `billing/ChargesTab.story.vue` — 3 variants: пустой / mixed prepaid+rub / только rub. + +### 6.6. A11y и стиль + +- Vuetify-компоненты покрывают keyboard nav + ARIA. +- Числа — JetBrains Mono с `font-feature-settings:'tnum'`. +- Pa11y проверка после impl — 0 violations. + +## 7. Тестовая стратегия и Acceptance Criteria + +### 7.1. Сводка тестов + +| Категория | Слой | Кол-во | +|---|---|---| +| Pure unit на `PricingTierResolver` | tests/Unit/Billing | 7 | +| Integration `LedgerService::chargeForDelivery` | tests/Feature/Billing | 6 | +| E2E `RouteSupplierLeadJob` с биллингом | tests/Feature/Supplier | 4 | +| `ResetMonthlyCountersCommand` | tests/Feature/Console | 4 | +| Auto-pause flow | tests/Feature/Supplier | 5 | +| `SupplierCsvParser` | tests/Unit/Supplier | 5 | +| `SupplierPortalClient::downloadLeadsCsv` | tests/Unit/Supplier | 3 | +| `CsvReconcileJob` integration | tests/Feature/Supplier | 6 | +| E2E CSV happy-path mock-server | tests/Browser (Linux CI only) | 1 | +| `AdminPricingTiersController` | tests/Feature/Admin | 8 | +| `AdminSuppliersController` | tests/Feature/Admin | 4 | +| `TenantChargesController` | tests/Feature/Billing | 6 | +| Frontend Vitest на 3 Vue компонента | resources/js/.../spec.ts | 12 | +| **Итого новых тестов Plan 4** | | **71** | + +После Plan 4 baseline: ориентировочно **688 Pest + 405 Vitest** (фактическое число подтвердится при impl). + +### 7.2. Pre-commit (lefthook) gates + +Без изменений в `lefthook.yml`: Pint / Larastan / squawk / pgFormatter / cspell / gitleaks / markdownlint — встраивается в существующие jobs. Larastan baseline вероятно расширится на ~5-10 entries. + +### 7.3. Acceptance criteria + +| AC | Описание | Verification | +|---|---|---| +| **AC-1** | Schema v8.18 → v8.19: +1 таблица, +3 колонки, +3 индекса, +2 CHECK. | `migrate:fresh` зелёный; `db/CHANGELOG_schema.md`. | +| **AC-2** | `RouteSupplierLeadJob` пишет `lead_charges` + `supplier_lead_costs` в одной транзакции с Deal. | 4 E2E теста (см. §7.1). | +| **AC-3** | Dual-balance: balance_leads-first, balance_rub-second; tier через `delivered_in_month + 1`; bcmath. | 6 LedgerService тестов (см. §7.1). | +| **AC-4** | `ResetMonthlyCountersCommand` + `monthlyOn(1, '00:00')` + Europe/Moscow. | 4 Console-теста + `schedule:list`. | +| **AC-5** | Insufficient balance → exception → rollback → `is_active=false` + email (1/час/tenant). | 5 auto-pause тестов. | +| **AC-6** | `CsvReconcileJob` hourly, окно 25h, drift > 5% → email, лог в `supplier_csv_reconcile_log`. | 6 integration + 1 E2E. | +| **AC-7** | Admin UI: 7-tier editor, 3-row supplier prices, ChargesTab + CSV export. | 18 backend + 12 frontend + Histoire +9 variants + Pa11y 0. | +| **AC-8** | Retry-idempotency: повторный run job'а не пишет дубль `lead_charges`. | 1 retry-idempotency тест. | +| **AC-9** | Атомарные коммиты: 1 commit = 1 logical change. | git log review. | +| **AC-10** | 0 schema-orphan FK / 0 duplicate CREATE TABLE / RLS-метрики 39. | self-review per Pravila §4.6. | + +### 7.4. Verification gate перед merge (CV-pattern) + +1. `composer pint` — clean diff. +2. `composer stan` — 0 errors above baseline. +3. `composer test` — все Pest зелёные. +4. `npm run lint:vue` — clean. +5. `npm run type-check` — 0 errors. +6. `npm run test:vue` — все Vitest зелёные. +7. `npm run story` build — все variants собираются. +8. `php artisan migrate:fresh --database=liderra_testing` + RLS smoke + model smoke. +9. `npm run a11y` — 0 violations. +10. `gitleaks detect --no-banner` full history — 0 leaks. +11. `npm run links` (lychee) — 0 broken. +12. code-reviewer subagent → no BLOCKER. +13. Manual smoke tinker `RouteSupplierLeadJob`. +14. Manual UI smoke: pricing-tiers editor. + +### 7.5. Risks / mitigations + +| Risk | Mitigation | +|---|---| +| `delivered_in_month` race 2 concurrent deliveries | `lockForUpdate(Tenant)` уже держится; tier-lookup внутри lock → safe. | +| PHP float drift при price/100 | `bcdiv($kopecks, '100', 2)` — string-math; `decimal:2` cast. | +| CSV-схема расходится с реальной (Tasks 1-2 BLOCKED) | Schema parser placeholder; Http::fake тесты; impl Task 0 = manual curl после credentials. | +| Reset cron не запустился вовремя | Idempotent UPDATE; manual `php artisan projects:reset-monthly`. | +| Redis crash → Cache::add false → ноль алертов | Log::warning всегда; observability-alert out of Plan 4. | +| Pricing-tier editor: tier 7 c `leads_in_tier ≠ NULL` | Validation на бэке: ровно одна row с NULL = tier 7. | +| `charge_source='prepaid'` + `price ≠ 0` | CHECK `chk_lead_charges_prepaid_zero_price`. | + +### 7.6. Открытые вопросы + +| # | Вопрос | Кто решает | Дефолт | +|---|---|---|---| +| 1 | Дефолтные tier-цены (500/450/400/350/300/270/250 руб) | Заказчик | placeholder, ждём согласования перед seed'ом | +| 2 | Email rate-limit 1/час/tenant — норма? | Заказчик | 1/час | +| 3 | Tenant видит ВСЕ ступени или только свою? | Заказчик | transparent (все) | +| 4 | CSV-схема (точные столбцы из `/admin/report/index?type=49`) | Discovery после credentials | placeholder webhook-payload | +| 5 | CSV окно (25h) — норма? | Заказчик/опыт | 25h | +| 6 | Drift threshold 5% — норма? | Заказчик/опыт | 5% | +| 7 | Pricing-tier-change при повышении цены | Заказчик | единая логика с понижением (effective 1-е след. месяца) | + +Эти 7 — попадают в Открытые_вопросы реестра как новые `Биз-*` после approval'а спецификации. + +### 7.7. Декомпозиция на impl-задачи (preview) + +Детальный impl-plan будет написан через skill `superpowers:writing-plans` после approval'а spec'а. Preview: + +1. **Task 1:** Schema delta v8.18 → v8.19. +2. **Task 2:** `PricingTierResolver` (pure unit, TDD). +3. **Task 3:** `LedgerService::chargeForDelivery` (integration, TDD). +4. **Task 4:** Integration `LedgerService` в `RouteSupplierLeadJob`. +5. **Task 5:** `ResetMonthlyCountersCommand` + Schedule entry. +6. **Task 6:** Auto-pause flow + `ZeroBalancePausedMail` + rate-limit. +7. **Task 7:** `SupplierPortalClient::downloadLeadsCsv` + `SupplierCsvParser`. +8. **Task 8:** `CsvReconcileJob` + `supplier_csv_reconcile_log` + `CsvDriftAlertMail`. +9. **Task 9:** `AdminPricingTiersController` backend + Vue view + Histoire. +10. **Task 10:** `AdminSuppliersController` backend + Vue view + Histoire. +11. **Task 11:** `TenantChargesController` + ChargesTab + CSV export + Histoire. +12. **Task 12:** Verification gate (full CV) + code-review subagent. + +12 Tasks. TDD: red → green → refactor; атомарный коммит на Task. + +## 8. Ограничения и неверифицированное + +В соответствии с экономия-0% дисциплиной фиксирую явно, что **не верифицировано** на момент написания spec'а: + +- `LeadRouter::matchEligibleProjects` действительно фильтрует `WHERE is_active = true` — будет проверено грепом и feature-тестом при impl. +- `config('services.supplier.alert_email')` ключ уже существует из Plan 3 — будет проверено грепом при impl. +- `AppLayout.vue` различает saas-admin vs tenant-user nav-tree — будет проверено грепом при impl. +- Точное число Pest baseline после Plan 4 (688) — округлённое; финальное число — после запуска `composer test`. +- Vuetify `` cells применяют `font-feature-settings:'tnum'` без кастомного slot'а — будет проверено визуально при impl. +- OpenSpout `StreamedResponse` паттерн в проекте — упомянут в memory `feedback_environment.md`, но конкретный пример код в DealExport не перечитывал; найдём при impl. + +## 9. Источник истины и cross-refs + +- Spec Plan 4 (этот файл): docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md +- Parent spec: [docs/superpowers/specs/2026-05-10-supplier-integration-design.md](./2026-05-10-supplier-integration-design.md) §5.2, §7 +- Schema: [db/schema.sql](../../../db/schema.sql) (v8.18; будет v8.19 после impl) +- CLAUDE.md §6 (текущая фаза) — будет обновлён после merge Plan 4 +- Pravila §4.5 (3 варианта) — overridden brainstorming-skill согласно §11.1 + §12