# Backend-конвенции Лидерры — детальный справочник

Образцы ниже — реальный код из `app/` (Laravel 13, PHP 8.3).
Указаны конкретные `file:line` на момент 20.05.2026.

---

## §1. Слоистость: Controller → FormRequest → Service → Job

### Правило

Контроллер принимает FormRequest (валидация), делегирует Service (бизнес-логика),
при необходимости Service dispatch'ит Job (асинхрон). Контроллер не содержит бизнес-логики.
Слои задокументированы в `app/deptrac.yaml` — 13 слоёв:
Controller, Request, Resource, Middleware, Service, Job, Console, Repository,
Model, Mail, Rule, Exception, Provider.
Допустимые направления зависимостей — только вниз по иерархии (deptrac gate, lefthook job 10).

### Образец из кода

`app/app/Http/Controllers/Api/ProjectController.php:87–90` — контроллер тонкий:

```php
/** POST /api/projects */
public function store(StoreProjectRequest $request): JsonResponse
{
    $project = $this->projects->create($request->user()->tenant, $request->validated());

    return response()->json(['data' => new ProjectResource($project)], 201);
}
```

`app/app/Http/Requests/StoreProjectRequest.php:18–44` — вся валидация в FormRequest:

```php
public function rules(): array
{
    $base = [
        'name'               => ['required', 'string', 'max:255'],
        'signal_type'        => ['required', Rule::in(['site', 'call', 'sms'])],
        'daily_limit_target' => ['required', 'integer', 'min:1', 'max:10000'],
        'regions'            => ['present', 'array'],
        'regions.*'          => ['integer', 'between:1,89'],
        'delivery_days_mask' => ['required', 'integer', 'min:1', 'max:127'],
    ];
    // ... conditional rules by signal_type
    return $base;
}
```

`app/app/Services/Billing/LedgerService.php` — бизнес-логика в Service.
`app/app/Jobs/ProcessWebhookJob.php` — асинхрон в Job.

### Антипаттерн

```php
// ПЛОХО: бизнес-логика в контроллере
public function store(Request $request): JsonResponse
{
    $tier = PricingTier::where('min_leads', '<=', $count)->orderBy('min_leads', 'desc')->first();
    $price = $tier->price_per_lead_kopecks * $count;  // float-арифметика + логика тира прямо здесь
    Deal::create([...]);
    return response()->json(['ok' => true]);
}
```

---

## §2. RLS-aware Eloquent и middleware `tenant`

### Правило

Middleware `SetTenantContext` (`app/app/Http/Middleware/SetTenantContext.php`) оборачивает
каждый HTTP-запрос в транзакцию и выполняет `SET LOCAL app.current_tenant_id = X`,
после чего RLS-политики PostgreSQL автоматически фильтруют строки по tenant.

**КРИТИЧНО для джобов**: очередные джобы Laravel выполняются в отдельном процессе вне
HTTP-стека. Роль `crm_supplier_worker` (connection `pgsql_supplier`) имеет атрибут
BYPASSRLS — RLS-политики для неё **не применяются**. Любой запрос в таком джобе без
явного `where('tenant_id', $tenantId)` вернёт строки всех tenant'ов.

Правило: в каждом джобе либо устанавливай `SET LOCAL` внутри транзакции (паттерн
`ProcessWebhookJob`/`ImportLeadsJob`), либо добавляй явный `where('tenant_id', ...)`.

### Образец из кода

`app/app/Http/Middleware/SetTenantContext.php:36–43` — HTTP-путь:

```php
DB::beginTransaction();
try {
    DB::statement('SET LOCAL app.current_tenant_id = ' . $tenantId);
    $response = $next($request);
    DB::commit();
    return $response;
} catch (\Throwable $e) {
    DB::rollBack();
    throw $e;
}
```

`app/app/Jobs/ImportLeadsJob.php:92–96` — джоб устанавливает `SET LOCAL` вручную:

```php
return DB::transaction(function (): ?ImportLog {
    DB::statement('SET LOCAL app.current_tenant_id = ' . $this->tenantId);
    return ImportLog::query()->find($this->importLogId);
});
```

`app/app/Jobs/ProcessWebhookJob.php:80–86` — аналогичный паттерн в webhook-джобе:

```php
DB::transaction(function () use ($duplicateDetector): void {
    DB::statement('SET LOCAL app.current_tenant_id = ' . $this->tenantId);
    $tenant = Tenant::query()
        ->whereKey($this->tenantId)
        ->lockForUpdate()
        ->first();
```

### Антипаттерн

```php
// ПЛОХО: джоб под crm_supplier_worker без SET LOCAL и без where tenant_id
// → вернёт все строки всех tenant'ов (BYPASSRLS не фильтрует)
public function handle(): void
{
    $logs = ImportLog::query()->where('status', 'pending')->get(); // ВСЕ tenant'ы!
}
```

---

## §3. Деньги — только через bcmath и LedgerService

### Правило

Все арифметические операции с деньгами (рубли, копейки) — исключительно через
функции `bcmath` с явным `scale`. Операнды передаются строками.
Никаких PHP `float`, никакого `+` / `-` / `*` / `/` над денежными значениями.

