# Разблокировка смены источника без потери лидов — 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.
>
> ⚠️ **ДЕНЕЖНОЕ ЯДРО.** Эпик 2 трогает раздачу лидов (revenue-critical, ~30k лидов/сутки). НЕ начинать Эпик 2 без явного «делай» от владельца И без закрытия design-gate (Задача 2.0). Эпики 1/3/4 безопаснее, но Эпик 3 (UX) нельзя выкатывать раньше Эпика 2 — иначе обещание клиенту станет враньём.

**Goal:** Убрать жёсткую блокировку «жди 2 дня» при смене источника проекта так, чтобы ни один уже заказанный поставщиком лид не терялся, а клиент видел понятные объявления вместо замка.

**Architecture:** Раздача завтрашних лидов уже идёт по дневному слепку `project_routing_snapshots` (18:02 МСК). Единственное звено, где источник матчится по «живой» связи `project_supplier_links` (рвётся при смене источника), переводим на матч по слепку — слепок специально переживает удаление проекта/связи. Старый источник не удаляем у поставщика, пока летит его хвост или он нужен другим клиентам группы. UX-замок заменяем баннерами + подтверждением. Онлайн-режим в окне 18:00→00:00 не шлёт поставщику немедленно (защита слепка поставщика 21:00).

**Tech Stack:** PHP 8.3 / Laravel 13, PostgreSQL 16 (partitioned snapshots, RLS), Pest 4 (`--parallel`), Vue 3 + Vuetify 3, Vitest.

**Контекст-разбор (читать первым):** [docs/superpowers/findings/2026-06-25-source-edit-block-snapshot-analysis.md](../findings/2026-06-25-source-edit-block-snapshot-analysis.md). Старая UX-спека (отклонённый «жёсткий замок»): [docs/superpowers/specs/2026-06-22-project-source-edit-lock-ux-design.md](../specs/2026-06-22-project-source-edit-lock-ux-design.md).

---

## СТАТУС РЕАЛИЗАЦИИ (обновляется по ходу)

Ветка `feat/source-edit-snapshot-routing` (НЕ запушено).

- ✅ **Эпик 0** (страховка) + **Task 2.1/2.2/2.3/2.5** — закоммичено `cc6c48b4`. Матч источника по слепку под флагом `routing_match_by_snapshot` (дефолт ВЫКЛ). Тесты зелёные (12 LeadRouter + 26 доставки + 14 guard).
- ✅ **Design-gate** (Task 2.0) — `675fba6d`, согласован.
- ✅ **Task 2.6** (офлайн-батч берёт источник из слепка) — `c8ec7a66`. `collectEligibleProjects` override signal_identifier/sms_senders/sms_keyword из слепка; тест-хелпер `insertSnapshotForTomorrow` зеркалит источник проекта.
- ✅ **Task 2.7** (страховка двойного списания) — `b7379e7a`. Под флагом ВКЛ CSV+webhook одного лида → 1 deal/charge (§9b project_id сходится).
- ✅ **Task 2.4** (отложенное удаление донора) — `35d51df6`. `DeleteSupplierProjectJob.hasActiveSnapshotTail` откладывает удаление пока летит хвост слепка.
- 🟢 **ЭПИК 2 ЗАКРЫТ.** Полная регрессия: 66 passed / 1 skipped / 0 failed (LeadRouter+доставка+guard+sync+billing+delete). Все правки за флагом `routing_match_by_snapshot` (дефолт ВЫКЛ) — прод не меняется до включения.
- ✅ **Эпик 1** (read-only поля) — `b1fe11b7`. ProjectResource уже отдавал source_locked/unlock_at/projected; добавлено 3-е состояние (пауза в grace).
- ✅ **Эпик 3** (UX вместо замка) — `7337bd23`. Поля источника больше не disabled; баннер applies-from при правке количества/региона/дней; диалог подтверждения смены источника. 26/26 фронт-тестов.
- ✅ **Эпик 4** (онлайн-заморозка) — `9d703ccb`. SyncSupplierProjectJob в окне 18:00→00:00 МСК откладывает в supplier_deferred_sync; FlushDeferredOnlineSyncJob (00:05) досылает. Схема v8.54 +1 таблица, RLS-ревью пройдено. 6 тестов + регрессия 33/33.
- ✅ **Эпик 5** (отчёт заливки) — `1d18933d`. Владелец выбрал «экран в админке». SyncSupplierProjectsJob пишет сводку в supplier_sync_runs (finally); эндпоинт + карточка «Вечерняя заливка» в SaaS-admin. Схема v8.55 +1 таблица, RLS-ревью 7/7. Проверено глазами.
- ✅ **Эпик 6** (уведомления правил) — 6.1 `be082396` сервис ProjectRuleMessages (единый источник текстов), 6.2 `e655af62` in-app уведомления (хвост/вступление), 6.3 `64a76a21` диалоги тянут текст из API (source_change_message). Проверено глазами.
- 🟢 **ВСЕ ЭПИКИ (1–6) ЗАКРЫТЫ.** Доп. находка глазами: окно «Редактировать» (EditProjectDialog→NewProjectDialog) имело отдельный путь без UX замка — приведено к Эпику 3 (`941b4b9f`). Проверка глазами через Playwright: панель + диалог + экран отчёта + единый текст правил. Финал-регрессия: 32 бэк + 39 фронт зелёные. Всё за флагом `routing_match_by_snapshot` (ВЫКЛ), НЕ запушено.
- ⚠️ Локальный larastan даёт окруженческий шум (ide-helper/Pest stubs) — не блокер кода, см. память `feedback_ide_helper_broken_stub_fresh_checkout`. Новые тест-файлы добавлены в baseline (Pest/Mockery higher-order). На сервере сборки чисто.

---

## Карта файлов (что и зачем трогаем)

