# Slepok Routing Protection — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Заменить live-чтение `projects.*` в `LeadRouter` на ежедневный snapshot, чтобы вчерашний заказ клиента действовал сегодня — закрыть 19 рисков (R-01..R-19), выявленных в аудите сессии `135a4adf`.

**Architecture:** Создаём таблицу `project_routing_snapshots` с зафиксированным состоянием каждого активного проекта на каждый рабочий день. Cron в 18:02 МСК (после `BalancePreflightSweepJob`, до `SyncSupplierProjectsJob`) создаёт slepok №NЛ для дня N+1. `LeadRouter` читает из snapshot для eligibility-полей (is_active/limit/days/regions); баланс остаётся live (защита auto-pause). Изменения настроек применяются со следующего slepok'а — UI явно отображает `applies_date` через расширенный `SupplierSnapshotGuard::appliesFrom()`.

**Tech Stack:**

- Backend: PHP 8.3 + Laravel 13.
- БД: PostgreSQL 16 + RLS + partitioning (`PARTITION BY RANGE (snapshot_date)`).
- Тесты: Pest 4 (`composer test --parallel`).
- DB connections: `pgsql` (RLS, default) + `pgsql_supplier` (BYPASSRLS-роль `crm_supplier_worker`).
- Queue/cache: Memurai Redis (Windows-аналог Redis 7).
- Cron: Laravel Scheduler в `app/routes/console.php` (heartbeat через `SchedulerHeartbeatTracker`).
- bcmath для денежных операций (`LedgerService`).

**Спек:** [`docs/superpowers/specs/2026-05-26-slepok-routing-protection-design.md`](../specs/2026-05-26-slepok-routing-protection-design.md) v0.4.

**Memory:** [`project_slepok_protection.md`](memory) + [`project_billing_v2.md`](memory) (online/batch policy).

**Контекст состояния веток (на момент старта плана):**

- main HEAD `52158423`.
- `feat/billing-v2-spec-c` HEAD `f0269534` — отстаёт от main на 50+ коммитов (Snapshot Guard + Phase 2 FK `0da72778` + enforce-hooks). Spec C forward = 14 коммитов.
- На проде scp-патчи Spec C поверх main `0902de96` — Этап 1 это рассинхронизирует.

---

## Структура файлов (что создаём и меняем)

**Новые файлы (Этап 2):**

- `app/database/migrations/2026_05_27_120000_create_project_routing_snapshots_table.php` — миграция таблицы + RLS + партиционирование.
- `app/app/Jobs/SnapshotProjectRoutingJob.php` — cron 18:02 МСК.
- `app/app/Console/Commands/SnapshotBackfillCommand.php` — `php artisan snapshot:backfill --date=YYYY-MM-DD`.
- `app/app/Console/Commands/SnapshotRebuildCommand.php` — recovery `php artisan snapshot:rebuild --date=YYYY-MM-DD`.

**Новые файлы (Этап 4):**

- `app/app/Console/Commands/SupplierRekeyOrphansCommand.php` — миграция R-17 orphan SMS-rows.

**Изменяемые файлы:**

| Файл | Этап | Что |
|---|---|---|
| `app/app/Services/LeadRouter.php` | 1.2, 2.5, 3.1 | DROP balance_leads, JOIN snapshot, frozen filter |
| `app/app/Jobs/RouteSupplierLeadJob.php` | 2.6 | snapshot recheck + is_active recheck |
| `app/app/Services/Project/SupplierSnapshotGuard.php` | 2.7 | новое API `appliesFrom()` |
| `app/app/Services/Project/ProjectService.php` | 2.8 | вызов `appliesFrom` для slepok-полей |
| `app/app/Jobs/Supplier/SyncSupplierProjectsJob.php` | 2.9 | чтение из snapshot вместо live |
| `app/app/Jobs/Billing/BalancePreflightSweepJob.php` | 3.2 | sync `paused_at` при freeze/unfreeze |
| `app/app/Services/Billing/LedgerService.php` | 3.1 | reject frozen |
| `app/app/Jobs/Supplier/CleanupInactiveSupplierProjectsJob.php` | 1.4 | subquery через pivot |
| `app/app/Services/Supplier/SupplierProjectGrouping.php` | 4.1 | удалить `buildUniqueKey($p, $platform)` |
| `app/app/Jobs/SyncSupplierProjectJob.php` | 4.3 | fixed target_date |
| `app/app/Models/Tenant.php` | 4.4 | share-aware `requiredLeadsForTomorrow` |
| `app/app/Jobs/Supplier/CsvReconcileJob.php` | 4.5 | business-drift проверка |
| `app/routes/console.php` | 2.4 | `SnapshotProjectRoutingJob` + recovery cmd |
| `app/resources/js/components/project/ProjectFormDialog.vue` (или similar) | 2.11 | сообщение `applies_date` |

---

# Этап 1 — Стабилизация прод-state (0.5–1 рабочий день)

Закрывает R-12, R-14, R-16. Готовит почву под Этап 2 (rebase, drop column, cleanup-fix).

## Task 1.1: Rebase `feat/billing-v2-spec-c` на `origin/main`

**Files:**

- Modify: вся ветка `feat/billing-v2-spec-c`.
- Особое внимание при резолве: `app/app/Services/LeadRouter.php`, `app/app/Jobs/RouteSupplierLeadJob.php`, `app/app/Models/Project.php`, `app/app/Models/Tenant.php`, `app/app/Services/Project/ProjectService.php`.

- [ ] **Step 1: `git fetch --all` и оценка диффа**

```bash
cd c:/моя/проекты/портал\ crm/Документация
git fetch --all
echo "=== spec-c forward ==="
git log origin/main..origin/feat/billing-v2-spec-c --oneline | wc -l
echo "=== main forward (которые нужно подтянуть) ==="
git log origin/feat/billing-v2-spec-c..origin/main --oneline | wc -l
```

Expected: spec-c forward ~14, main forward 50+. Если main forward сильно вырос с момента 26.05 — повторно сверить с состоянием на момент начала.

- [ ] **Step 2: Создать локальную ветку rebase**

```bash
git checkout feat/billing-v2-spec-c
git checkout -b rebase/spec-c-on-main
git rebase origin/main
```

Expected: rebase начинается, конфликты в файлах списка `Files` выше.

- [ ] **Step 3: Резолв конфликтов — `LeadRouter.php`**

Сохранить из main: DIRECT-path (lines 54-80 main) — он добавлен в main после Plan 3 supplier-platform-prefix. Сохранить из spec-c: ничего (spec-c не трогает LeadRouter в этой части).

Финальный файл должен иметь:

- DIRECT-path из main (для `supplier_project->platform === 'DIRECT'`).
- B1/B2/B3 path из main.
- Никаких snapshot-правок (это Task 2.5 потом).

- [ ] **Step 4: Резолв конфликтов — `RouteSupplierLeadJob.php`**

Сохранить из main:

- `parseProjectField` с DIRECT fallback (lines 172-205 main).
- Phase 2 CSV-merge `$existingMergeable` блок (lines 253-302 main).
- `handleInsufficientBalance` (lines 399-426 main).
- **КРИТИЧНО:** `received_at` НЕ обновлять на merge — это Phase 2 FK fix `0da72778`. Cмотри inline-комментарии lines 282-289 main про partition-key + FK ON UPDATE NO ACTION. Этот фикс не теряем.

Сохранить из spec-c: ничего (spec-c не изменял этот файл независимо).

- [ ] **Step 5: Резолв конфликтов — `Project.php`**

Сохранить из main: `paused_at` в `$fillable` + cast как `datetime` (lines 43, 70 main).

В spec-c этого поля нет — он его удалил. После rebase оно должно быть.

- [ ] **Step 6: Резолв конфликтов — `Tenant.php`**

Сохранить из spec-c: `frozen_by_balance_at` в `$fillable` + cast + метод `requiredLeadsForTomorrow()`.

Сохранить из main: всё остальное.

- [ ] **Step 7: Резолв конфликтов — `ProjectService.php`**

Сохранить из main: вызовы `SupplierSnapshotGuard::assertCanMutateSource` в `update()` (line 40) и `delete()` (line 165) + `bulkPauseResume` запись `paused_at` (line 284) + `bulkDelete` reason `supplier_snapshot_locked` (lines 318-322).

Сохранить из spec-c: ничего (spec-c не изменял ProjectService).

- [ ] **Step 8: Завершить rebase**

```bash
git add app/
git rebase --continue
# Повторить для каждого commit'а с конфликтом
```

Expected: rebase успешно завершён, локальный HEAD ветки `rebase/spec-c-on-main` — поверх `origin/main`.

- [ ] **Step 9: Полный регресс**

```bash
cd app
composer test --parallel
```

Expected: все Pest зелёные. Особенно проверить:

- `tests/Feature/Billing/BalancePreflightSweepJobTest.php` — frozen flow.
- `tests/Feature/Supplier/SyncSupplierPreflightFilterTest.php` — фильтр frozen в push'е.
- `tests/Feature/Supplier/SupplierWebhookReceiveTest.php` — DIRECT path.
- Любые supplier-snapshot-guard тесты на main.

Если красные — резолв конфликтов был неправильный, вернуться к step 3.

- [ ] **Step 10: Force-push на origin (вы overwrите старую ветку)**

```bash
git push origin rebase/spec-c-on-main:feat/billing-v2-spec-c --force-with-lease
```

Expected: ветка `feat/billing-v2-spec-c` обновлена.

- [ ] **Step 11: Создать PR и merge через FF**

```bash
gh pr create --base main --head feat/billing-v2-spec-c --title "feat(billing-v2-c): merge Spec C into main (Phase 1+Spec C extensions)" --body "Merge spec-c branch (14 commits) into main after rebase. Includes BalancePreflightSweepJob, BalancePreflightService, SupplierExportMode, ProjectController preflight, balance_freeze migration. Closes scp-drift between repo and prod."
gh pr merge --auto --merge
```

Expected: PR создан, после прохождения CI — авто-merge.

- [ ] **Step 12: Обновить `ПИЛОТ.md` + memory**

В `ПИЛОТ.md` сверху добавить: «{date} — spec-c влит в main через rebase + FF merge, scp-патчи больше не нужны для следующего деплоя».

В memory `project_billing_v2.md` пометить «Spec C merged into main HEAD `<новый-sha>`».

- [ ] **Step 13: Commit memory + ПИЛОТ.md**

```bash
git add ПИЛОТ.md
git add C:/Users/Administrator/.claude/projects/c---------------------crm-------------/memory/project_billing_v2.md
git commit -m "docs: spec-c merged into main, scp-drift closed"
```

NB: `redeploy.sh` НЕ запускать на этом шаге — это будет после Task 1.4.

---

## Task 1.2: R-12 — `LeadRouter` готов к DROP `balance_leads`

**Files:**

- Modify: `app/app/Services/LeadRouter.php:65-66, 95-97` — удалить `OR tenants.balance_leads > 0`.
- Test: `app/tests/Feature/LeadRouter/BalanceFilterTest.php` (новый).

- [ ] **Step 1: Создать failing test**

`app/tests/Feature/LeadRouter/BalanceFilterTest.php`:

