docs(specs): Plan 4 (Billing + CSV Reconcile + Admin) implementation design
Brainstorming output для Plan 4: активация ступенчатого биллинга
(pricing_tiers/lead_charges никем не читались/писались), резервный CSV-канал
приёма лидов из портала поставщика, Admin UI (pricing-tiers editor +
supplier-prices editor + tenant ChargesTab).
9 разделов: контекст + 8 бизнес-инвариантов + 9 out-of-scope; schema delta
v8.18 → v8.19 (+1 таблица supplier_csv_reconcile_log, +3 колонки
tenants.delivered_in_month/lead_charges.charge_source/supplier_leads.recovered_from_csv_at,
+3 индекса, +2 CHECK); billing flow в RouteSupplierLeadJob (3 новых сервиса
PricingTierResolver/LedgerService/InsufficientBalanceException + dual-balance
prepaid-first логика + bcmath денежная арифметика); monthly reset
(ResetMonthlyCountersCommand + Schedule monthlyOn(1,'00:00') Europe/Moscow);
auto-pause flow (project.is_active=false + email с rate-limit 1/час/tenant);
CSV reconcile (расширение SupplierPortalClient + SupplierCsvParser +
CsvReconcileJob hourly + drift > 5% → email); Admin UI (2 SaaS-admin
view + 1 tab в существующем BillingView); тестовая стратегия (+71 теста)
и 10 AC; ограничения (6 неверифицированных).
Закрывает TODO «Биллинг per Plan 4» в RouteSupplierLeadJob.php:48.
Inherits from Plans 1+2+2.5+2.6+3 (HEAD origin/main = 926fee9).
Self-review applied 5 fixes inline: AC ссылки §3.5 → §7.1; auto-pause UPDATE
через pgsql_supplier BYPASSRLS connection; supplier_id source для
supplier_lead_costs INSERT; bccomp(bcmul()) вместо PHP float compare;
GRANT-policy в db/02_grants.sql, не schema.sql.
7 открытых вопросов с дефолтами в §7.6 для последующего ввода в реестр.
+5 терминов в cspell-words.txt (декрементится / Инкрементится / Подписочный
/ bcdiv / TRUNC) для прохождения lefthook cspell stage.
Парный план (writing-plans output) будет в docs/superpowers/plans/ отдельным
коммитом после согласования spec'а.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -915,3 +915,10 @@ Sel
|
||||
overhead
|
||||
overhead'ный
|
||||
choco
|
||||
|
||||
# Plan 4 spec — Billing + CSV Reconcile + Admin (2026-05-11)
|
||||
декрементится
|
||||
Инкрементится
|
||||
Подписочный
|
||||
bcdiv
|
||||
TRUNC
|
||||
|
||||
@@ -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<int, PricingTier> $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<int, array{vid: string, project: string, phone: string, time: int}>
|
||||
*/
|
||||
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'`).
|
||||
|
||||
- `<v-card>` «Текущая активная сетка (с {effective_from})»: `<v-data-table>` 7 строк (tier_no, leads_in_tier — `NULL → «всё свыше»`, price отображается через computed «{руб},{коп}»).
|
||||
- `<v-card>` «Запланированные изменения»: будущие pricing_tiers (`effective_from > today`) + «отменить запланированное».
|
||||
- `<v-btn>` «Редактировать сетку» → `<v-dialog>` с 7-строчным редактором: `<v-text-field type="number">` для `leads_in_tier` (последний disabled = «NULL»), `<v-text-field>` для `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:** `<v-card>` + `<v-data-table>` 3 строки (B1/B2/B3): code, name, cost_rub (editable inline `<v-text-field type="number" step="0.01">`), 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`:
|
||||
|
||||
- Фильтры: `<v-select>` период (текущий месяц / прошлый / 90 дней / диапазон), `<v-select>` charge_source.
|
||||
- `<v-data-table>`: 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 `<v-data-table>` 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
|
||||
Reference in New Issue
Block a user