| Файл | Роль в плане | Эпик |
|---|---|---|
| `app/app/Services/LeadRouter.php` | `queryCandidates` — перевести `$sourceWhere` поставщиковых проектов с pivot на матч по слепку | 2 |
| `app/app/Services/Project/ProjectService.php` | `detachOldSourceSupplierProjects` — не рвать/не удалять источник, пока летит хвост | 2 |
| `app/app/Jobs/Supplier/DeleteSupplierProjectJob.php` | отложенное удаление supplier_project (хвост + группа) | 2 |
| `app/app/Services/Project/SupplierSnapshotGuard.php` | `assertCanMutateSource` — снять жёсткий запрет на источник после доделки ядра | 2/3 |
| `app/app/Http/Resources/ProjectResource.php` | read-only поля `source_locked`/`source_unlock_at`/`source_change_pending` | 1 |
| `app/resources/js/components/projects/ProjectDetailsDrawer.vue` | баннеры/подтверждение вместо disabled-замка | 3 |
| `app/app/Jobs/SyncSupplierProjectJob.php` | онлайн — отложить отправку в окне 18:00→00:00 | 4 |
| `app/routes/console.php` | джоб досыла отложенной онлайн-очереди в 00:00 | 4 |
| `app/app/Jobs/Supplier/SyncSupplierProjectsJob.php` | сводный отчёт о завершении вечерней заливки | 5 (open) |

---

## Эпик 0 — Подготовка и страховочные тесты (безопасно, делать первым)

Цель: зафиксировать ТЕКУЩЕЕ поведение тестами-сторожами, чтобы переделка ядра не сломала молча.

### Task 0.1: Характеристический тест — лид по источнику доезжает (как есть сейчас)

**Files:**
- Test: `app/tests/Feature/LeadRouter/SourceMatchCharacterizationTest.php` (создать)

- [ ] **Step 1: Написать тест, фиксирующий текущий happy-path**

Тест строит: tenant + проект (sms, sender `Caranga`) + supplier_project + pivot + снимок за завтра, шлёт лид и проверяет, что создаётся сделка. Это «зелёная база» — должна проходить ДО любых правок ядра.

```php
<?php
declare(strict_types=1);

use App\Models\Project;
use App\Models\SupplierLead;
use App\Jobs\RouteSupplierLeadJob;
// ... фабрики проекта/supplier_project/snapshot — по образцу
//     app/tests/Feature/LeadRouter/SnapshotRoutingTest.php

it('доставляет лид по поставщиковому источнику при живой связи (baseline)', function () {
    $fix = makeRoutableProjectWithSnapshot(signalType: 'sms', sender: 'Caranga'); // helper из SnapshotRoutingTest
    $lead = makeIncomingLead(project: 'B3_Caranga');

    RouteSupplierLeadJob::dispatchSync($lead->id);

    expect(\App\Models\Deal::where('project_id', $fix->project->id)->count())->toBe(1);
});
```

- [ ] **Step 2: Запустить — убедиться, что проходит на текущем коде**

Run: `cd app && composer test -- --filter=SourceMatchCharacterization`
Expected: PASS (фиксируем baseline).

- [ ] **Step 3: Commit**

```bash
git add app/tests/Feature/LeadRouter/SourceMatchCharacterizationTest.php
git commit -m "test/router: характеристический тест доставки лида по источнику (baseline под переделку слепка)"
```

---

## Эпик 1 — Read-only поля статуса источника (безопасно, бэкенд)

Цель: дать фронту данные для баннеров, НЕ меняя логику гарда. Зеркалит §3.1 старой спеки, но поля переименованы под новый UX.

### Task 1.1: ProjectResource отдаёт статус смены источника

**Files:**
- Modify: `app/app/Http/Resources/ProjectResource.php`
- Test: `app/tests/Feature/Http/Resources/ProjectResourceSourceLockTest.php` (есть — расширить)

- [ ] **Step 1: Написать тест на 3 состояния**