```php
<?php

declare(strict_types=1);

use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\LeadRouter;

it('does not match project for tenant with only balance_leads (no balance_rub)', function () {
    $tenant = Tenant::factory()->create([
        'balance_rub' => '0.00',
        'balance_leads' => 999, // legacy — должно игнорироваться после фикса
    ]);
    $project = Project::factory()->for($tenant)->create([
        'is_active' => true,
        'delivery_days_mask' => 127,
        'daily_limit_target' => 10,
        'delivered_today' => 0,
    ]);
    $sp = SupplierProject::factory()->create();
    \DB::table('project_supplier_links')->insert([
        'project_id' => $project->id,
        'supplier_project_id' => $sp->id,
        'platform' => $sp->platform,
        'subject_code' => null,
    ]);

    $matched = app(LeadRouter::class)->matchEligibleProjects($sp);

    expect($matched)->toHaveCount(0); // balance_rub=0 → не eligible
});

it('matches project for tenant with balance_rub > 0 (balance_leads ignored)', function () {
    $tenant = Tenant::factory()->create([
        'balance_rub' => '500.00',
        'balance_leads' => 0,
    ]);
    $project = Project::factory()->for($tenant)->create([
        'is_active' => true,
        'delivery_days_mask' => 127,
        'daily_limit_target' => 10,
        'delivered_today' => 0,
    ]);
    $sp = SupplierProject::factory()->create();
    \DB::table('project_supplier_links')->insert([
        'project_id' => $project->id,
        'supplier_project_id' => $sp->id,
        'platform' => $sp->platform,
        'subject_code' => null,
    ]);

    $matched = app(LeadRouter::class)->matchEligibleProjects($sp);

    expect($matched)->toHaveCount(1);
});
```

- [ ] **Step 2: Запустить тест — должен упасть**

```bash
cd app && composer test -- --filter=BalanceFilterTest
```

Expected: первый тест FAIL (текущий код матчит при balance_leads>0); второй PASS.

- [ ] **Step 3: Применить фикс**

`app/app/Services/LeadRouter.php` lines 63-67 (DIRECT path) и lines 94-98 (B1/B2/B3 path) — заменить:

```php
AND EXISTS (
    SELECT 1 FROM tenants
    WHERE tenants.id = projects.tenant_id
      AND (tenants.balance_leads > 0 OR tenants.balance_rub > 0)
)
```

на:

```php
AND EXISTS (
    SELECT 1 FROM tenants
    WHERE tenants.id = projects.tenant_id
      AND tenants.balance_rub > 0
)
```

- [ ] **Step 4: Запустить тест — должен пройти**

```bash
composer test -- --filter=BalanceFilterTest
```

Expected: оба теста PASS.

- [ ] **Step 5: Полный регресс**

```bash
composer test --parallel
```

Expected: все Pest зелёные. Если что-то red — проверить не использует ли LedgerService legacy `balance_leads` для charge.

- [ ] **Step 6: Commit**

```bash
git add app/app/Services/LeadRouter.php app/tests/Feature/LeadRouter/BalanceFilterTest.php
git commit -m "fix(lead-router): R-12 — remove balance_leads from eligibility filter

balance_rub is the only balance used after Spec A Phase A.
LeadRouter SQL still referenced legacy balance_leads in OR clause —
would crash on Spec B Phase B DROP COLUMN. Filter now only checks balance_rub."
```

---

## Task 1.3: R-14 — Проверить и зафиксировать прод-версию `RouteSupplierLeadJob`

**Files:**

- Reference: `app/app/Jobs/RouteSupplierLeadJob.php` (после rebase в Task 1.1 должен быть main-версия с DIRECT path).
- Smoke target: `https://liderra.ru/api/webhook/supplier/<secret>` (после merge в main).

- [ ] **Step 1: SSH на прод, проверить файл**

```bash
ssh ubuntu@liderra.ru "head -210 /var/www/liderra/app/app/Jobs/RouteSupplierLeadJob.php | grep -A2 'parseProjectField'"
```

Expected: должна быть main-версия — `if (preg_match('/^(B[123])_(.+)$/', $project, $m) === 1) { $platform = $m[1]; ... } else { $platform = 'DIRECT'; ... }`.

Если присутствует `throw new RuntimeException("Cannot parse...")` — это устаревшая spec-c версия, scp-drift не закрыт. Перейти к Step 2.

Если main-версия — Task 1.3 закрыт, переходить к Task 1.4.

- [ ] **Step 2: Деплой (только если шаг 1 показал spec-c версию)**

```bash
ssh ubuntu@liderra.ru "cd /var/www/liderra && sudo -u www-data bash redeploy.sh"
```

Expected: `git pull` подтягивает main HEAD после merge Task 1.1. Сервисы перезапущены.

- [ ] **Step 3: Smoke-test DIRECT path вживую**

В UI создать тестовый webhook через test-form или curl:

```bash
curl -X POST "https://liderra.ru/api/webhook/supplier/<secret>" \
  -H "Content-Type: application/json" \
  -d '{"vid": 999999, "project": "lkomega.ru", "phone": "79000000001", "time": '$(date +%s)'}'
```

Expected: HTTP 202 с `supplier_lead_id`. Через 30 сек проверить:

```bash
ssh ubuntu@liderra.ru "sudo -u postgres psql -d liderra -c \"SELECT id, vid, platform, processed_at, error FROM supplier_leads WHERE vid=999999\""
```

Expected: row существует, `platform='DIRECT'`, `processed_at` NOT NULL, `error` NULL.

- [ ] **Step 4: Cleanup тестового лида**

```bash
ssh ubuntu@liderra.ru "sudo -u postgres psql -d liderra -c \"DELETE FROM deals WHERE source_crm_id=999999; DELETE FROM supplier_leads WHERE vid=999999\""
```

- [ ] **Step 5: Commit отметки в ПИЛОТ.md**

В `ПИЛОТ.md` строкой сверху: «{date} — R-14 закрыт, prod RouteSupplierLeadJob = main-версия с DIRECT path, smoke на `lkomega.ru` ✅».

```bash
git add ПИЛОТ.md && git commit -m "docs(pilot): R-14 closed, DIRECT path verified live"
```

---

## Task 1.4: R-16 — `CleanupInactiveSupplierProjectsJob` через pivot

**Files:**

- Modify: `app/app/Jobs/Supplier/CleanupInactiveSupplierProjectsJob.php:62-78` (`$activeIdsSubquery`).
- Test: `app/tests/Feature/Supplier/CleanupInactiveOnPivotTest.php` (новый).

- [ ] **Step 1: Создать failing test**

`app/tests/Feature/Supplier/CleanupInactiveOnPivotTest.php`:

```php
<?php

declare(strict_types=1);

use App\Jobs\Supplier\CleanupInactiveSupplierProjectsJob;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;

it('does not mark inactive supplier_project that has pivot link to active project', function () {
    $tenant = Tenant::factory()->create();
    $project = Project::factory()->for($tenant)->create([
        'is_active' => true,
        // легаси FK НЕ заполнены (Plan 3+ архитектура):
        'supplier_b1_project_id' => null,
        'supplier_b2_project_id' => null,
        'supplier_b3_project_id' => null,
    ]);
    $sp = SupplierProject::factory()->create([
        'inactive_since' => null,
    ]);
    \DB::table('project_supplier_links')->insert([
        'project_id' => $project->id,
        'supplier_project_id' => $sp->id,
        'platform' => $sp->platform,
        'subject_code' => null,
    ]);

    (new CleanupInactiveSupplierProjectsJob)->handle(app(\App\Services\Supplier\SupplierPortalClient::class));

    expect($sp->fresh()->inactive_since)->toBeNull();
});

it('marks supplier_project inactive when no pivot link exists', function () {
    $sp = SupplierProject::factory()->create(['inactive_since' => null]);
    // нет project_supplier_links

    (new CleanupInactiveSupplierProjectsJob)->handle(app(\App\Services\Supplier\SupplierPortalClient::class));

    expect($sp->fresh()->inactive_since)->not->toBeNull();
});
```

- [ ] **Step 2: Запустить тест — должен упасть**

```bash
composer test -- --filter=CleanupInactiveOnPivotTest
```

Expected: первый тест FAIL (legacy FK NULL → subquery не находит → проект помечается inactive).

- [ ] **Step 3: Заменить subquery в `handle()`**

`app/app/Jobs/Supplier/CleanupInactiveSupplierProjectsJob.php:62-78`:

```php
// Было:
$activeIdsSubquery = <<<'SQL'
    SELECT DISTINCT id FROM (
        SELECT supplier_b1_project_id AS id FROM projects WHERE is_active=true AND supplier_b1_project_id IS NOT NULL
        UNION SELECT supplier_b2_project_id FROM projects WHERE is_active=true AND supplier_b2_project_id IS NOT NULL
        UNION SELECT supplier_b3_project_id FROM projects WHERE is_active=true AND supplier_b3_project_id IS NOT NULL
    ) AS active_supplier_ids
SQL;

// Стало:
$activeIdsSubquery = <<<'SQL'
    SELECT DISTINCT psl.supplier_project_id AS id
    FROM project_supplier_links psl
    INNER JOIN projects p ON p.id = psl.project_id
    WHERE p.is_active = true
SQL;
```

Inline-комментарий выше subquery дополнить: «Источник истинности активности — `project_supplier_links` pivot (Plan 3+). Legacy FK `supplier_b{1,2,3}_project_id` оставлены для read-compat, но не определяют активность».

- [ ] **Step 4: Запустить тест — должен пройти**

```bash
composer test -- --filter=CleanupInactiveOnPivotTest
```

Expected: оба теста PASS.

- [ ] **Step 5: Полный регресс**

```bash
composer test --parallel
```

Expected: все Pest зелёные.

- [ ] **Step 6: Commit**

```bash
git add app/app/Jobs/Supplier/CleanupInactiveSupplierProjectsJob.php app/tests/Feature/Supplier/CleanupInactiveOnPivotTest.php
git commit -m "fix(supplier): R-16 — cleanup uses pivot, not legacy FK

CleanupInactiveSupplierProjectsJob Phase A/B/C subquery determined
active supplier_projects through legacy supplier_b{1,2,3}_project_id FKs,
which are NULL for Plan 3+ projects (using project_supplier_links pivot).
After 180d TTL these supplier_projects would be deleted from supplier,
breaking real lead flow. Subquery now uses pivot."
```

- [ ] **Step 7: Деплой Этапа 1**

```bash
ssh ubuntu@liderra.ru "cd /var/www/liderra && sudo -u www-data bash redeploy.sh"
```

Expected: после `redeploy.sh` прод-код = main HEAD с патчами R-12 + R-14 (если нужен был) + R-16.

- [ ] **Step 8: Smoke на проде**

```bash
ssh ubuntu@liderra.ru "sudo -u postgres psql -d liderra -c \"SELECT COUNT(*) AS sp_total, COUNT(*) FILTER (WHERE inactive_since IS NULL) AS active, COUNT(*) FILTER (WHERE inactive_since IS NOT NULL) AS marked FROM supplier_projects\""
```

Записать цифры — baseline ДО следующего запуска cleanup-cron в 02:00 МСК. После 02:00 МСК следующих суток — сверить (числа не должны драматически измениться).

- [ ] **Step 9: Этап 1 Acceptance check**

В `ПИЛОТ.md` зафиксировать: «Этап 1 закрыт {date}: R-12 (balance_leads filter) + R-14 (DIRECT path verified) + R-16 (cleanup on pivot). Прод-код = main HEAD `<sha>`».

---

# Этап 2 — Собственный slepok (3–5 рабочих дней)

Главная архитектурная работа. Закрывает R-01, R-02, R-04, R-06, R-07, R-08, R-09, R-10, R-15.

## Task 2.1: Миграция `project_routing_snapshots`

**Files:**