Точка входа для списания за лид — `LedgerService::chargeForDelivery()`.
Этот метод реализует dual-balance flow (prepaid-лиды → `balance_leads`, рубли → `balance_rub`).
Вызывается **внутри открытой транзакции** с `lockForUpdate(Tenant)` — см. §4.

Аудит денежных инвариантов (I1–I5) — скил `billing-audit` (#62). Здесь — конвенция написания.

### Образец из кода

`app/app/Services/Billing/LedgerService.php:64–65` — конвертация копеек в рубли:

```php
$amountRub = bcdiv((string) $priceKopecks, '100', 2);
$newBalanceRub = bcsub((string) $lockedTenant->balance_rub, $amountRub, 2);
```

`app/app/Services/Billing/LedgerService.php:124–125` — сравнение балансов:

```php
$balanceKopecks = bcmul((string) $tenant->balance_rub, '100', 0);
if (bccomp($balanceKopecks, (string) $priceKopecks, 0) >= 0) {
    return 'rub';
}
```

### Антипаттерн

```php
// ПЛОХО: float-арифметика теряет копейки
$price = $tier->price_per_lead_kopecks / 100;  // float
$newBalance = $tenant->balance_rub - $price;    // потеря точности при накоплении
```

---

## §4. Идемпотентные джобы через advisory lock

### Правило

Повторный запуск джоба (ретрай, краш, дубль cron) не должен создавать дублирующие
записи. Паттерн: `pg_advisory_xact_lock(bigint)` внутри транзакции сериализует все
конкурентные обработки одного (tenant_id, source_crm_id).

Дополнительно для мутаций баланса: `lockForUpdate` на строку Tenant — защита от
TOCTOU (между чтением баланса и его обновлением другой воркер не должен изменить значение).

### Образец из кода

`app/app/Jobs/ProcessWebhookJob.php:293–296` — advisory lock перед upsert:

```php
// pg_advisory_xact_lock(bigint): верхние 32 бита = tenant_id, нижние 32 = source_crm_id
$lockKey = (($tenant->id & 0xFFFFFFFF) << 32) | ($sourceCrmId & 0xFFFFFFFF);
DB::statement('SELECT pg_advisory_xact_lock(?)', [$lockKey]);
```

`app/app/Services/Import/HistoricalImportService.php:145–147` — тот же паттерн в сервисе:

```php
// advisory lock (tenant_id, source_crm_id) — сериализует upsert (§6.5)
$lockKey = (($tenantId & 0xFFFFFFFF) << 32) | ($row->sourceCrmId & 0xFFFFFFFF);
DB::statement('SELECT pg_advisory_xact_lock(?)', [$lockKey]);
```

`app/app/Jobs/RouteSupplierLeadJob.php:210–213` — lockForUpdate на Tenant перед списанием:

```php
$tenant = Tenant::query()
    ->whereKey($project->tenant_id)
    ->lockForUpdate()
    ->firstOrFail();
```

Для overlap-защиты долгоживущих джобов (cron) — `Cache::lock` (Redis):
`app/app/Jobs/Supplier/CsvReconcileJob.php:69–74`:

```php
$lock = $lockStore->lock(self::LOCK_NAME, self::LOCK_TTL_SECONDS);
if (! $lock->get()) {
    Log::info('csv_reconcile.skipped_overlap');
    return;
}
```

### Антипаттерн

```php
// ПЛОХО: нет lock — два конкурентных воркера создают два deal для одного vid
$existing = Deal::where('source_crm_id', $vid)->where('tenant_id', $tenantId)->first();
if (!$existing) {
    Deal::create([...]); // race condition: оба воркера видят null и оба создают
}
```

---

## §5. Partition-aware запросы для `deals` и `supplier_lead_costs`

### Правило

Таблицы `deals` и `supplier_lead_costs` секционированы по `PARTITION BY RANGE (received_at)`.
Запросы должны содержать условие по `received_at` (ключ партиционирования) — это позволяет
PostgreSQL выполнять partition pruning и не сканировать все партиции.
Запрос без `WHERE received_at ...` делает full-scan всех партиций.

### Образец из кода

`db/schema.sql:1658` — партиционирование `deals`:

```sql
) PARTITION BY RANGE (received_at);
```

`db/schema.sql:2361` — партиционирование `supplier_lead_costs`:

```sql
) PARTITION BY RANGE (received_at);
```

`app/app/Services/DuplicateDetector.php:49` — запрос к `deals` с ключом партиции:

```php
->where('received_at', '>=', $windowStart)
```

`app/app/Jobs/Supplier/CsvReconcileJob.php:113` — запрос к `supplier_leads` с ключом:

```php
->where('received_at', '>=', $windowStart)
```

### Антипаттерн

```php
// ПЛОХО: запрос к deals без received_at — full-scan всех партиций
$deals = Deal::where('tenant_id', $tenantId)
    ->where('phone', $phone)
    ->get(); // сканирует deals_2026_05, deals_2026_06, ... все партиции
```