Состояния: (а) проект без supplier-связей → `source_locked=false`; (б) активный supplier-связанный → `source_locked=true`, `source_unlock_projected=true`; (в) на паузе в grace → `source_locked=true`, `source_unlock_at` = твёрдая дата. Источник правды — `SupplierSnapshotGuard::lockState` (уже существует, [строки 146-170](../../../app/app/Services/Project/SupplierSnapshotGuard.php#L146-L170)).

```php
it('отдаёт source_locked/unlock_at из lockState для активного supplier-проекта', function () {
    $project = makeSupplierLinkedProject(isActive: true);
    $payload = (new \App\Http\Resources\ProjectResource($project))->resolve();
    expect($payload['source_locked'])->toBeTrue()
        ->and($payload['source_unlock_projected'])->toBeTrue()
        ->and($payload['source_unlock_at'])->not->toBeNull();
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `cd app && composer test -- --filter=ProjectResourceSourceLock`
Expected: FAIL (полей ещё нет).

- [ ] **Step 3: Добавить поля в ProjectResource**

В `toArray()` подмешать (через уже eager-loaded `supplier_links_count`, без N+1):

```php
$lock = app(\App\Services\Project\SupplierSnapshotGuard::class)->lockState(
    hasLinks: (bool) ($this->supplier_links_count ?? 0),
    isActive: (bool) $this->is_active,
    pausedAt: $this->paused_at,
);
// ...
'source_locked'           => $lock['locked'],
'source_unlock_at'        => $lock['unlock_at']?->toIso8601String(),
'source_unlock_projected' => $lock['projected'],
```

- [ ] **Step 4: Запустить — убедиться, что проходит**

Run: `cd app && composer test -- --filter=ProjectResourceSourceLock`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add app/app/Http/Resources/ProjectResource.php app/tests/Feature/Http/Resources/ProjectResourceSourceLockTest.php
git commit -m "feat/projects: read-only поля source_locked/unlock_at для UX смены источника"
```

---

## Эпик 2 — ЯДРО: раздача источника по слепку + отложенное удаление (ДЕНЬГИ)

⚠️ Не начинать без «делай» владельца. Сначала Задача 2.0 (design-gate).

### Task 2.0: DESIGN-GATE — выбрать механизм матча источника по слепку

**Files:**
- Create: `docs/superpowers/specs/2026-06-25-snapshot-source-routing-design.md`

- [ ] **Step 1: Зафиксировать развилку и рекомендацию**

Сейчас матч поставщикового источника: `EXISTS (project_supplier_links psl WHERE psl.project_id=snap.project_id AND psl.supplier_project_id=?)` ([LeadRouter.php:114-115](../../../app/app/Services/LeadRouter.php#L114-L115)) — `?` это `SupplierProject.id`, полученный из `resolveOrStub` по идентификатору лида. При смене источника pivot удаляется → EXISTS=false → лид теряется.

Два пути убрать зависимость от живого pivot:

- **Путь A (рекомендую): матч по signal-полям слепка** — как уже сделано для DIRECT ([LeadRouter.php:108-112](../../../app/app/Services/LeadRouter.php#L108-L112)). Слепок уже хранит `signal_type/signal_identifier/sms_senders/sms_keyword`. Матчим agnostic-ключ лида против agnostic-ключа слепка. Плюс: не зависит ни от pivot, ни от supplier_project; слепок переживает удаление «из коробки». Минус: надо аккуратно повторить agnostic-логику sms (sender vs sender+keyword) в SQL/PHP — переиспользовать `SupplierProjectGrouping::buildUniqueKeyAgnostic`.
- **Путь B: слепок хранит `supplier_project_id`** на 18:02 + матч по нему + `DeleteSupplierProjectJob` не удаляет sp, пока есть снимки с его id. Минус: миграция таблицы снимков, supplier_project «живёт мёртвым» — больше состояний.

- [ ] **Step 1b: Чеклист критичных швов (из ювелирного аудита §9b findings) — спека ОБЯЗАНА ответить на каждый**

Перенести в спеку и решить по каждому шву (детали — [findings §9b](../findings/2026-06-25-source-edit-block-snapshot-analysis.md)):

1. **Двойное списание (CSV-merge):** как гарантировать, что webhook и CSV-recovery одного лида матчатся к одному `project_id`? (merge-ключ зависит от project_id; промах = второй charge).
2. **resolveOrStub новый id:** матч обязан идти по signal-полям слепка, не по `sp.id` — подтвердить, что stub с новым id не рвёт доставку.
3. **no_snapshot молчаливый skip:** гарантия слепка для активного проекта на момент И матча, И транзакции.
4. **Источник в офлайн-батче:** `collectEligibleProjects` берёт источник LIVE — переопределять из слепка (см. Task 2.6).
5. **sms agnostic-ключ:** SQL-матч по `snap.sms_senders ->> 0` (первый!) + `snap.sms_keyword`, не `@>`.
6. **root-domain:** site-матч по слепку воспроизводит суффиксное раскрытие корень→субдомены, однонаправленно.
7. **Удаление источника:** условие = pivot-count=0 И нет снимка-хвоста И нет необработанных supplier_leads (Task 2.4).
8. **Терминальные подстроки ошибок:** синхронизировать список с новым кодом матча (иначе retry-шторм).
9. **Авторитетный счётчик:** live `delivered_today` vs snapshot `delivered_count` — какой главный.
10. **Чистка stale pivot:** ночной батч пере-связывает ADDITIVELY (без DELETE); старую связь `(project_id, old_sp_id)` никто не удаляет — Task 2.4 обязан целенаправленно её чистить при отложенном удалении (подтверждено §9c-Q4).
11. **sms ВЕЗДЕ по `sms_senders`, не `signal_identifier`:** sms-проект слепка матчить против `snap.sms_senders ->> 0` + `snap.sms_keyword`; для sms `signal_identifier=NULL`. Заодно чинит существующий тихий провал DIRECT+sms (подтверждено §9c-Q5).
12. **Транзакция вокруг смены источника:** сейчас `update→detach→dispatch` БЕЗ `DB::transaction` — решить, оборачивать ли (частичное состояние при сбое; очередь redis async) (§9c).

- [ ] **Step 2: Решение владельца/архитектора**

Зафиксировать выбор (A/B) в спеке + ответы на 9 швов. Дальнейшие задачи 2.1+ написаны под **Путь A**; при выборе B — переписать 2.1.

- [ ] **Step 3: Commit**

```bash
git add docs/superpowers/specs/2026-06-25-snapshot-source-routing-design.md
git commit -m "docs/spec: design-gate матча источника по слепку (Путь A рекомендован)"
```

### Task 2.1: Тест — лид по старому источнику доезжает после смены источника (RED)

**Files:**
- Test: `app/tests/Feature/LeadRouter/SourceMatchAfterChangeTest.php` (создать)

- [ ] **Step 1: Написать падающий тест на целевое поведение**

Сценарий: проект с источником `Caranga`, снимок за завтра снят со старым источником. Клиент меняет источник на `NewSender` (pivot старого удаляется). Приходит лид по СТАРОМУ `Caranga`. Ожидание: сделка ВСЁ РАВНО создаётся (слепок помнит старый источник).

```php
it('доставляет лид по СТАРОМУ источнику на следующий день после смены (slepok survives)', function () {
    $fix = makeRoutableProjectWithSnapshot(signalType: 'sms', sender: 'Caranga');
    // клиент сменил источник: pivot старого источника удалён, sp отвязан
    app(\App\Services\Project\ProjectService::class)->update($fix->project, ['sms_senders' => ['NewSender']]);

    $lead = makeIncomingLead(project: 'B3_Caranga'); // лид по СТАРОМУ источнику
    RouteSupplierLeadJob::dispatchSync($lead->id);

    expect(\App\Models\Deal::where('project_id', $fix->project->id)->count())->toBe(1);
});
```

- [ ] **Step 2: Запустить — убедиться, что падает на текущем коде**

Run: `cd app && composer test -- --filter=SourceMatchAfterChange`
Expected: FAIL (0 deals — pivot удалён, текущий матч не находит).

- [ ] **Step 3: Commit (red-тест в отдельном коммите)**

```bash
git add app/tests/Feature/LeadRouter/SourceMatchAfterChangeTest.php
git commit -m "test/router: red — лид по старому источнику теряется после смены (демонстрация дыры)"
```

### Task 2.2: Перевести матч поставщикового источника на слепок (GREEN)

**Files:**
- Modify: `app/app/Services/LeadRouter.php` (`queryCandidates`, ветка не-DIRECT, [строки 113-117](../../../app/app/Services/LeadRouter.php#L113-L117))

- [ ] **Step 1: Заменить pivot-EXISTS на матч по signal-полям слепка**

Под Путь A: `$sourceWhere` для не-DIRECT строится из agnostic-ключа лида против полей `snap`. Идентификатор лида (`$sp->unique_key` + `signal_type`) уже агностик-нормализован резолвером. Заменить EXISTS-pivot на сравнение `snap.signal_type` + agnostic-выражение по `snap.signal_identifier`/`snap.sms_senders`/`snap.sms_keyword` (повторить семантику `buildUniqueKeyAgnostic`). Точную SQL-форму утвердить в Задаче 2.0 и закодировать здесь.

> Реализационный шаг намеренно ссылается на design-gate: SQL-выражение agnostic-матча по слепку фиксируется в спеке 2.0, потому что от точности зависят деньги. Здесь — применить утверждённое выражение и прогнать тесты 2.1 + baseline 0.1 + существующие LeadRouter-тесты.

- [ ] **Step 2: Запустить целевой + характеристический + регрессию роутера**

Run: `cd app && composer test -- --filter='SourceMatchAfterChange|SourceMatchCharacterization|LeadRouter|SnapshotRouting'`
Expected: всё PASS (новое поведение + ничего старого не сломано).

- [ ] **Step 3: Commit**

```bash
git add app/app/Services/LeadRouter.php
git commit -m "fix/router: матч поставщикового источника по слепку вместо живой связи — лид по старому источнику доезжает"
```

### Task 2.3: Тест — сосед по общему источнику не страдает при смене у одного клиента

**Files:**
- Test: `app/tests/Feature/LeadRouter/SharedSourceUnaffectedTest.php` (создать)

- [ ] **Step 1: Написать тест**

Два tenant'а на одном источнике `Caranga` (группа). Клиент A меняет источник. Лид по `Caranga` должен по-прежнему доезжать клиенту B.

```php
it('смена источника у клиента A не ломает доставку клиенту B на общем источнике', function () {
    $shared = makeTwoTenantsOnSharedSource(sender: 'Caranga');
    app(\App\Services\Project\ProjectService::class)->update($shared->projectA, ['sms_senders' => ['NewSender']]);

    $lead = makeIncomingLead(project: 'B3_Caranga');
    RouteSupplierLeadJob::dispatchSync($lead->id);

    expect(\App\Models\Deal::where('project_id', $shared->projectB->id)->count())->toBe(1);
});
```

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

Run: `cd app && composer test -- --filter=SharedSourceUnaffected`
Expected: PASS (если Путь A матчит по signal-полям — B и так в снимке со старым источником). Если FAIL → дефект в Задаче 2.2, чинить там.

- [ ] **Step 3: Commit**

```bash
git add app/tests/Feature/LeadRouter/SharedSourceUnaffectedTest.php
git commit -m "test/router: сосед по общему источнику не страдает при смене у одного клиента"
```

### Task 2.4: Отложенное удаление старого источника (хвост + группа)

**Files:**
- Modify: `app/app/Services/Project/ProjectService.php` (`detachOldSourceSupplierProjects`, [строки 141-182](../../../app/app/Services/Project/ProjectService.php#L141-L182))
- Modify: `app/app/Jobs/Supplier/DeleteSupplierProjectJob.php`
- Test: `app/tests/Feature/Supplier/DeleteSupplierProjectTailGuardTest.php` (создать)

- [ ] **Step 1: Тест — sp со снимком-хвостом или другим потребителем НЕ удаляется**

```php
it('не удаляет supplier_project, пока есть снимок с его источником за активную дату', function () {
    $sp = makeSupplierProjectWithLiveSnapshotTail();
    \App\Jobs\Supplier\DeleteSupplierProjectJob::dispatchSync([$sp->id]);
    expect(\App\Models\SupplierProject::find($sp->id))->not->toBeNull();
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `cd app && composer test -- --filter=DeleteSupplierProjectTailGuard`
Expected: FAIL (сейчас удаляет безусловно).

- [ ] **Step 3: Добавить guard в DeleteSupplierProjectJob**

Перед удалением sp проверять: (а) нет снимка `project_routing_snapshots` за активную/завтрашнюю дату с этим источником (хвост летит); (б) нет других живых pivot-потребителей. Если есть — пропустить удаление (отложить), повтор на следующем `CleanupInactiveSupplierProjectsJob` (02:00, уже в расписании).

- [ ] **Step 4: Запустить — PASS + регрессия supplier**

Run: `cd app && composer test -- --filter='DeleteSupplierProjectTailGuard|SupplierProject'`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add app/app/Jobs/Supplier/DeleteSupplierProjectJob.php app/app/Services/Project/ProjectService.php app/tests/Feature/Supplier/DeleteSupplierProjectTailGuardTest.php
git commit -m "fix/supplier: отложенное удаление источника пока летит хвост слепка или есть другие потребители"
```

### Task 2.5: Снять жёсткий запрет смены источника (после доделки ядра)

**Files:**
- Modify: `app/app/Services/Project/SupplierSnapshotGuard.php` (`assertCanMutateSource`, [строки 121-136](../../../app/app/Services/Project/SupplierSnapshotGuard.php#L121-L136))
- Test: `app/tests/Unit/Services/Project/SupplierSnapshotGuardTest.php` (расширить)

- [ ] **Step 1: Тест — change_source больше НЕ кидает 422, delete с активным хвостом — по-прежнему осторожен**

`assertCanMutateSource($project, 'change_source')` после Эпика 2 не должен бросать (ядро доводит хвост). `delete` оставляем под защитой has-deals/хвоста.

```php
it('не блокирует смену источника после перевода раздачи на слепок', function () {
    $project = makeSupplierLinkedProject(isActive: true);
    expect(fn () => app(SupplierSnapshotGuard::class)->assertCanMutateSource($project, 'change_source'))
        ->not->toThrow(\Illuminate\Http\Exceptions\HttpResponseException::class);
});
```

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

Run: `cd app && composer test -- --filter=SupplierSnapshotGuard`
Expected: FAIL (сейчас бросает для change_source).

- [ ] **Step 3: Убрать ветку change_source из жёсткого запрета**

Оставить осторожность только для `delete` (хвост/сделки). Для `change_source` — разрешить (UX-подтверждение берёт на себя Эпик 3).

- [ ] **Step 4: Запустить — PASS + полная регрессия ядра**

Run: `cd app && composer test -- --filter='SupplierSnapshotGuard|SourceMatch|SharedSource|DeleteSupplierProjectTailGuard'`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add app/app/Services/Project/SupplierSnapshotGuard.php app/tests/Unit/Services/Project/SupplierSnapshotGuardTest.php
git commit -m "feat/projects: снять жёсткий запрет смены источника — раздача доводит хвост по слепку"
```

### Task 2.6: Офлайн-батч берёт источник из слепка, не LIVE (закрыть рассинхрон 18:02→18:05)

**Files:**
- Modify: `app/app/Jobs/Supplier/SyncSupplierProjectsJob.php` (`collectEligibleProjects`, [строки 218-234](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L218-L234))
- Test: `app/tests/Feature/Supplier/SyncSourceFromSnapshotTest.php` (создать)

- [ ] **Step 1: Тест — смена источника в окне 18:02→18:05 не уводит заказ от слепка**

```php
it('батч заказывает по источнику из слепка, даже если live-источник сменили после слепка', function () {
    $project = makeSnapshottedProject(sender: 'Caranga'); // снимок снят с Caranga
    DB::table('projects')->where('id', $project->id)->update(['sms_senders' => json_encode(['NewSender'])]); // live сменён
    (new \App\Jobs\Supplier\SyncSupplierProjectsJob)->collectEligibleProjects()->each(function ($p) {
        expect($p->sms_senders)->toBe(['Caranga']); // источник ИЗ СЛЕПКА, не live
    });
});
```

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

Run: `cd app && composer test -- --filter=SyncSourceFromSnapshot`
Expected: FAIL (сейчас источник live).

- [ ] **Step 3: Добавить override источника из слепка**

В `collectEligibleProjects`: select также `snap.signal_identifier/sms_senders/sms_keyword`, и в цикле override (L230-234) дополнительно перезаписать `signal_identifier`/`sms_senders`/`sms_keyword` значениями слепка (рядом с лимитом/днями/регионами). `sms_senders` парсить из JSONB.

- [ ] **Step 4: Запустить — PASS + регрессия supplier-sync**

Run: `cd app && composer test -- --filter='SyncSourceFromSnapshot|SyncSupplierProjects'`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add app/app/Jobs/Supplier/SyncSupplierProjectsJob.php app/tests/Feature/Supplier/SyncSourceFromSnapshotTest.php
git commit -m "fix/supplier: офлайн-батч берёт источник из слепка — закрыт рассинхрон 18:02→18:05"
```

### Task 2.7: Тест-страховка — нет двойного списания при CSV-recovery после смены источника

**Files:**
- Test: `app/tests/Feature/Billing/NoDoubleChargeOnSourceChangeTest.php` (создать)

- [ ] **Step 1: Тест — webhook + CSV-recovery одного лида = ОДНО списание после смены источника**

```php
it('не списывает дважды когда CSV-recovery и webhook одного лида приходят вокруг смены источника', function () {
    $fix = makeRoutableProjectWithSnapshot(signalType: 'sms', sender: 'Caranga');
    $balanceBefore = $fix->tenant->fresh()->balance_rub;

    // CSV-recovery лида (vid=NULL) → создаёт deal
    $csvLead = makeCsvRecoveredLead(phone: '79161234567', project: 'B3_Caranga');
    RouteSupplierLeadJob::dispatchSync($csvLead->id);
    // webhook того же физического лида (vid=int) приходит позже
    $webhookLead = makeWebhookLead(phone: '79161234567', project: 'B3_Caranga', vid: 555);
    RouteSupplierLeadJob::dispatchSync($webhookLead->id);

    expect(\App\Models\Deal::where('tenant_id', $fix->tenant->id)->where('phone', '79161234567')->count())->toBe(1)
        ->and((int) ($balanceBefore - $fix->tenant->fresh()->balance_rub))->toBe(/* одна цена тарифа */ expectedTierPriceRub());
});
```

- [ ] **Step 2: Запустить на текущем коде — фиксируем baseline (должно проходить ДО переделки)**

Run: `cd app && composer test -- --filter=NoDoubleChargeOnSourceChange`
Expected: PASS (merge схлопывает по project_id). Это страховка — она должна остаться зелёной ПОСЛЕ Task 2.2/2.6.

- [ ] **Step 3: Commit**

```bash
git add app/tests/Feature/Billing/NoDoubleChargeOnSourceChangeTest.php
git commit -m "test/billing: страховка от двойного списания CSV+webhook вокруг смены источника"
```

> ⚠️ Эту страховку гонять ПОСЛЕ Task 2.2 и 2.6 — если позеленела до и покраснела после, переделка матча развела project_id у CSV и webhook (шов §9b «двойное списание»).

---

## Эпик 3 — UX: баннеры и подтверждение вместо замка (фронт)

⚠️ Не выкатывать в прод раньше Эпика 2 (иначе обещание «лиды придут» — враньё).

### Task 3.1: Drawer — объявление вместо disabled-полей для количества/региона/дней

**Files:**
- Modify: `app/resources/js/components/projects/ProjectDetailsDrawer.vue`
- Test: `app/tests/Frontend/ProjectDetailsDrawer.spec.ts`

- [ ] **Step 1: Тест (Vitest) — при изменении лимита/региона после порога показывается объявление «с послезавтра», поля НЕ заблокированы**

```ts
it('показывает объявление о вступлении с послезавтра, не блокируя поля лимита/региона', async () => {
  const wrapper = mountDrawer({ project: { source_locked: true, source_unlock_at: '2026-06-27T21:00:00+03:00' } });
  await setLimit(wrapper, 20);
  expect(wrapper.find('[data-test=applies-from-banner]').exists()).toBe(true);
  expect(limitInput(wrapper).attributes('disabled')).toBeUndefined();
});
```

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

Run: `cd app && npm run test:vue -- ProjectDetailsDrawer`
Expected: FAIL.

- [ ] **Step 3: Реализовать баннер `applies-from-banner`**

Текст (§4 старой спеки, переформулирован): «Мы уже ведём сбор на завтра. Изменения вступят в силу с {дата}». Поля количество/регион/дни НЕ disabled.

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

Run: `cd app && npm run test:vue -- ProjectDetailsDrawer`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add app/resources/js/components/projects/ProjectDetailsDrawer.vue app/tests/Frontend/ProjectDetailsDrawer.spec.ts
git commit -m "feat/ui: объявление о вступлении изменений вместо блокировки полей лимит/регион/дни"
```

### Task 3.2: Drawer — подтверждение при смене источника / удалении

**Files:**
- Modify: `app/resources/js/components/projects/ProjectDetailsDrawer.vue`
- Test: `app/tests/Frontend/ProjectDetailsDrawer.spec.ts`

- [ ] **Step 1: Тест — смена источника при source_locked открывает диалог подтверждения, сохранение только после подтверждения**

```ts
it('требует подтверждения при смене источника на залоченном проекте', async () => {
  const wrapper = mountDrawer({ project: { source_locked: true } });
  await setSource(wrapper, 'NewSender');
  await clickSave(wrapper);
  expect(wrapper.find('[data-test=source-change-confirm]').exists()).toBe(true);
  expect(savedPayload()).toBeNull(); // ещё не сохранили
  await confirm(wrapper);
  expect(savedPayload()).toMatchObject({ sms_senders: ['NewSender'] });
});
```

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

Run: `cd app && npm run test:vue -- ProjectDetailsDrawer`
Expected: FAIL.

- [ ] **Step 3: Реализовать диалог `source-change-confirm`**

Текст: «Мы уже ведём сбор на завтра. Лиды по старому источнику придут завтра в любом случае. Послезавтра — уже нет. Подтвердите смену источника.» Сохранение источника — только после подтверждения.

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

Run: `cd app && npm run test:vue -- ProjectDetailsDrawer`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add app/resources/js/components/projects/ProjectDetailsDrawer.vue app/tests/Frontend/ProjectDetailsDrawer.spec.ts
git commit -m "feat/ui: подтверждение смены источника/удаления вместо жёсткого замка"
```

---

## Эпик 4 — Онлайн-режим: заморозка отправки 18:00→00:00 + досыл очередью

Цель: закрыть рассинхрон онлайна (§6.1 разбора). Офлайн уже корректен — не трогаем.

### Task 4.1: Онлайн откладывает отправку поставщику в окне 18:00→00:00

**Files:**
- Modify: `app/app/Jobs/SyncSupplierProjectJob.php` (`handle`, [строки 80-95](../../../app/app/Jobs/SyncSupplierProjectJob.php#L80-L95))
- Test: `app/tests/Feature/Supplier/OnlineDeferWindowTest.php` (создать)

- [ ] **Step 1: Тест — правка в окне 18:00→00:00 не шлёт поставщику немедленно, а ставит в отложенную очередь**

```php
it('в окне 18:00→00:00 онлайн не шлёт поставщику, а откладывает', function () {
    \Carbon\CarbonImmutable::setTestNow(\Carbon\CarbonImmutable::parse('2026-06-25 19:00', 'Europe/Moscow'));
    $project = makeOnlineModeProject();
    \App\Jobs\SyncSupplierProjectJob::dispatchSync($project->id);
    expect(\DB::table('supplier_deferred_sync')->where('project_id', $project->id)->exists())->toBeTrue();
    // и НЕ было вызова портала (мокнутый SupplierPortalClient — 0 saveProjectMultiFlag)
});
```

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

Run: `cd app && composer test -- --filter=OnlineDeferWindow`
Expected: FAIL.

- [ ] **Step 3: Реализовать оконную проверку**

В `handle`: если режим online И текущее МСК-время в окне [18:00, 00:00) — записать проект в отложенную очередь (`supplier_deferred_sync`, простая таблица project_id+created_at, ON CONFLICT DO NOTHING) вместо немедленного `handleOnline`. Вне окна — как сейчас.

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

Run: `cd app && composer test -- --filter=OnlineDeferWindow`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add app/app/Jobs/SyncSupplierProjectJob.php app/tests/Feature/Supplier/OnlineDeferWindowTest.php
git commit -m "fix/supplier: онлайн откладывает отправку поставщику в окне 18:00-00:00 (защита слепка 21:00)"
```

### Task 4.2: Джоб досыла отложенной очереди в 00:00

**Files:**
- Create: `app/app/Jobs/Supplier/FlushDeferredOnlineSyncJob.php`
- Modify: `app/routes/console.php` (после `projects:reset-delivered-today` в 00:00)
- Test: `app/tests/Feature/Supplier/FlushDeferredOnlineSyncTest.php` (создать)

- [ ] **Step 1: Тест — в 00:00 отложенные проекты синкаются и очередь очищается**

```php
it('досылает отложенные онлайн-правки и чистит очередь', function () {
    $project = makeDeferredProject();
    \App\Jobs\Supplier\FlushDeferredOnlineSyncJob::dispatchSync();
    expect(\DB::table('supplier_deferred_sync')->count())->toBe(0);
    // и был вызван SyncSupplierProjectJob/handleOnline для project
});
```

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

Run: `cd app && composer test -- --filter=FlushDeferredOnlineSync`
Expected: FAIL.

- [ ] **Step 3: Реализовать джоб + расписание**

Джоб читает `supplier_deferred_sync`, для каждого диспатчит `SyncSupplierProjectJob` (вне окна — он отработает немедленно), удаляет строку. В `routes/console.php`:

```php
Schedule::job(new \App\Jobs\Supplier\FlushDeferredOnlineSyncJob)
    ->dailyAt('00:05')->timezone('Europe/Moscow')
    ->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\FlushDeferredOnlineSyncJob', true, null, null))
    ->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\FlushDeferredOnlineSyncJob', false, 'Job failed', null));
```

(00:05, после сброса счётчиков в 00:00.)

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

Run: `cd app && composer test -- --filter=FlushDeferredOnlineSync`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add app/app/Jobs/Supplier/FlushDeferredOnlineSyncJob.php app/routes/console.php app/tests/Feature/Supplier/FlushDeferredOnlineSyncTest.php
git commit -m "feat/supplier: джоб досыла отложенной онлайн-очереди в 00:05 МСК"
```

---

## Эпик 5 — Отчёт о завершении вечерней заливки (OPEN-вопрос §9a)

Зависит от решения владельца по формату (письмо vs экран). Реализовывать после подтверждения.

### Task 5.1: Сводка по завершении SyncSupplierProjectsJob

**Files:**
- Modify: `app/app/Jobs/Supplier/SyncSupplierProjectsJob.php` (в конце `handle`)
- Create: `app/app/Mail/SupplierSyncSummaryMail.php`
- Test: `app/tests/Feature/Supplier/SyncSummaryReportTest.php` (создать)

- [ ] **Step 1: Тест — по завершении формируется сводка (групп N, создано M, в ручной очереди K)**

```php
it('по завершении вечерней заливки шлёт сводку с итогами', function () {
    Mail::fake();
    runSyncWithGroups(updated: 3, created: 1, manualQueued: 1);
    Mail::assertQueued(\App\Mail\SupplierSyncSummaryMail::class, function ($m) {
        return $m->updated === 3 && $m->created === 1 && $m->manualQueued === 1;
    });
});
```

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

Run: `cd app && composer test -- --filter=SyncSummaryReport`
Expected: FAIL.

- [ ] **Step 3: Реализовать сбор сводки + письмо**

В конце `handle()`: собрать счётчики из `SupplierSyncLog` за текущий прогон + `SupplierManualSyncQueue` (pending), отправить `SupplierSyncSummaryMail` на `services.supplier.alert_email`. Письмо до 21:00 — успеть вмешаться в окно ручной доливки.

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

Run: `cd app && composer test -- --filter=SyncSummaryReport`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add app/app/Jobs/Supplier/SyncSupplierProjectsJob.php app/app/Mail/SupplierSyncSummaryMail.php app/tests/Feature/Supplier/SyncSummaryReportTest.php
git commit -m "feat/supplier: сводный отчёт о завершении вечерней заливки для сверки глазами"
```

---

## Эпик 6 — Уведомления и объяснение правил клиенту (чтобы понимал «как сейчас»)

Цель: клиент должен ПОНИМАТЬ текущие правила сбора — почему изменения вступают не сразу, почему после паузы ещё приходят лиды («хвост»), когда пойдут первые. Переиспользуем существующую систему: `NotificationService::notifyInApp` (колокольчик, откатываемо) + опц. email-дайджест. НЕ синхронный `Mail::send` внутри транзакций (см. §9c).

**Точки уведомлений (правила «как сейчас») — единый источник текстов:**

| Событие | Текст (in-app + баннер) | Объясняет правило |
|---|---|---|
| Проект создан | «Проект в сборе. Первые лиды пойдут с {дата}.» | слепок: эффект со следующего дня |
| Изменён лимит/регион/дни после порога | «Изменения вступят в силу с {дата}.» | правка после 18:00 → завтра/послезавтра |
| Смена источника подтверждена | «Лиды по старому источнику придут до {дата}, дальше — по новому.» | хвост старого источника |
| Проект на паузе | «Сбор остановится сегодня в 18:00. До {дата} ещё могут прийти уже заказанные лиды — это нормально.» | grace-хвост после паузы |
| Проект возобновлён | «Проект снова в сборе. Лиды пойдут с {дата}.» | слепок на следующий день |
| Баланс заканчивается | «Баланса хватит примерно на {N} лидов. Пополните, чтобы сбор не остановился.» | preflight/заморозка (RunwayCalculator) |

### Task 6.1: Сервис текстов правил (единый источник, без дублей)

**Files:**
- Create: `app/app/Services/Project/ProjectRuleMessages.php`
- Test: `app/tests/Unit/Services/Project/ProjectRuleMessagesTest.php`

- [ ] **Step 1: Тест — каждое событие даёт текст с корректной датой**

```php
it('формирует текст «вступит в силу» с датой из appliesFrom', function () {
    $msg = app(\App\Services\Project\ProjectRuleMessages::class)
        ->changesApplyFrom(\Carbon\CarbonImmutable::parse('2026-06-27 21:00', 'Europe/Moscow'));
    expect($msg)->toContain('27 июня');
});
```

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

Run: `cd app && composer test -- --filter=ProjectRuleMessages`
Expected: FAIL.

- [ ] **Step 3: Реализовать сервис**

6 методов по таблице выше, форматирование даты «D MMMM» / «после 21:00». Тексты — единственный источник (фронт-баннеры Эпик 3 и in-app тянут отсюда через API/i18n, не дублируют строки).

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

Run: `cd app && composer test -- --filter=ProjectRuleMessages`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add app/app/Services/Project/ProjectRuleMessages.php app/tests/Unit/Services/Project/ProjectRuleMessagesTest.php
git commit -m "feat/projects: единый источник текстов правил сбора для клиента"
```

### Task 6.2: In-app уведомления на ключевые события проекта

**Files:**
- Modify: `app/app/Services/Project/ProjectService.php` (после update/create — нотификация)
- Modify: `app/app/Services/NotificationService.php` (метод `notifyProjectRule` если нужен)
- Test: `app/tests/Feature/Notifications/ProjectRuleNotificationTest.php`

- [ ] **Step 1: Тест — смена источника шлёт in-app с правилом про хвост**

```php
it('шлёт клиенту in-app уведомление о правиле при смене источника', function () {
    $project = makeSupplierLinkedProject(isActive: true);
    app(\App\Services\Project\ProjectService::class)->update($project, ['sms_senders' => ['NewSender']]);
    expect(\App\Models\InAppNotification::where('tenant_id', $project->tenant_id)
        ->where('body', 'like', '%по старому источнику%')->exists())->toBeTrue();
});
```

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

Run: `cd app && composer test -- --filter=ProjectRuleNotification`
Expected: FAIL.

- [ ] **Step 3: Реализовать вызовы notifyInApp с текстами из ProjectRuleMessages**

На событиях: создан / изменён slepok-поле после порога / смена источника / пауза / возобновление. Через `notifyInApp` (НЕ синхронный email). Идемпотентно (одно уведомление на действие).

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

Run: `cd app && composer test -- --filter=ProjectRuleNotification`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add app/app/Services/Project/ProjectService.php app/app/Services/NotificationService.php app/tests/Feature/Notifications/ProjectRuleNotificationTest.php
git commit -m "feat/notifications: in-app уведомления клиенту о правилах сбора при действиях с проектом"
```

### Task 6.3: Баннеры/подсказки на экране тянут тексты из единого источника

**Files:**
- Modify: `app/resources/js/components/projects/ProjectDetailsDrawer.vue` (Эпик 3 баннеры → тексты из API)
- Test: `app/tests/Frontend/ProjectDetailsDrawer.spec.ts`

- [ ] **Step 1: Тест — баннер показывает текст правила из ответа API, не хардкод**

```ts
it('баннер берёт текст правила из rule_message ответа, не из хардкода', async () => {
  const wrapper = mountDrawer({ project: { source_locked: true, rule_message: 'Лиды по старому источнику придут до 27 июня…' } });
  expect(wrapper.find('[data-test=rule-banner]').text()).toContain('27 июня');
});
```

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

Run: `cd app && npm run test:vue -- ProjectDetailsDrawer`
Expected: FAIL.

- [ ] **Step 3: Реализовать — баннеры читают `rule_message`/`applies_from` из ProjectResource**

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

Run: `cd app && npm run test:vue -- ProjectDetailsDrawer`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add app/resources/js/components/projects/ProjectDetailsDrawer.vue app/tests/Frontend/ProjectDetailsDrawer.spec.ts
git commit -m "feat/ui: баннеры правил тянут единый текст из API (без дублирования строк)"
```

---

## Финальная проверка (после всех эпиков)

- [ ] **Полная регрессия:** `/regression full` (Pest --parallel, Larastan, Vitest, Vite build, lychee, gitleaks). Вердикт GREEN.
- [ ] **Самопроверка денежного ядра:** ни один lead-тест не сломан; baseline 0.1 зелёный; новые 2.1/2.3/2.4 зелёные.
- [ ] **rls-reviewer** на правки supplier-flow (новые таблицы `supplier_deferred_sync` — RLS/GRANT по 5-ролевой модели).
- [ ] **prod-deploy-validator** перед любым выкатом на liderra.ru.
- [ ] **Документы-следствия:** обновить §6 CLAUDE.md / память по итогу; закрыть OPEN-вопрос §9a в реестре открытых вопросов.

---

## Карта зависимостей эпиков

```
Эпик 0 (страховка) ──► Эпик 2 (ЯДРО, деньги, нужен «делай» + design-gate 2.0)
                              │
Эпик 1 (read-only поля) ──────┤
                              ▼
                        Эпик 3 (UX) — выкат ТОЛЬКО после Эпика 2
Эпик 4 (онлайн-заморозка) — независим, можно параллельно
Эпик 5 (отчёт) — независим, после решения по формату (OPEN §9a)
Эпик 6 (уведомления правил) — тексты (6.1) независимы; in-app (6.2) после Эпика 2; баннеры (6.3) вместе с Эпиком 3
```