- Create: `app/database/migrations/2026_05_27_120000_create_project_routing_snapshots_table.php`.
- Test: `app/tests/Feature/Migrations/ProjectRoutingSnapshotsTableTest.php` (новый).

- [ ] **Step 1: Создать failing test**

`app/tests/Feature/Migrations/ProjectRoutingSnapshotsTableTest.php`:

```php
<?php

declare(strict_types=1);

use Illuminate\Support\Facades\Schema;

it('has project_routing_snapshots table with composite PK', function () {
    expect(Schema::hasTable('project_routing_snapshots'))->toBeTrue();
    expect(Schema::hasColumns('project_routing_snapshots', [
        'snapshot_date', 'project_id', 'tenant_id',
        'daily_limit', 'delivery_days_mask', 'regions',
        'signal_type', 'signal_identifier', 'sms_senders', 'sms_keyword',
        'expected_volume', 'delivered_count', 'created_at',
    ]))->toBeTrue();
});

it('rejects negative daily_limit / expected_volume / delivered_count', function () {
    \DB::table('project_routing_snapshots')->insert([
        'snapshot_date' => '2026-05-28',
        'project_id' => 1,
        'tenant_id' => 1,
        'daily_limit' => -1, // CHECK violation
        'delivery_days_mask' => 0,
        'regions' => '{}',
        'signal_type' => 'call',
        'expected_volume' => 0,
        'delivered_count' => 0,
        'created_at' => now(),
    ]);
})->throws(\Illuminate\Database\QueryException::class);

it('enforces composite PK (snapshot_date, project_id)', function () {
    $tenant = \App\Models\Tenant::factory()->create();
    $project = \App\Models\Project::factory()->for($tenant)->create();
    \DB::table('project_routing_snapshots')->insert([
        'snapshot_date' => '2026-05-28', 'project_id' => $project->id, 'tenant_id' => $tenant->id,
        'daily_limit' => 10, 'delivery_days_mask' => 127, 'regions' => '{}',
        'signal_type' => 'call', 'expected_volume' => 10, 'delivered_count' => 0,
        'created_at' => now(),
    ]);
    // Дубль — должен упасть на PK violation
    expect(fn () => \DB::table('project_routing_snapshots')->insert([
        'snapshot_date' => '2026-05-28', 'project_id' => $project->id, 'tenant_id' => $tenant->id,
        'daily_limit' => 20, 'delivery_days_mask' => 127, 'regions' => '{}',
        'signal_type' => 'call', 'expected_volume' => 20, 'delivered_count' => 0,
        'created_at' => now(),
    ]))->toThrow(\Illuminate\Database\QueryException::class);
});
```

- [ ] **Step 2: Запустить тесты — должны упасть**

```bash
composer test -- --filter=ProjectRoutingSnapshotsTableTest
```

Expected: все три FAIL (таблицы нет).

- [ ] **Step 3: Создать миграцию**

`app/database/migrations/2026_05_27_120000_create_project_routing_snapshots_table.php`:

```php
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;

return new class extends Migration {
    public function up(): void
    {
        DB::statement('SET ROLE crm_migrator');

        DB::unprepared(<<<'SQL'
            CREATE TABLE project_routing_snapshots (
                snapshot_date      DATE        NOT NULL,
                project_id         BIGINT      NOT NULL,
                tenant_id          BIGINT      NOT NULL,
                daily_limit        INT         NOT NULL CHECK (daily_limit >= 0),
                delivery_days_mask INT         NOT NULL CHECK (delivery_days_mask BETWEEN 0 AND 127),
                regions            INT[]       NOT NULL DEFAULT '{}',
                signal_type        TEXT        NOT NULL CHECK (signal_type IN ('call','site','sms')),
                signal_identifier  TEXT,
                sms_senders        JSONB,
                sms_keyword        TEXT,
                expected_volume    INT         NOT NULL CHECK (expected_volume >= 0),
                delivered_count    INT         NOT NULL DEFAULT 0 CHECK (delivered_count >= 0),
                created_at         TIMESTAMP   NOT NULL DEFAULT NOW(),
                PRIMARY KEY (snapshot_date, project_id),
                FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
                -- NB: НЕ ссылаемся на projects(id) — проект может быть удалён,
                --     а snapshot должен пережить (хвост слепка ещё летит).
            ) PARTITION BY RANGE (snapshot_date);

            CREATE INDEX project_routing_snapshots_tenant_date_idx
                ON project_routing_snapshots (tenant_id, snapshot_date);
            CREATE INDEX project_routing_snapshots_signal_idx
                ON project_routing_snapshots (snapshot_date, signal_type, lower(signal_identifier));

            ALTER TABLE project_routing_snapshots ENABLE ROW LEVEL SECURITY;
            CREATE POLICY project_routing_snapshots_tenant_isolation
                ON project_routing_snapshots
                USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint);

            GRANT SELECT, INSERT, UPDATE ON project_routing_snapshots TO crm_app_user;
            GRANT SELECT, INSERT, UPDATE, DELETE ON project_routing_snapshots TO crm_supplier_worker;

            -- Партиция для текущего месяца (создаётся также через partitions:create-months).
            CREATE TABLE project_routing_snapshots_y2026_m05
                PARTITION OF project_routing_snapshots
                FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
            CREATE TABLE project_routing_snapshots_y2026_m06
                PARTITION OF project_routing_snapshots
                FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
        SQL);

        // Регистрация в retention (system_settings).
        DB::table('system_settings')->insertOrIgnore([
            'key' => 'partition_retention_months_project_routing_snapshots',
            'value' => '3',
            'description' => 'Retention в месяцах для project_routing_snapshots (90 дней)',
            'created_at' => now(),
            'updated_at' => now(),
        ]);
    }

    public function down(): void
    {
        DB::statement('SET ROLE crm_migrator');
        DB::statement('DROP TABLE IF EXISTS project_routing_snapshots CASCADE');
        DB::table('system_settings')->where('key', 'partition_retention_months_project_routing_snapshots')->delete();
    }
};
```

- [ ] **Step 4: Запустить миграцию + тесты**

```bash
cd app
php artisan migrate --env=testing
composer test -- --filter=ProjectRoutingSnapshotsTableTest
```

Expected: миграция OK, тесты PASS.

- [ ] **Step 5: Обновить `partitions:create-months` для новой партиционированной таблицы**

`app/app/Services/MonthlyPartitionManager.php` — добавить `project_routing_snapshots` в массив `PARTITIONED_TABLES` (по аналогии с существующими `deals`, `supplier_lead_costs`).

```php
public const PARTITIONED_TABLES = [
    'deals' => 'received_at',
    'supplier_lead_costs' => 'received_at',
    'project_routing_snapshots' => 'snapshot_date', // ← новое
];
```

Smoke:

```bash
php artisan partitions:create-months --ahead=3 --env=testing
```

Expected: создаются партиции `project_routing_snapshots_y2026_m07/m08/m09`.

- [ ] **Step 6: Обновить `db/schema.sql` header**

Header `db/schema.sql:1-10` пометить новый baseline (одна новая таблица):

```sql
-- v8.39 от 2026-05-27 — +1 partitioned table project_routing_snapshots
```

Записать в `db/CHANGELOG_schema.md` запись о новой таблице.

- [ ] **Step 7: Commit**

```bash
git add app/database/migrations/2026_05_27_120000_create_project_routing_snapshots_table.php
git add app/app/Services/MonthlyPartitionManager.php
git add app/tests/Feature/Migrations/ProjectRoutingSnapshotsTableTest.php
git add db/schema.sql db/CHANGELOG_schema.md
git commit -m "feat(slepok): R-01..R-04 — project_routing_snapshots table + RLS + partitioning

New partitioned table to fix snapshot routing per spec
2026-05-26-slepok-routing-protection-design.md §4.2.1. Composite PK
(snapshot_date, project_id) for idempotency. RLS tenant-isolation,
GRANT crm_app_user + crm_supplier_worker. Retention 90 days via
partition_retention_months. Partitioning by snapshot_date (RANGE per month)."
```

---

## Task 2.2: `SnapshotProjectRoutingJob`

**Files:**

- Create: `app/app/Jobs/SnapshotProjectRoutingJob.php`.
- Test: `app/tests/Feature/Jobs/SnapshotProjectRoutingJobTest.php` (новый).

- [ ] **Step 1: Создать failing test**

`app/tests/Feature/Jobs/SnapshotProjectRoutingJobTest.php`:

```php
<?php

declare(strict_types=1);

use App\Jobs\SnapshotProjectRoutingJob;
use App\Models\Project;
use App\Models\Tenant;
use Carbon\Carbon;

beforeEach(function () {
    Carbon::setTestNow('2026-05-27 18:02:00', 'Europe/Moscow');
});

afterEach(function () {
    Carbon::setTestNow();
});

it('creates snapshot for tomorrow with active projects only', function () {
    $tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
    $active = Project::factory()->for($tenant)->create([
        'is_active' => true,
        'delivery_days_mask' => 127,
        'daily_limit_target' => 10,
        'preflight_blocked_at' => null,
    ]);
    $inactive = Project::factory()->for($tenant)->create([
        'is_active' => false,
        'delivery_days_mask' => 127,
    ]);

    (new SnapshotProjectRoutingJob)->handle();

    $rows = \DB::table('project_routing_snapshots')
        ->where('snapshot_date', '2026-05-28')->get();
    expect($rows)->toHaveCount(1);
    expect($rows->first()->project_id)->toBe($active->id);
});

it('excludes frozen tenants', function () {
    $tenant = Tenant::factory()->create(['frozen_by_balance_at' => now()]);
    Project::factory()->for($tenant)->create([
        'is_active' => true, 'delivery_days_mask' => 127, 'daily_limit_target' => 10,
    ]);

    (new SnapshotProjectRoutingJob)->handle();

    expect(\DB::table('project_routing_snapshots')->count())->toBe(0);
});

it('excludes preflight_blocked projects', function () {
    $tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
    Project::factory()->for($tenant)->create([
        'is_active' => true, 'delivery_days_mask' => 127, 'daily_limit_target' => 10,
        'preflight_blocked_at' => now(),
    ]);

    (new SnapshotProjectRoutingJob)->handle();

    expect(\DB::table('project_routing_snapshots')->count())->toBe(0);
});

it('excludes projects whose days_mask does not match tomorrow', function () {
    // 2026-05-28 — четверг (isoWeekday=4 → bit 1<<3 = 8). Тест: mask=4 (только среда).
    $tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
    Project::factory()->for($tenant)->create([
        'is_active' => true,
        'delivery_days_mask' => 4, // только среда
        'daily_limit_target' => 10,
    ]);

    (new SnapshotProjectRoutingJob)->handle();

    expect(\DB::table('project_routing_snapshots')->count())->toBe(0);
});

it('uses effective_daily_limit_today as daily_limit when set (R-11/OPEN-5 vatiant A)', function () {
    $tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
    Project::factory()->for($tenant)->create([
        'is_active' => true, 'delivery_days_mask' => 127,
        'daily_limit_target' => 10,
        'effective_daily_limit_today' => 3, // override
    ]);

    (new SnapshotProjectRoutingJob)->handle();

    $row = \DB::table('project_routing_snapshots')->first();
    expect($row->daily_limit)->toBe(3);
});

it('is idempotent — second run does not duplicate', function () {
    $tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
    Project::factory()->for($tenant)->create([
        'is_active' => true, 'delivery_days_mask' => 127, 'daily_limit_target' => 10,
    ]);

    (new SnapshotProjectRoutingJob)->handle();
    (new SnapshotProjectRoutingJob)->handle();

    expect(\DB::table('project_routing_snapshots')->count())->toBe(1);
});
```

- [ ] **Step 2: Запустить — должны упасть**

```bash
composer test -- --filter=SnapshotProjectRoutingJobTest
```

Expected: все FAIL — класса нет.

- [ ] **Step 3: Создать класс**

`app/app/Jobs/SnapshotProjectRoutingJob.php`:

```php
<?php

declare(strict_types=1);

namespace App\Jobs;

use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

/**
 * Daily 18:02 МСК snapshot — фиксирует состояние всех eligible Лидерра-проектов
 * на завтрашний день (slepok №NЛ по канону спека §0).
 *
 * Запускается ПОСЛЕ billing:preflight-sweep (18:00) чтобы подхватить актуальный
 * tenants.frozen_by_balance_at, и ДО SyncSupplierProjectsJob (18:05) чтобы тот
 * пушил поставщику ровно этот snapshot.
 *
 * Идемпотентность: PK (snapshot_date, project_id) + ON CONFLICT DO NOTHING.
 *
 * Spec: docs/superpowers/specs/2026-05-26-slepok-routing-protection-design.md §4.2.2.
 */
final class SnapshotProjectRoutingJob implements ShouldQueue
{
    use Dispatchable, Queueable, InteractsWithQueue, SerializesModels;

    public const DB_CONNECTION = 'pgsql_supplier'; // BYPASSRLS

    public function handle(): void
    {
        $snapshotDate = Carbon::tomorrow('Europe/Moscow')->toDateString();
        $weekdayBit = 1 << (Carbon::tomorrow('Europe/Moscow')->isoWeekday() - 1);

        DB::connection(self::DB_CONNECTION)->transaction(function () use ($snapshotDate, $weekdayBit) {
            $exists = DB::connection(self::DB_CONNECTION)
                ->table('project_routing_snapshots')
                ->where('snapshot_date', $snapshotDate)
                ->exists();
            if ($exists) {
                Log::info('snapshot.already_exists', ['date' => $snapshotDate]);
                return;
            }

            $count = DB::connection(self::DB_CONNECTION)->insert(<<<SQL
                INSERT INTO project_routing_snapshots (
                    snapshot_date, project_id, tenant_id,
                    daily_limit, delivery_days_mask, regions,
                    signal_type, signal_identifier, sms_senders, sms_keyword,
                    expected_volume
                )
                SELECT
                    ?::date,
                    p.id, p.tenant_id,
                    COALESCE(p.effective_daily_limit_today, p.daily_limit_target),
                    p.delivery_days_mask,
                    p.regions,
                    p.signal_type, p.signal_identifier, p.sms_senders, p.sms_keyword,
                    COALESCE(p.effective_daily_limit_today, p.daily_limit_target)
                FROM projects p
                INNER JOIN tenants t ON t.id = p.tenant_id
                WHERE p.is_active = true
                  AND (p.delivery_days_mask & ?::int) <> 0
                  AND p.preflight_blocked_at IS NULL
                  AND t.frozen_by_balance_at IS NULL
                  AND t.deleted_at IS NULL
                ON CONFLICT (snapshot_date, project_id) DO NOTHING
            SQL, [$snapshotDate, $weekdayBit]);

            Log::info('snapshot.created', ['date' => $snapshotDate, 'rows' => $count]);
        });
    }
}
```

- [ ] **Step 4: Запустить тесты — должны пройти**

```bash
composer test -- --filter=SnapshotProjectRoutingJobTest
```

Expected: все 6 PASS.

- [ ] **Step 5: Commit**

```bash
git add app/app/Jobs/SnapshotProjectRoutingJob.php app/tests/Feature/Jobs/SnapshotProjectRoutingJobTest.php
git commit -m "feat(slepok): SnapshotProjectRoutingJob — daily 18:02 MSK snapshot

Captures is_active + days_mask + regions + daily_limit (with override) into
project_routing_snapshots for tomorrow's date. Filters frozen tenants and
preflight_blocked projects. Idempotent via ON CONFLICT DO NOTHING.
Spec §4.2.2. R-11/OPEN-5 closed: effective_daily_limit_today carried into snapshot."
```

---

## Task 2.3: `SnapshotBackfillCommand`

**Files:**

- Create: `app/app/Console/Commands/SnapshotBackfillCommand.php`.
- Test: `app/tests/Feature/Console/SnapshotBackfillCommandTest.php` (новый).

- [ ] **Step 1: Failing test**

`app/tests/Feature/Console/SnapshotBackfillCommandTest.php`:

```php
<?php

declare(strict_types=1);

use App\Models\Project;
use App\Models\Tenant;
use Carbon\Carbon;

it('creates snapshot for given date from current live state', function () {
    Carbon::setTestNow('2026-05-27 14:00:00', 'Europe/Moscow');
    $tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
    Project::factory()->for($tenant)->create([
        'is_active' => true, 'delivery_days_mask' => 127, 'daily_limit_target' => 10,
    ]);

    $this->artisan('snapshot:backfill', ['--date' => '2026-05-27'])
        ->assertSuccessful();

    expect(\DB::table('project_routing_snapshots')->where('snapshot_date', '2026-05-27')->count())->toBe(1);
    Carbon::setTestNow();
});

it('is idempotent — does not duplicate on re-run', function () {
    Carbon::setTestNow('2026-05-27 14:00:00', 'Europe/Moscow');
    $tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
    Project::factory()->for($tenant)->create([
        'is_active' => true, 'delivery_days_mask' => 127, 'daily_limit_target' => 10,
    ]);

    $this->artisan('snapshot:backfill', ['--date' => '2026-05-27'])->assertSuccessful();
    $this->artisan('snapshot:backfill', ['--date' => '2026-05-27'])->assertSuccessful();

    expect(\DB::table('project_routing_snapshots')->count())->toBe(1);
    Carbon::setTestNow();
});
```

- [ ] **Step 2: Запустить — FAIL**

```bash
composer test -- --filter=SnapshotBackfillCommandTest
```

- [ ] **Step 3: Реализация**

`app/app/Console/Commands/SnapshotBackfillCommand.php`:

```php
<?php

declare(strict_types=1);

namespace App\Console\Commands;

use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

/**
 * Создаёт project_routing_snapshots за указанную дату из текущего live-состояния.
 * Используется один раз при выкатке Этапа 2 + для ручного recovery после падения cron'а.
 *
 * Spec §4.2.6.
 */
final class SnapshotBackfillCommand extends Command
{
    protected $signature = 'snapshot:backfill {--date= : YYYY-MM-DD, по умолчанию сегодня}';

    protected $description = 'Заполнить project_routing_snapshots за указанную дату из live projects';

    public function handle(): int
    {
        $dateStr = (string) ($this->option('date') ?? Carbon::today('Europe/Moscow')->toDateString());
        $date = Carbon::parse($dateStr, 'Europe/Moscow');
        $weekdayBit = 1 << ($date->isoWeekday() - 1);

        $count = DB::connection('pgsql_supplier')->transaction(function () use ($dateStr, $weekdayBit) {
            return DB::connection('pgsql_supplier')->insert(<<<SQL
                INSERT INTO project_routing_snapshots (
                    snapshot_date, project_id, tenant_id,
                    daily_limit, delivery_days_mask, regions,
                    signal_type, signal_identifier, sms_senders, sms_keyword,
                    expected_volume
                )
                SELECT
                    ?::date,
                    p.id, p.tenant_id,
                    COALESCE(p.effective_daily_limit_today, p.daily_limit_target),
                    p.delivery_days_mask, p.regions,
                    p.signal_type, p.signal_identifier, p.sms_senders, p.sms_keyword,
                    COALESCE(p.effective_daily_limit_today, p.daily_limit_target)
                FROM projects p
                INNER JOIN tenants t ON t.id = p.tenant_id
                WHERE p.is_active = true
                  AND (p.delivery_days_mask & ?::int) <> 0
                  AND p.preflight_blocked_at IS NULL
                  AND t.frozen_by_balance_at IS NULL
                  AND t.deleted_at IS NULL
                ON CONFLICT (snapshot_date, project_id) DO NOTHING
            SQL, [$dateStr, $weekdayBit]);
        });

        $this->info("Snapshot backfilled for {$dateStr}: {$count} rows.");
        Log::info('snapshot.backfill', ['date' => $dateStr, 'rows' => $count]);
        return self::SUCCESS;
    }
}
```

- [ ] **Step 4: Зарегистрировать команду**

Laravel auto-discovery её подхватит из `app/app/Console/Commands/`. Проверить:

```bash
php artisan list | grep snapshot
```

Expected: видим `snapshot:backfill`.

- [ ] **Step 5: Тесты PASS**

```bash
composer test -- --filter=SnapshotBackfillCommandTest
```

Expected: оба PASS.

- [ ] **Step 6: Commit**

```bash
git add app/app/Console/Commands/SnapshotBackfillCommand.php app/tests/Feature/Console/SnapshotBackfillCommandTest.php
git commit -m "feat(slepok): snapshot:backfill artisan command

One-time use at Stage 2 deploy + manual recovery if cron fails.
Idempotent via ON CONFLICT DO NOTHING. Spec §4.2.6."
```

---

## Task 2.4: Cron в `routes/console.php`

**Files:**

- Modify: `app/routes/console.php` — добавить Schedule entry.

- [ ] **Step 1: Добавить Schedule entry**

После строки 65 (после `partitions:drop-expired`) и до Plan 3 Task 8 (line 67) — добавить:

```php
// Spec 2026-05-26-slepok-routing-protection §4.2.2:
// SnapshotProjectRoutingJob создаёт slepok №NЛ для дня N+1 в 18:02 МСК.
// Запускается ПОСЛЕ billing:preflight-sweep (18:00) и ДО SyncSupplierProjectsJob (18:05).
Schedule::job(new \App\Jobs\SnapshotProjectRoutingJob)
    ->dailyAt('18:02')
    ->timezone('Europe/Moscow')
    ->before(fn () => $startTimes['SnapshotProjectRoutingJob'] = microtime(true))
    ->onSuccess(function () use ($hb, &$startTimes): void {
        $name = 'SnapshotProjectRoutingJob';
        $ms = isset($startTimes[$name]) ? (int) ((microtime(true) - $startTimes[$name]) * 1000) : null;
        $hb->recordRunResult($name, true, null, $ms);
    })
    ->onFailure(fn () => $hb->recordRunResult('SnapshotProjectRoutingJob', false, 'Job failed', null));
```

- [ ] **Step 2: Smoke test — schedule:list**

```bash
php artisan schedule:list
```

Expected: видим `SnapshotProjectRoutingJob` запланированным на 18:02 МСК ежедневно.

- [ ] **Step 3: Commit**

```bash
git add app/routes/console.php
git commit -m "feat(slepok): register SnapshotProjectRoutingJob cron @ 18:02 MSK

Between billing:preflight-sweep (18:00) and SyncSupplierProjectsJob (18:05).
Heartbeat through SchedulerHeartbeatTracker. Spec §4.2.2."
```

---

## Task 2.5: `LeadRouter` SQL переключение на snapshot

**Files:**

- Modify: `app/app/Services/LeadRouter.php` — переписать SQL (B1/B2/B3 + DIRECT paths).
- Test: `app/tests/Feature/LeadRouter/SnapshotRoutingTest.php` (новый).

- [ ] **Step 1: Failing test**

`app/tests/Feature/LeadRouter/SnapshotRoutingTest.php`:

```php
<?php

declare(strict_types=1);

use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\LeadRouter;
use Carbon\Carbon;

it('uses snapshot before 21:00 MSK, snapshot_date = today', function () {
    Carbon::setTestNow('2026-05-28 12:00:00', 'Europe/Moscow');

    $tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'frozen_by_balance_at' => null]);
    $project = Project::factory()->for($tenant)->create([
        'is_active' => false, // ЖИВОЕ состояние — paused
        'delivery_days_mask' => 127,
        'daily_limit_target' => 100,
        'delivered_today' => 0,
    ]);
    $sp = SupplierProject::factory()->create();
    \DB::table('project_supplier_links')->insert([
        'project_id' => $project->id, 'supplier_project_id' => $sp->id,
        'platform' => $sp->platform, 'subject_code' => null,
    ]);
    // SNAPSHOT за сегодня имеет проект → роутер должен вернуть, несмотря на is_active=false
    \DB::table('project_routing_snapshots')->insert([
        'snapshot_date' => '2026-05-28', 'project_id' => $project->id, 'tenant_id' => $tenant->id,
        'daily_limit' => 10, 'delivery_days_mask' => 127, 'regions' => '{}',
        'signal_type' => 'call', 'expected_volume' => 10, 'delivered_count' => 0,
        'created_at' => now(),
    ]);

    $matched = app(LeadRouter::class)->matchEligibleProjects($sp);

    expect($matched)->toHaveCount(1); // ← это R-01 closure
    Carbon::setTestNow();
});

it('uses snapshot after 21:00 MSK, snapshot_date = tomorrow', function () {
    Carbon::setTestNow('2026-05-28 22:00:00', 'Europe/Moscow');

    $tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'frozen_by_balance_at' => null]);
    $project = Project::factory()->for($tenant)->create([
        'is_active' => true, 'delivery_days_mask' => 127,
        'daily_limit_target' => 100, 'delivered_today' => 0,
    ]);
    $sp = SupplierProject::factory()->create();
    \DB::table('project_supplier_links')->insert([
        'project_id' => $project->id, 'supplier_project_id' => $sp->id,
        'platform' => $sp->platform, 'subject_code' => null,
    ]);
    // Snapshot за СЕГОДНЯ (2026-05-28) НЕТ.
    // Snapshot за ЗАВТРА (2026-05-29) есть.
    \DB::table('project_routing_snapshots')->insert([
        'snapshot_date' => '2026-05-29', 'project_id' => $project->id, 'tenant_id' => $tenant->id,
        'daily_limit' => 10, 'delivery_days_mask' => 127, 'regions' => '{}',
        'signal_type' => 'call', 'expected_volume' => 10, 'delivered_count' => 0,
        'created_at' => now(),
    ]);

    $matched = app(LeadRouter::class)->matchEligibleProjects($sp);

    expect($matched)->toHaveCount(1); // после 21:00 МСК активен завтрашний snapshot
    Carbon::setTestNow();
});

it('returns 0 if no snapshot exists for active date', function () {
    Carbon::setTestNow('2026-05-28 12:00:00', 'Europe/Moscow');

    $tenant = Tenant::factory()->create(['balance_rub' => '500.00']);
    $project = Project::factory()->for($tenant)->create([
        'is_active' => true, 'delivery_days_mask' => 127, 'daily_limit_target' => 10,
    ]);
    $sp = SupplierProject::factory()->create();
    \DB::table('project_supplier_links')->insert([
        'project_id' => $project->id, 'supplier_project_id' => $sp->id,
        'platform' => $sp->platform, 'subject_code' => null,
    ]);
    // НЕТ snapshot за 2026-05-28.

    $matched = app(LeadRouter::class)->matchEligibleProjects($sp);

    expect($matched)->toHaveCount(0); // fail-loud, не fallback на live
    Carbon::setTestNow();
});

it('limit comes from snapshot, not live projects.daily_limit_target', function () {
    Carbon::setTestNow('2026-05-28 12:00:00', 'Europe/Moscow');

    $tenant = Tenant::factory()->create(['balance_rub' => '500.00']);
    $project = Project::factory()->for($tenant)->create([
        'is_active' => true, 'delivery_days_mask' => 127,
        'daily_limit_target' => 100, // живой лимит
        'delivered_today' => 7,
    ]);
    $sp = SupplierProject::factory()->create();
    \DB::table('project_supplier_links')->insert([
        'project_id' => $project->id, 'supplier_project_id' => $sp->id,
        'platform' => $sp->platform, 'subject_code' => null,
    ]);
    \DB::table('project_routing_snapshots')->insert([
        'snapshot_date' => '2026-05-28', 'project_id' => $project->id, 'tenant_id' => $tenant->id,
        'daily_limit' => 5, // ← snapshot лимит МЕНЬШЕ чем delivered_today=7
        'delivery_days_mask' => 127, 'regions' => '{}',
        'signal_type' => 'call', 'expected_volume' => 5, 'delivered_count' => 0,
        'created_at' => now(),
    ]);

    $matched = app(LeadRouter::class)->matchEligibleProjects($sp);

    expect($matched)->toHaveCount(0); // delivered_today=7 >= snapshot.daily_limit=5
    Carbon::setTestNow();
});
```

- [ ] **Step 2: Запустить — должны упасть**

```bash
composer test -- --filter=SnapshotRoutingTest
```

Expected: FAIL (LeadRouter ещё читает live).

- [ ] **Step 3: Переписать SQL B1/B2/B3 path**

`app/app/Services/LeadRouter.php` — заменить SQL внутри `matchEligibleProjects` (lines 83-104 для B-platforms):

```php
$sql = <<<'SQL'
    WITH active_slepok_date AS (
        SELECT
            CASE
                WHEN EXTRACT(HOUR FROM (NOW() AT TIME ZONE 'Europe/Moscow')) >= 21
                    THEN ((NOW() AT TIME ZONE 'Europe/Moscow')::date + INTERVAL '1 day')::date
                ELSE (NOW() AT TIME ZONE 'Europe/Moscow')::date
            END AS d
    )
    SELECT DISTINCT ON (snap.tenant_id)
        projects.*,
        snap.daily_limit AS snapshot_daily_limit
    FROM project_routing_snapshots snap
    INNER JOIN projects ON projects.id = snap.project_id
    WHERE snap.snapshot_date = (SELECT d FROM active_slepok_date)
      AND EXISTS (
          SELECT 1 FROM project_supplier_links psl
          WHERE psl.project_id = snap.project_id
            AND psl.supplier_project_id = ?
      )
      AND projects.delivered_today < snap.daily_limit
      AND EXISTS (
          SELECT 1 FROM tenants
          WHERE tenants.id = snap.tenant_id
            AND tenants.balance_rub > 0
      )
    ORDER BY snap.tenant_id,
             (snap.daily_limit - projects.delivered_today) DESC,
             projects.created_at,
             projects.id
SQL;
$rows = DB::connection('pgsql_supplier')->select($sql, [$supplierProject->id]);

// Fail-loud если snapshot за активную дату отсутствует:
if ($rows === [] && DB::connection('pgsql_supplier')
        ->table('project_routing_snapshots')
        ->where('snapshot_date', $this->activeSnapshotDate())
        ->doesntExist()) {
    Log::error('lead_router.no_snapshot_for_active_date', [
        'active_date' => $this->activeSnapshotDate(),
        'supplier_project_id' => $supplierProject->id,
    ]);
}
return Project::hydrate($rows)->values();
```

И обновить DIRECT-path аналогично (заменить `$directSql` lines 55-77).

Добавить приватный метод:

```php
private function activeSnapshotDate(): string
{
    $msk = Carbon::now('Europe/Moscow');
    return $msk->hour >= 21
        ? $msk->copy()->addDay()->toDateString()
        : $msk->toDateString();
}
```

Убрать `Carbon::now('Europe/Moscow')->isoWeekday()` мёртвую переменную `$todayBit` (она больше не нужна — workday-фильтр через snapshot).

- [ ] **Step 4: Запустить — PASS**

```bash
composer test -- --filter=SnapshotRoutingTest
```

Expected: 4 теста PASS.

- [ ] **Step 5: Полный регресс**

```bash
composer test --parallel
```

Expected: все Pest зелёные. **NB:** некоторые feature-тесты могут оказаться сломаны, если они полагались на live-routing. Каждый такой тест надо пройти и добавить вызов `php artisan snapshot:backfill --date={today}` в `beforeEach()` (или factory-helper создающий snapshot).

- [ ] **Step 6: Commit**

```bash
git add app/app/Services/LeadRouter.php app/tests/Feature/LeadRouter/SnapshotRoutingTest.php
git commit -m "feat(slepok): R-01..R-04+R-06..R-10+R-15 — LeadRouter reads from snapshot

LeadRouter SQL переписан на JOIN с project_routing_snapshots по active_slepok_date
(до 21:00 МСК = today, после 21:00 МСК = today+1). is_active / delivery_days_mask /
daily_limit / regions берутся из snapshot. Баланс остаётся live (auto-pause defence).
Fail-loud: Log::error если snapshot отсутствует за активную дату.
Spec §4.2.3. Closes R-01, R-04, R-06, R-07, R-08, R-15. Partial: R-02 (через шеринг),
R-09 (race), R-10 (editable identifier) — закрываются последующими тасками."
```

---

## Task 2.6: `RouteSupplierLeadJob` — snapshot recheck + is_active recheck

**Files:**

- Modify: `app/app/Jobs/RouteSupplierLeadJob.php:212-386` — `createDealCopyForProject`.
- Test: `app/tests/Feature/Jobs/RouteSupplierLeadJobSnapshotTest.php` (новый).

- [ ] **Step 1: Failing test**

`app/tests/Feature/Jobs/RouteSupplierLeadJobSnapshotTest.php`:

```php
<?php

declare(strict_types=1);

use App\Jobs\RouteSupplierLeadJob;
use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\Tenant;
use Carbon\Carbon;

it('uses snapshot limit not live daily_limit_target', function () {
    Carbon::setTestNow('2026-05-28 12:00:00', 'Europe/Moscow');
    $tenant = Tenant::factory()->create(['balance_rub' => '500.00']);
    $project = Project::factory()->for($tenant)->create([
        'is_active' => true, 'delivery_days_mask' => 127,
        'daily_limit_target' => 100, 'delivered_today' => 4,
    ]);
    $sp = SupplierProject::factory()->create();
    \DB::table('project_supplier_links')->insert([
        'project_id' => $project->id, 'supplier_project_id' => $sp->id,
        'platform' => $sp->platform, 'subject_code' => null,
    ]);
    \DB::table('project_routing_snapshots')->insert([
        'snapshot_date' => '2026-05-28', 'project_id' => $project->id, 'tenant_id' => $tenant->id,
        'daily_limit' => 5, // ← маленький snapshot лимит, осталось 1
        'delivery_days_mask' => 127, 'regions' => '{}', 'signal_type' => 'call',
        'expected_volume' => 5, 'delivered_count' => 4, 'created_at' => now(),
    ]);

    $lead = SupplierLead::factory()->create([
        'supplier_project_id' => $sp->id,
        'raw_payload' => ['project' => 'B1_79161234567'],
        'phone' => '79161234567', 'vid' => 1,
    ]);

    (new RouteSupplierLeadJob($lead->id))->handle(...);

    expect(\DB::table('deals')->count())->toBe(1); // 1 deal, осталось 0 quota
    expect(\DB::table('project_routing_snapshots')->where('project_id', $project->id)->first()->delivered_count)->toBe(5);
    Carbon::setTestNow();
});

it('rejects lead when is_active becomes false under lock (R-09)', function () {
    // Реальный race трудно воспроизвести в unit; имитируем через прямой UPDATE между matchEligible и handle.
    Carbon::setTestNow('2026-05-28 12:00:00', 'Europe/Moscow');
    $tenant = Tenant::factory()->create(['balance_rub' => '500.00']);
    $project = Project::factory()->for($tenant)->create([
        'is_active' => false, // ← paused вживую
        'delivery_days_mask' => 127, 'daily_limit_target' => 10,
    ]);
    $sp = SupplierProject::factory()->create();
    \DB::table('project_supplier_links')->insert([
        'project_id' => $project->id, 'supplier_project_id' => $sp->id,
        'platform' => $sp->platform, 'subject_code' => null,
    ]);
    \DB::table('project_routing_snapshots')->insert([
        'snapshot_date' => '2026-05-28', 'project_id' => $project->id, 'tenant_id' => $tenant->id,
        'daily_limit' => 10, 'delivery_days_mask' => 127, 'regions' => '{}',
        'signal_type' => 'call', 'expected_volume' => 10, 'delivered_count' => 0,
        'created_at' => now(),
    ]);
    $lead = SupplierLead::factory()->create([
        'supplier_project_id' => $sp->id,
        'raw_payload' => ['project' => 'B1_79161234567'],
        'phone' => '79161234567', 'vid' => 2,
    ]);

    (new RouteSupplierLeadJob($lead->id))->handle(...);

    // Snapshot говорит «доставлять», но проект live-paused — НЕ доставляем под lock.
    expect(\DB::table('deals')->count())->toBe(0);
    Carbon::setTestNow();
});
```

NB: `handle(...)` — full DI signature, см. RouteSupplierLeadJob.php:83-90. В тесте mock или fake'нуть нужно по существующей конвенции.

- [ ] **Step 2: FAIL**

```bash
composer test -- --filter=RouteSupplierLeadJobSnapshotTest
```

- [ ] **Step 3: Применить правки в `createDealCopyForProject`**

`app/app/Jobs/RouteSupplierLeadJob.php` — после `lockForUpdate(Project)` (line 235):

```php
$lockedProject = Project::query()
    ->whereKey($project->id)
    ->lockForUpdate()
    ->firstOrFail();

// R-09: recheck is_active под lock'ом — между matchEligibleProjects и здесь
// клиент мог нажать «пауза» (live state, не slepok).
if (!$lockedProject->is_active) {
    Log::info('supplier_lead.project_paused_under_lock', [
        'supplier_lead_id' => $lead->id,
        'project_id' => $lockedProject->id,
    ]);
    return false;
}

// R-04 + R-06: лимит из snapshot, не live (slepok-инвариант).
$msk = Carbon::now('Europe/Moscow');
$activeDate = $msk->hour >= 21 ? $msk->copy()->addDay()->toDateString() : $msk->toDateString();
$snapshot = DB::connection('pgsql_supplier')
    ->table('project_routing_snapshots')
    ->where('snapshot_date', $activeDate)
    ->where('project_id', $lockedProject->id)
    ->lockForUpdate()
    ->first();
if ($snapshot === null) {
    Log::info('supplier_lead.no_snapshot_skipped', [
        'supplier_lead_id' => $lead->id,
        'project_id' => $lockedProject->id,
        'active_date' => $activeDate,
    ]);
    return false;
}
$effectiveLimit = (int) $snapshot->daily_limit;

if ($lockedProject->delivered_today >= $effectiveLimit) {
    Log::info('supplier_lead.project_at_limit_skipped', [
        'supplier_lead_id' => $lead->id,
        'project_id' => $lockedProject->id,
        'tenant_id' => $tenant->id,
        'delivered_today' => $lockedProject->delivered_today,
        'effective_limit' => $effectiveLimit,
    ]);
    return false;
}
$project = $lockedProject;
```

И после успешного `$project->increment('delivered_today')` (line 350) — добавить инкремент snapshot:

```php
$project->increment('delivered_today');
$project->increment('delivered_in_month');

// Инкремент в snapshot — атомарный, lockForUpdate не нужен (UPDATE col = col + 1).
DB::connection('pgsql_supplier')
    ->table('project_routing_snapshots')
    ->where('snapshot_date', $activeDate)
    ->where('project_id', $project->id)
    ->increment('delivered_count');
```

- [ ] **Step 4: PASS**

```bash
composer test -- --filter=RouteSupplierLeadJobSnapshotTest
```

- [ ] **Step 5: Полный регресс**

```bash
composer test --parallel
```

- [ ] **Step 6: Commit**

```bash
git add app/app/Jobs/RouteSupplierLeadJob.php app/tests/Feature/Jobs/RouteSupplierLeadJobSnapshotTest.php
git commit -m "feat(slepok): RouteSupplierLeadJob — snapshot lock + is_active recheck

R-04 closure: limit from snapshot.daily_limit (not live).
R-09 closure: is_active recheck under lockForUpdate(Project).
+ atomic increment of snapshot.delivered_count for CSV business-drift reconcile.
Spec §4.2.4."
```

---

## Task 2.7: `SupplierSnapshotGuard::appliesFrom()`

**Files:**

- Modify: `app/app/Services/Project/SupplierSnapshotGuard.php`.
- Test: `app/tests/Unit/Services/Project/SupplierSnapshotGuardAppliesFromTest.php` (новый).

- [ ] **Step 1: Failing test**

`app/tests/Unit/Services/Project/SupplierSnapshotGuardAppliesFromTest.php`:

```php
<?php

declare(strict_types=1);

use App\Models\Project;
use App\Services\Project\SupplierSnapshotGuard;
use Carbon\CarbonImmutable;

it('returns N.21:00 MSK for edit before 18:00 MSK', function () {
    \Carbon\Carbon::setTestNow('2026-05-28 14:00:00', 'Europe/Moscow');
    $project = Project::factory()->make();
    \DB::table('project_supplier_links')->insert([
        'project_id' => $project->id ?? 1,
        'supplier_project_id' => 1, 'platform' => 'B1', 'subject_code' => null,
    ]);
    $applies = (new SupplierSnapshotGuard)->appliesFrom($project, 'daily_limit_target');
    expect($applies->format('Y-m-d H:i'))->toBe('2026-05-28 21:00');
    \Carbon\Carbon::setTestNow();
});

it('returns (N+1).21:00 MSK for edit after 18:00 MSK', function () {
    \Carbon\Carbon::setTestNow('2026-05-28 19:00:00', 'Europe/Moscow');
    $project = Project::factory()->make();
    \DB::table('project_supplier_links')->insert([
        'project_id' => $project->id ?? 1, 'supplier_project_id' => 1, 'platform' => 'B1', 'subject_code' => null,
    ]);
    $applies = (new SupplierSnapshotGuard)->appliesFrom($project, 'daily_limit_target');
    expect($applies->format('Y-m-d H:i'))->toBe('2026-05-29 21:00');
    \Carbon\Carbon::setTestNow();
});

it('returns null for non-slepok field (e.g. name)', function () {
    $project = Project::factory()->make();
    $applies = (new SupplierSnapshotGuard)->appliesFrom($project, 'name');
    expect($applies)->toBeNull();
});

it('returns null when project has no pivot links', function () {
    $project = Project::factory()->make();
    // нет project_supplier_links для этого проекта
    $applies = (new SupplierSnapshotGuard)->appliesFrom($project, 'daily_limit_target');
    expect($applies)->toBeNull();
});
```

- [ ] **Step 2: FAIL**

```bash
composer test -- --filter=SupplierSnapshotGuardAppliesFromTest
```

- [ ] **Step 3: Расширить `SupplierSnapshotGuard.php`**

Добавить метод после `assertCanMutateSource`:

```php
/**
 * Возвращает момент, с которого правка указанного slepok-sensitive поля
 * вступит в силу (по канону §0 спека: правка до 18:00 МСК → сегодня 21:00 МСК,
 * после 18:00 МСК → завтра 21:00 МСК). NULL = немедленно (поле не slepok-sensitive
 * или нет pivot links к поставщику).
 */
public function appliesFrom(Project $project, string $field): ?CarbonImmutable
{
    $isSlepokSensitive = in_array($field, [
        'is_active', 'daily_limit_target', 'delivery_days_mask', 'regions',
        'signal_identifier', 'sms_senders', 'sms_keyword',
    ], true);
    if (!$isSlepokSensitive) {
        return null;
    }

    $hasLinks = DB::table('project_supplier_links')
        ->where('project_id', $project->id)
        ->exists();
    if (!$hasLinks) {
        return null;
    }

    $nowMsk = CarbonImmutable::now('Europe/Moscow');
    $todayCutoff = $nowMsk->setTime(18, 0, 0);
    if ($nowMsk->gte($todayCutoff)) {
        return $nowMsk->addDay()->setTime(21, 0, 0);
    }
    return $nowMsk->setTime(21, 0, 0);
}
```

- [ ] **Step 4: PASS**

```bash
composer test -- --filter=SupplierSnapshotGuardAppliesFromTest
```

- [ ] **Step 5: Commit**

```bash
git add app/app/Services/Project/SupplierSnapshotGuard.php app/tests/Unit/Services/Project/SupplierSnapshotGuardAppliesFromTest.php
git commit -m "feat(snapshot-guard): appliesFrom() API for UI date display

Returns CarbonImmutable when edit will take effect (slepok start, 21:00 MSK).
Used by ProjectService to attach applies_at to UI response.
6 slepok-sensitive fields. Spec §4.2.5."
```

---

## Task 2.8: `ProjectService` интеграция с `appliesFrom`

**Files:**

- Modify: `app/app/Services/Project/ProjectService.php` — после `update()` и `bulkPauseResume()` возвращать `applies_from`.
- Test: `app/tests/Feature/Services/Project/ProjectServiceAppliesFromTest.php` (новый).

- [ ] **Step 1: Failing test**

```php
<?php

declare(strict_types=1);

use App\Models\Project;
use App\Models\Tenant;
use App\Services\Project\ProjectService;

it('returns applies_from when changing daily_limit_target', function () {
    \Carbon\Carbon::setTestNow('2026-05-28 14:00:00', 'Europe/Moscow');
    $tenant = Tenant::factory()->create();
    $project = Project::factory()->for($tenant)->create();
    \DB::table('project_supplier_links')->insert([
        'project_id' => $project->id, 'supplier_project_id' => \App\Models\SupplierProject::factory()->create()->id,
        'platform' => 'B1', 'subject_code' => null,
    ]);

    $result = app(ProjectService::class)->update($project, ['daily_limit_target' => 5]);

    expect($result->applies_from)->toBeInstanceOf(\Carbon\CarbonImmutable::class);
    expect($result->applies_from->format('Y-m-d H:i'))->toBe('2026-05-28 21:00');
    \Carbon\Carbon::setTestNow();
});
```

- [ ] **Step 2: FAIL → Step 3: Реализовать**

В `ProjectService::update()` после `$project->update($data)` (line 84) — для каждого изменённого slepok-sensitive поля вычислить максимальный `applies_from`:

```php
$slepokSensitive = ['is_active', 'daily_limit_target', 'delivery_days_mask', 'regions',
                    'signal_identifier', 'sms_senders', 'sms_keyword'];
$appliesFrom = null;
foreach ($slepokSensitive as $field) {
    if (array_key_exists($field, $data)) {
        $candidate = $this->snapshotGuard->appliesFrom($project, $field);
        if ($candidate !== null && ($appliesFrom === null || $candidate->gt($appliesFrom))) {
            $appliesFrom = $candidate;
        }
    }
}
$fresh = $project->fresh();
$fresh->applies_from = $appliesFrom; // dynamic attribute, не в БД
return $fresh;
```

- [ ] **Step 4: PASS** → **Step 5: Commit**

```bash
git commit -m "feat(project-service): expose applies_from after slepok-sensitive update

ProjectService::update() сейчас возвращает Project с dynamic applies_from atribute,
который API resource подхватит для UI ('изменения вступят в силу с DD.MM 21:00')."
```

---

## Task 2.9: `SyncSupplierProjectsJob` читает snapshot

**Files:**

- Modify: `app/app/Jobs/Supplier/SyncSupplierProjectsJob.php:83-91` — `handle()` берёт проекты из snapshot.
- Test: `app/tests/Feature/Jobs/Supplier/SyncSupplierProjectsJobSnapshotTest.php` (новый).

- [ ] **Step 1: Failing test** — snapshot для tomorrow содержит проект, live `projects` его уже не имеет (клиент paus'нул между 18:02 и 18:05) → push идёт по snapshot, не по live.

```php
it('reads from snapshot for tomorrow, not live projects (race 18:02→18:05)', function () {
    \Carbon\Carbon::setTestNow('2026-05-27 18:04:00', 'Europe/Moscow');
    $tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
    $project = Project::factory()->for($tenant)->create([
        'is_active' => false, // ← клиент paus'нул МЕЖДУ snapshot и sync
        'daily_limit_target' => 10, 'delivery_days_mask' => 127,
        'signal_type' => 'call', 'signal_identifier' => '79161234567',
    ]);
    // Snapshot создан до paus'a:
    \DB::table('project_routing_snapshots')->insert([
        'snapshot_date' => '2026-05-28', 'project_id' => $project->id, 'tenant_id' => $tenant->id,
        'daily_limit' => 10, 'delivery_days_mask' => 127, 'regions' => '{}',
        'signal_type' => 'call', 'signal_identifier' => '79161234567',
        'expected_volume' => 10, 'delivered_count' => 0, 'created_at' => now(),
    ]);
    // Mock channel
    $channel = $this->mock(...);
    $channel->shouldReceive('saveProjectMultiFlag')->once(); // ← должен быть вызван 1 раз
    (new SyncSupplierProjectsJob)->handle($channel);
    \Carbon\Carbon::setTestNow();
});
```

- [ ] **Step 2-5: TDD как обычно**

В `handle()` line 87 заменить:

```php
$projects = Project::on(self::DB_CONNECTION)
    ->where('is_active', true)
    ->orderBy('id')
    ->get();
```

на:

```php
$tomorrow = Carbon::tomorrow('Europe/Moscow')->toDateString();
$rows = DB::connection(self::DB_CONNECTION)
    ->table('project_routing_snapshots AS snap')
    ->join('projects', 'projects.id', '=', 'snap.project_id')
    ->where('snap.snapshot_date', $tomorrow)
    ->select('projects.*',
             'snap.daily_limit AS snapshot_daily_limit',
             'snap.regions AS snapshot_regions',
             'snap.delivery_days_mask AS snapshot_days_mask')
    ->orderBy('projects.id')
    ->get();
$projects = $rows->map(fn ($r) => tap(Project::hydrate([(array) $r])->first(), function ($p) use ($r) {
    $p->setRawAttributes((array) $r, true);
}));
```

В `syncGroup()` ниже: использовать `$p->snapshot_daily_limit` вместо `$p->daily_limit_target`, `$p->snapshot_days_mask` вместо `$p->delivery_days_mask`, `$p->snapshot_regions` вместо `$p->regions`.

- [ ] **Step 6: Commit**

```bash
git commit -m "feat(slepok): SyncSupplierProjectsJob reads from snapshot (race-safe with 18:02 cron)

After Stage 2, 18:05 sync reads project_routing_snapshots for tomorrow date,
not live projects.is_active. Closes race 18:02→18:05 (client edit between
snapshot creation and supplier push). Spec §4.2.4b."
```

---

## Task 2.10: Fail-loud механизм

**Files:**

- Modify: `app/app/Services/LeadRouter.php` — Log::error при отсутствии snapshot (уже сделано в Task 2.5 step 3).
- Modify: `app/app/Console/Commands/IncidentsWatchFailures.php` — расширить порог сканирования на лог-источник `lead_router.no_snapshot_for_active_date`.
- Create: `app/app/Console/Commands/SnapshotRebuildCommand.php`.

- [ ] **Step 1: Failing test для `SnapshotRebuildCommand`**

```php
it('rebuilds snapshot for given date from current live', function () {
    \Carbon\Carbon::setTestNow('2026-05-29 09:00:00', 'Europe/Moscow');
    $tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
    Project::factory()->for($tenant)->create([
        'is_active' => true, 'delivery_days_mask' => 127, 'daily_limit_target' => 10,
    ]);
    // 2026-05-29 snapshot отсутствует — cron 28.05 18:02 упал.
    $this->artisan('snapshot:rebuild', ['--date' => '2026-05-29'])->assertSuccessful();
    expect(\DB::table('project_routing_snapshots')->where('snapshot_date', '2026-05-29')->count())->toBe(1);
});
```

- [ ] **Step 2-5: TDD; команда практически копия `SnapshotBackfillCommand`, но без идемпотентного skip — каждый запуск пересоздаёт.**

`app/app/Console/Commands/SnapshotRebuildCommand.php` — копия Backfill с сигнатурой `snapshot:rebuild` и предварительным `DELETE FROM project_routing_snapshots WHERE snapshot_date=?` перед INSERT.

- [ ] **Step 6: Commit**

```bash
git commit -m "feat(slepok): snapshot:rebuild for manual recovery after cron failure

Fail-loud strategy per spec §4.2.2:
1. Heartbeat alarm via SchedulerHeartbeatTracker (already wired in Task 2.4).
2. LeadRouter Log::error on missing snapshot (Task 2.5).
3. Manual recovery: snapshot:rebuild --date=YYYY-MM-DD.
NO fallback to live projects — explicit downtime + alert is safer than silent regression."
```

---

## Task 2.11: UI сообщение «применится с DD.MM.YYYY HH:MM»

**Files:**

- Modify: `app/resources/js/composables/useProjectFormSubmit.ts` (или похожий — конкретный путь найти `grep -r 'projectStore.update' app/resources/js`).
- Modify: `app/app/Http/Resources/ProjectResource.php` — включить `applies_from`.
- Test: `app/tests/Feature/Resources/ProjectResourceTest.php` + `app/tests/Vitest`.

- [ ] **Step 1: Failing test PHP**

```php
it('ProjectResource includes applies_from when set', function () {
    $project = Project::factory()->make();
    $project->applies_from = \Carbon\CarbonImmutable::parse('2026-05-29 21:00:00', 'Europe/Moscow');
    $resource = (new \App\Http\Resources\ProjectResource($project))->toArray(request());
    expect($resource['applies_from'])->toBe('2026-05-29T21:00:00+03:00');
});
```

- [ ] **Step 2-4: TDD**, в `ProjectResource.php` после остальных полей:

```php
'applies_from' => $this->applies_from?->toIso8601String(),
```

- [ ] **Step 5: UI**

В Vue-компоненте (`useProjectFormSubmit.ts` или место обработки save-response):

```typescript
async function onSaveSuccess(response: ProjectResponse) {
    if (response.data.applies_from) {
        const dt = new Date(response.data.applies_from);
        notify.success(`Сохранено. Изменения вступят в силу ${formatDate(dt)} в 21:00 МСК.`);
    } else {
        notify.success('Сохранено.');
    }
}
```

- [ ] **Step 6: Vitest для UI**

```typescript
// resources/js/composables/__tests__/useProjectFormSubmit.spec.ts
it('shows applies_from message when API returns applies_from', () => {
    const { onSaveSuccess } = useProjectFormSubmit();
    onSaveSuccess({ data: { applies_from: '2026-05-29T21:00:00+03:00' } });
    expect(mockNotify.success).toHaveBeenCalledWith(expect.stringContaining('21:00'));
});
```

- [ ] **Step 7: Commit**

```bash
git commit -m "feat(ui): show applies_from message after project edit

R-15 + R-06..R-08 UX closure: client sees 'changes take effect at DD.MM 21:00 MSK'
after editing slepok-sensitive fields. Backend serializes Project::applies_from
via ProjectResource. Spec §4.2.5 UX block."
```

---

## Task 2.12: Деплой Этапа 2 + 24h мониторинг

- [ ] **Step 1: Pre-deploy validator**

```bash
ssh ubuntu@liderra.ru "/var/www/liderra/redeploy.sh --dry-run"
```

Expected: NO-GO список пуст или явные блокеры разобраны.

- [ ] **Step 2: Деплой**

```bash
ssh ubuntu@liderra.ru "cd /var/www/liderra && sudo -u www-data bash redeploy.sh"
```

Expected: миграция `2026_05_27_120000_create_project_routing_snapshots_table` отрабатывает.

- [ ] **Step 3: Сразу после deploy — backfill за сегодня**

```bash
ssh ubuntu@liderra.ru "cd /var/www/liderra/app && sudo -u www-data php artisan snapshot:backfill --date=$(date +%Y-%m-%d)"
```

Expected: `Snapshot backfilled for YYYY-MM-DD: N rows.` где N = число active+eligible проектов.

- [ ] **Step 4: 24h мониторинг**

```bash
# через 24h после deploy
ssh ubuntu@liderra.ru "sudo -u postgres psql -d liderra -c \"
SELECT snapshot_date, COUNT(*) AS rows, SUM(delivered_count) AS delivered, SUM(expected_volume) AS expected
FROM project_routing_snapshots GROUP BY 1 ORDER BY 1 DESC LIMIT 5
\""
```

Expected: записи за вчера + сегодня + завтра (cron 18:02 МСК отработал). delivered ≤ expected.

- [ ] **Step 5: Heartbeat check**

```bash
ssh ubuntu@liderra.ru "sudo -u postgres psql -d liderra -c \"
SELECT job_name, last_run_at, last_status FROM scheduler_heartbeats WHERE job_name='SnapshotProjectRoutingJob'
\""
```

Expected: `last_run_at` свежий (за последние 24h), `last_status` = `true`.

- [ ] **Step 6: Pilot/ПИЛОТ обновление**

```bash
git add ПИЛОТ.md
git commit -m "docs(pilot): Stage 2 deployed, 24h monitoring green"
```

---

# Этап 3 — Frozen filter + paused_at sync (1 рабочий день)

## Task 3.1: `LedgerService` reject frozen

**Files:**

- Modify: `app/app/Services/Billing/LedgerService.php:51-70`.
- Test: `app/tests/Feature/Billing/LedgerServiceFrozenRejectTest.php`.

- [ ] **Step 1: Failing test**

```php
it('throws InsufficientBalanceException when tenant is frozen', function () {
    $tenant = Tenant::factory()->create([
        'balance_rub' => '500.00',
        'frozen_by_balance_at' => now(),
    ]);
    $deal = Deal::factory()->for($tenant)->create();
    expect(fn () => app(LedgerService::class)->chargeForDelivery($tenant, $deal))
        ->toThrow(\App\Exceptions\Billing\InsufficientBalanceException::class);
});
```

- [ ] **Step 2-4: TDD; в `chargeForDelivery` перед `$balanceKopecks = bcmul(...)` добавить**:

```php
if ($lockedTenant->frozen_by_balance_at !== null) {
    throw new InsufficientBalanceException(
        priceKopecks: $priceKopecks,
        balanceRub: (string) $lockedTenant->balance_rub,
    );
}
```

- [ ] **Step 5: Commit**

```bash
git commit -m "feat(billing): R-03 — LedgerService rejects frozen tenants

Even if balance_rub > 0, a frozen tenant must not receive new charges.
Triggers same auto-pause flow as InsufficientBalance — project goes is_active=false
and ZeroBalancePausedMail (rate-limited). Spec §4.3.1."
```

---

## Task 3.2: `BalancePreflightSweepJob` sync `paused_at`

**Files:**

- Modify: `app/app/Jobs/Billing/BalancePreflightSweepJob.php` — оба transition блока.

- [ ] **Step 1: Failing test**

```php
it('sets paused_at on all tenant projects when freezing', function () {
    $tenant = Tenant::factory()->create(['frozen_by_balance_at' => null, 'balance_rub' => '0.00']);
    $project = Project::factory()->for($tenant)->create(['paused_at' => null]);
    // PricingTier'ы такие, что preflight fail'ит
    (new BalancePreflightSweepJob)->handle();
    expect($project->fresh()->paused_at)->not->toBeNull();
});

it('clears paused_at on tenant projects when unfreezing (only those paused by us)', function () {
    $tenant = Tenant::factory()->create([
        'frozen_by_balance_at' => now()->subDay(),
        'balance_rub' => '10000.00', // достаточно для unfreeze
    ]);
    // 2 проекта:
    $autoPaused = Project::factory()->for($tenant)->create(['paused_at' => now()->subHours(20)]);  // авто-pause при freeze
    $manualPaused = Project::factory()->for($tenant)->create(['paused_at' => now()->subDays(2)]); // вручную paused клиентом ДО freeze

    (new BalancePreflightSweepJob)->handle();

    expect($autoPaused->fresh()->paused_at)->toBeNull();
    expect($manualPaused->fresh()->paused_at)->not->toBeNull();
});
```

- [ ] **Step 2-4: TDD; в `evaluateTenant` добавить логику паузы при freeze и снятия при unfreeze (см. спек §4.3.2)**

- [ ] **Step 5: Commit**

```bash
git commit -m "feat(billing): R-13 — sync paused_at on freeze/unfreeze transitions

When freezing tenant: also set paused_at=NOW() on all projects without one.
When unfreezing: clear paused_at only on projects whose paused_at <= frozen_at_was
(to preserve manual client pauses). Closes Snapshot Guard blindspot on frozen tenants.
Spec §4.3.2."
```

---

## Task 3.3: Деплой Этапа 3

Аналогично 2.12.

---

# Этап 4 — Корректные расчёты (2 рабочих дня)

## Task 4.1: Унификация `buildUniqueKey`

**Files:**

- Modify: `app/app/Services/Supplier/SupplierProjectGrouping.php` — удалить `buildUniqueKey($project, $platform)`.
- Найти всех callers через grep и заменить на `buildUniqueKeyAgnostic`.

- [ ] **Step 1: Найти callers**

```bash
cd app && grep -rn "buildUniqueKey(" app/ tests/
```

Expected: ~3-5 мест в `SyncSupplierProjectJob.php` (handleOnline) + tests.

- [ ] **Step 2-5: Заменить каждое на `buildUniqueKeyAgnostic($project)`** (без второго аргумента). Тесты прогнать.

- [ ] **Step 6: Удалить platform-specific метод**

```php
// УДАЛИТЬ buildUniqueKey(Project $project, string $platform): string
```

- [ ] **Step 7: Commit**

```bash
git commit -m "refactor(supplier-grouping): R-17 — unify on buildUniqueKeyAgnostic

platform-specific buildUniqueKey diverged from agnostic for SMS+keyword projects
(B3 used 'sender' without keyword, B2 used 'sender+keyword'), creating orphan
rows in batch flow. Now always 'sender+keyword' for SMS with keyword. Spec §4.4.1."
```

---

## Task 4.2: Migration команда `supplier:rekey-orphans`

**Files:**

- Create: `app/app/Console/Commands/SupplierRekeyOrphansCommand.php`.

- [ ] **Step 1-5: TDD** — найти orphans (SMS B3 с unique_key без `+`), либо UPDATE их `unique_key=sender+keyword` если такой ещё нет, либо пометить как dead и пустить через `DeleteSupplierProjectJob`.

- [ ] **Step 6: Commit + run on prod (после деплоя)**

```bash
ssh ubuntu@liderra.ru "cd /var/www/liderra/app && sudo -u www-data php artisan supplier:rekey-orphans --dry-run"
# проверить вывод, потом без --dry-run
```

---

## Task 4.3: R-18 fixed target_date

**Files:**

- Modify: `app/app/Jobs/SyncSupplierProjectJob.php:141` — заменить `Carbon::tomorrow` на условную логику.

- [ ] **Step 1: Failing test**

```php
it('uses tomorrow at 23:59 MSK', function () {
    Carbon::setTestNow('2026-05-28 23:59:59', 'Europe/Moscow');
    $job = new SyncSupplierProjectJob(1);
    // assert targetWeekday = Tuesday (29-th)
    Carbon::setTestNow();
});
it('uses tomorrow at 00:01 MSK (NO flicker to Wednesday)', function () {
    Carbon::setTestNow('2026-05-29 00:01:00', 'Europe/Moscow');
    // assert targetWeekday = STILL Tuesday (29-th — same target)
});
it('uses day-after-tomorrow at 21:01 MSK', function () {
    Carbon::setTestNow('2026-05-29 21:01:00', 'Europe/Moscow');
    // assert targetWeekday = Wednesday (30-th)
});
```

- [ ] **Step 2-5: TDD**, в `handleOnline`:

```php
$msk = CarbonImmutable::now('Europe/Moscow');
$targetDate = $msk->hour >= 21 ? $msk->copy()->addDays(2)->startOfDay() : $msk->copy()->addDay()->startOfDay();
$targetWeekday = $targetDate->isoWeekday();
```

- [ ] **Step 6: Commit**

---

## Task 4.4: R-19 share-aware `requiredLeadsForTomorrow`

**Files:**

- Modify: `app/app/Models/Tenant.php`.

- [ ] **Step 1: Failing test**

```php
it('returns full limit when no sharing', function () {
    $tenant = Tenant::factory()->create();
    Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 10]);
    expect($tenant->fresh()->requiredLeadsForTomorrow())->toBe(10);
});

it('returns proportional share when 3 tenants share source', function () {
    // 3 tenants, same call source 79161234567, each daily_limit_target=10
    // group_order = max(10, ceil(30/3)) = 10
    // share per tenant = ceil(10 * 10/30) = 4
    $tenant1 = Tenant::factory()->create();
    $tenant2 = Tenant::factory()->create();
    $tenant3 = Tenant::factory()->create();
    foreach ([$tenant1, $tenant2, $tenant3] as $t) {
        Project::factory()->for($t)->create([
            'is_active' => true, 'signal_type' => 'call',
            'signal_identifier' => '79161234567', 'daily_limit_target' => 10,
        ]);
    }
    expect($tenant1->requiredLeadsForTomorrow())->toBe(4);
});
```

- [ ] **Step 2-5: TDD** — реализация по §4.4.3.

- [ ] **Step 6: Commit**

---

## Task 4.5: `CsvReconcileJob` business-drift

**Files:**

- Modify: `app/app/Jobs/Supplier/CsvReconcileJob.php` — добавить второй проход.

- [ ] **Step 1: Failing test** — snapshot за вчера имеет expected=10, delivered=2 → shortfall=80% → второй email-алёрт.

- [ ] **Step 2-5: TDD** — добавить SQL запрос (см. спек §4.4.4) после существующего drift расчёта; новый email `TenantBusinessDriftAlertMail` (отдельный от существующего `CsvDriftAlertMail`).

- [ ] **Step 6: Commit**

---

# Этап 5 — Переключение online → batch (1 час + неделя наблюдения)

## Task 5.1: UPDATE `supplier_export_mode`

После Этапов 1-4 в production стабильно работали ≥1 недели без P0:

- [ ] **Step 1: Прод-SQL**

```bash
ssh ubuntu@liderra.ru "sudo -u postgres psql -d liderra -c \"
UPDATE system_settings SET value='batch' WHERE key='supplier_export_mode';
SELECT value FROM system_settings WHERE key='supplier_export_mode';
\""
```

Expected: `value='batch'` подтверждено.

- [ ] **Step 2: Smoke в течение часа**

Создать/изменить тестовый проект через UI на dev/staging. Убедиться что:

- `SyncSupplierProjectJob` (single) НЕ пушит сразу (handleBatch вместо handleOnline).
- Изменения уезжают ночью через `SyncSupplierProjectsJob` (множественный).

- [ ] **Step 3: Memory update**

`memory/project_billing_v2.md` — обновить «целевой режим достигнут (batch)».
`memory/project_slepok_protection.md` — пометить «Этап 5 закрыт, online режим архивирован».

- [ ] **Step 4: 7-дневный мониторинг**

Каждый день: `scheduler_heartbeats`, error rate, business-drift alerts. Если 7 дней зелёные — этап закрыт.

- [ ] **Step 5: Commit memory + ПИЛОТ**

```bash
git commit -m "docs: Stage 5 closed — batch mode live, slepok protection end-to-end"
```

---

# Self-review

**Spec coverage:**

- R-01 — Task 2.5 (snapshot routing) ✅
- R-02 — Task 2.5 + 2.9 (slepok согласован между нами и поставщиком) ✅
- R-03 — Task 2.5 (фильтр frozen в SQL) + Task 3.1 (LedgerService reject) ✅
- R-04 — Task 2.5 + 2.6 (snapshot defines today; auto-pause через is_active) ✅
- R-05 — Task 4.5 (business-drift) ✅
- R-06..R-08 — Task 2.5 (snapshot read), Task 2.7 (appliesFrom) ✅
- R-09 — Task 2.6 (is_active recheck под lock) ✅
- R-10 — Task 2.7 (appliesFrom расширен на signal_identifier/sms_*) ✅
- R-11 — Task 2.2 step 3 (snapshot подхватывает effective_daily_limit_today) ✅
- R-12 — Task 1.2 ✅
- R-13 — Task 3.2 (paused_at sync) ✅
- R-14 — Task 1.3 ✅
- R-15 — Task 2.5 + 2.11 (UI message) ✅
- R-16 — Task 1.4 ✅
- R-17 — Task 4.1 + 4.2 ✅
- R-18 — Task 4.3 ✅
- R-19 — Task 4.4 ✅

Все 19 рисков покрыты задачами.

**Placeholder scan:** в плане нет «TBD/TODO/implement later». Все шаги имеют конкретный код или конкретную команду.

**Type consistency:** `applies_from` (snake_case PHP attribute) vs `appliesFrom` (camelCase method) — это разные сущности, OK. `snapshot_daily_limit` используется консистентно в Task 2.5 и 2.9. `delivered_count` vs `delivered_today` — разные поля, разные таблицы, OK.

---

**Plan complete and saved to `docs/superpowers/plans/2026-05-26-slepok-routing-protection.md`.**

Два варианта исполнения:

1. **Subagent-Driven (рекомендуется)** — диспатчу свежего субагента на каждую task, делаю review между task'ами, быстрая итерация. Скил `superpowers:subagent-driven-development`.

2. **Inline Execution** — исполняю task'и в этой сессии через `superpowers:executing-plans`, батч исполнение с checkpoint'ами для review.

Какой подход?
