docs(приёмка): ещё 11 планов-отчётов — движок добит + UI-блок

Движок Фазы 3: 02 второй круг, 04 лимит/пауза под локом, 06 каналы/парсинг,
12 денежный аудит, 11 лента денег/калькуляторы, 15 сделки переживают удаление,
16 изоляция. UI-блок R3b: 19 гейт реквизитов+ИНН, 20 колокольчик+дайджест,
22 отчёты, 23 импорт CSV.

Расхождения свода с кодом помечены как находки под проверку:
15 удаление проекта со сделками запрещено а не сохраняется,
20 дефолт email-дайджеста по схеме выключен,
22 PDF-формат проверить на штатность.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-20 15:36:30 +03:00
parent b6109e1485
commit 83f0b88d3e
11 changed files with 1109 additions and 0 deletions
@@ -0,0 +1,84 @@
# Приёмка liderra.ru — ПУНКТ №2: Второй круг (серия заявок раздаётся всем честно)
> **Для исполнителя:** формат PR1 (одобрен 20.06). Каждый шаг = `🔧 Код-факт (file:line)` · `🎬 Действие` · `📋 ОТЧЁТ (было→ожидали→стало + 📸/📝)` · `❌ Если не так`. Техника «под капотом»; владельцу — человеческие карточки.
**Цель:** показать, что на **серии** заявок (не одной, а потоком) раздача честная: со временем заявки достаются **всем** подходящим клиентам (не одни и те же снимают всё), и **никто не получает больше своего дневного лимита**. Портал отдаёт предпочтение тем, у кого больше свободных слотов — поэтому очередь выравнивается.
**Что снимаем глазами:** **Сделки** и **Баланс** всех 7 клиентов — у каждого появились заявки, ни у кого не больше лимита. Живые 📸.
**Источник сценария:** свод проверок №2 (D-ROUND); R3 «движок».
---
## 🔧 Код-факты (подтверждено чтением 20.06)
- **Жребий выравнивает по остатку лимита** — кандидаты сортируются `ORDER BY (snap.daily_limit projects.delivered_today) DESC` — [LeadRouter.php:154-157](../../../app/app/Services/LeadRouter.php#L154-L157); взвешенный отбор: вероятность ∝ остатку лимита, мелкие не отрезаются — [weightedPick :171](../../../app/app/Services/LeadRouter.php#L171).
- **Дневной лимит не превышается** — в отбор попадают только `delivered_today < daily_limit` — [:145](../../../app/app/Services/LeadRouter.php#L145).
- **CAP=3 на каждую заявку** — [LeadDistributor.php:22](../../../app/app/Services/LeadDistributor.php#L22): за серию из N заявок суммарно до N×3 доставок, распределённых по 7 клиентам в рамках их лимитов.
- Счётчик `delivered_today` растёт при каждой доставке — снижает шанс «перегруженного» клиента в следующих заявках.
---
## Шаг 2-1 — Влить серию (~15 заявок) на источник всех 7
**🔧 Код:** отбор/жребий — [LeadRouter :104-171](../../../app/app/Services/LeadRouter.php#L104-L171); CAP — [LeadDistributor :22-43](../../../app/app/Services/LeadDistributor.php#L22-L43).
**🎬 Действие (лок):** влить ~15 заявок подряд на источник, где подходят все 7 клиентов (это сценарий засева `imitation:seed --clients=7 --leads=15`).
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Получили заявки | 0 | **все 7 клиентов** обслужены (никто не пустой) | _(слот прогона)_ | 📸 Сделки каждого из 7 (есть заявки) |
| Распределение | — | примерно равномерно (жребий по остатку лимита) | _(слот)_ | 📸 сводка по 7 (близкие числа) |
| Всего доставок | — | ≈ 15×3 = 45 (по 3 на заявку) | _(слот)_ | 📝 SQL `COUNT deals` за серию |
**💡 Что внутри:** когда заявки идут потоком, портал следит, чтобы они доставались всем по очереди, а не оседали у первых. Он отдаёт предпочтение тем, у кого ещё много свободных мест на сегодня — поэтому отстающие догоняют, и через серию заявки получают все семеро примерно поровну.
**❌ Если не так:** часть клиентов осталась без заявок при свободном лимите → перекос раздачи.
---
## Шаг 2-2 — Никто не получил больше своего дневного лимита
**🔧 Код:** фильтр лимита — [LeadRouter :145](../../../app/app/Services/LeadRouter.php#L145).
**🎬 Действие (лок):** у каждого из 7 сверить число полученных за день с его дневным лимитом.
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Доставлено за день каждому | — | **≤ дневного лимита** у всех | _(слот)_ | 📸 Сделки/счётчик дня; 📝 SQL `delivered_today ≤ daily_limit` |
| Клиент у лимита | — | по достижении лимита больше не получает | _(слот)_ | 📸 его список перестал расти |
**💡 Что внутри:** у каждого клиента есть дневной потолок — сколько заявок в день он готов взять. Портал его соблюдает: как только клиент набрал свой лимит, новые заявки идут другим, а не сверх оплаченного объёма.
**❌ Если не так:** кто-то получил сверх лимита → дефект (клиент платит за лишнее).
---
## Шаг 2-3 — Деньги по серии сходятся у всех
**🔧 Код:** списание — [LedgerService :53-95](../../../app/app/Services/Billing/LedgerService.php#L53-L95).
**🎬 Действие (лок):** у нескольких клиентов сверить: число сделок × цена = уменьшение баланса.
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Баланс vs сделки | — | у каждого: списано = (число сделок × цена ступени) | _(слот)_ | 📸 Баланс + Сделки клиента |
**❌ Если не так:** баланс не бьётся с числом сделок → денежный дефект (связка с №7).
---
## Verification №2 (выход из пункта)
- [ ] 📸 Все 7 клиентов получили заявки (никто не пустой при свободном лимите).
- [ ] 📸 Распределение примерно равномерное (жребий по остатку лимита).
- [ ] 📸/📝 Никто не превысил дневной лимит.
- [ ] 📸 Деньги сходятся у каждого (сделки × цена = списано).
## 📸 vs 📝 в этом пункте
- **📸 Скриншот:** Сделки и Баланс всех 7 (или сводка), счётчик дня у клиента-у-лимита.
- **📝 Текст:** `COUNT deals`, `delivered_today` vs `daily_limit` — частично текстом.
## Грабли
- Жребий случайный — равномерность приблизительная, не точная; смотрим, что все обслужены и лимиты целы, а не идеальное равенство.
- Дневной лимит — отдельная страховка; «пауза на лету» — пункт №4.
- Связка с №1 (CAP на одну заявку) — здесь проверяем поведение на серии.
@@ -0,0 +1,98 @@
# Приёмка liderra.ru — ПУНКТ №4: Лимит и пауза «на лету» (под локом)
> **Для исполнителя:** формат PR1 (одобрен 20.06). Каждый шаг = `🔧 Код-факт (file:line)` · `🎬 Действие` · `📋 ОТЧЁТ (было→ожидали→стало + 📸/📝)` · `❌ Если не так`. Техника «под капотом»; владельцу — человеческие карточки.
**Цель:** показать, что если клиент **в любой момент** ставит свой проект на паузу — он **сразу** перестаёт получать заявки, даже ту, что прямо сейчас раздаётся (пауза срабатывает мгновенно, без задержки). И что под потоком заявок **дневной лимит не превышается** даже при одновременных доставках.
**Что снимаем глазами:** **Проекты** (поставлен на паузу), **Сделки** (паузнутый клиент не получил заявку), счётчик дня (не больше лимита). Живые 📸.
**Источник сценария:** свод проверок №4 (D-LIMIT/D-PAUSE); R3 «движок».
---
## 🔧 Код-факты (подтверждено чтением 20.06)
- **Пауза «на лету» под локом** — перед доставкой проект блокируется (`lockForUpdate`) и **заново** проверяется `is_active`; если клиент нажал «пауза» в окне между отбором и доставкой — заявку **не доставляем** (`paused under lock = stop`), сделка не создаётся, баланс не трогается — [RouteSupplierLeadJob.php:283-301](../../../app/app/Jobs/RouteSupplierLeadJob.php#L283-L301).
- **Лимит под локом** — лимит берётся из зафиксированного слепка, проверяется под блокировкой; пока шла раздача, параллельная доставка могла добить лимит → лишнюю не доставляем — [:280-282](../../../app/app/Jobs/RouteSupplierLeadJob.php#L280-L282), [:303-316](../../../app/app/Jobs/RouteSupplierLeadJob.php#L303-L316).
- Переключатель паузы клиента — `PATCH /api/projects/{id}/toggle-active`: `is_active=false` + `paused_at` + синк паузы к поставщику — [ProjectController.php:264-282](../../../app/app/Http/Controllers/Api/ProjectController.php#L264-L282).
- Фильтр лимита в отборе — `delivered_today < daily_limit` — [LeadRouter.php:145](../../../app/app/Services/LeadRouter.php#L145).
---
## Шаг 4-1 — Поставить проект на паузу
**🔧 Код:** [ProjectController::toggleActive :264-282](../../../app/app/Http/Controllers/Api/ProjectController.php#L264-L282).
**🎬 Действие (лок):** под клиентом поставить его проект на паузу (переключатель в разделе Проекты).
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Проект | активен | **на паузе** (выключен приём) | _(слот прогона)_ | 📸 Проекты (статус «на паузе») |
**❌ Если не так:** пауза не ставится → проверить переключатель.
---
## Шаг 4-2 — Паузнутый клиент НЕ получает заявку (даже текущую)
**🔧 Код:** recheck под локом — [RouteSupplierLeadJob :288-301](../../../app/app/Jobs/RouteSupplierLeadJob.php#L288-L301).
**🎬 Действие (лок):** влить заявку на источник, где этот клиент был бы подходящим; проверить, что ему она не пришла.
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Сделка у паузнутого | — | **не создана** (на паузе = не получает) | _(слот)_ | 📸 Сделки этого клиента (без новой) |
| Баланс паузнутого | X ₽ | **не изменился** | _(слот)_ | 📸 Баланс до = после |
| Заявка у других | — | ушла другим подходящим (поток не сломан) | _(слот)_ | 📸 Сделки другого клиента (есть) |
**💡 Что внутри:** когда клиент ставит проект на паузу, портал перестаёт давать ему заявки **немедленно** — даже ту, что в этот самый момент раздавалась. Перед каждой выдачей он ещё раз сверяется: «проект всё ещё активен?» Если нет — заявку этому клиенту не отдаёт (и денег не списывает), а отправляет другим. Пауза работает мгновенно, без «доедет завтра».
**❌ Если не так:** паузнутый клиент получил заявку / списались деньги → дефект (пауза не мгновенная).
---
## Шаг 4-3 — Снять паузу → снова получает
**🔧 Код:** [toggleActive :264-282](../../../app/app/Http/Controllers/Api/ProjectController.php#L264-L282).
**🎬 Действие (лок):** снять паузу; влить заявку; проверить доставку.
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Проект | на паузе | снова активен | _(слот)_ | 📸 Проекты (активен) |
| Доставка после снятия | — | заявки снова приходят | _(слот)_ | 📸 Сделки (новая) |
**❌ Если не так:** после снятия паузы не получает → дефект возобновления.
---
## Шаг 4-4 — Дневной лимит не превышается под потоком
**🔧 Код:** лимит под локом — [RouteSupplierLeadJob :303-316](../../../app/app/Jobs/RouteSupplierLeadJob.php#L303-L316); фильтр — [LeadRouter :145](../../../app/app/Services/LeadRouter.php#L145).
**🎬 Действие (лок):** клиенту с маленьким лимитом (например 2) влить поток заявок быстрее, чем по одной; проверить, что доставок ровно по лимиту.
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Доставлено под потоком | 0 | **ровно лимит** (например 2), не больше | _(слот)_ | 📸 Сделки клиента; 📝 `delivered_today = daily_limit` |
**💡 Что внутри:** даже если несколько заявок «прилетают» клиенту почти одновременно, портал не даст превысить его дневной лимит: перед выдачей он под «замком» проверяет, не добит ли лимит уже параллельной заявкой. Лишнюю не отдаёт.
**❌ Если не так:** доставок больше лимита под потоком → дефект (гонка пробивает лимит).
---
## Verification №4 (выход из пункта)
- [ ] 📸 Проект ставится на паузу и снимается.
- [ ] 📸 На паузе клиент не получает заявку (и денег не списывают); заявка уходит другим.
- [ ] 📸 После снятия паузы снова получает.
- [ ] 📸/📝 Под потоком лимит не превышен.
## 📸 vs 📝 в этом пункте
- **📸 Скриншот:** Проекты (пауза/актив), Сделки паузнутого (без новой) и другого (с новой), счётчик дня у лимита.
- **📝 Текст:** проверка `is_active`/лимита под локом, `delivered_today` — частично текстом.
## Грабли
- «Пауза на лету» — тонкий race: проверять, что пауза, нажатая в момент раздачи, успевает сработать (recheck под локом).
- Лимит из слепка (зафиксирован на 18:00/21:00 МСК) — уменьшение лимита после слепка не рвёт уже зафиксированный поток (это by design, не дефект).
- Связка с №9 (автопауза при нехватке) — там пауза ставит сам портал; здесь — клиент вручную.
@@ -0,0 +1,101 @@
# Приёмка liderra.ru — ПУНКТ №6: Каналы и распознавание заявки (площадка + тип)
> **Для исполнителя:** формат PR1 (одобрен 20.06). Каждый шаг = `🔧 Код-факт (file:line)` · `🎬 Действие` · `📋 ОТЧЁТ (было→ожидали→стало + 📸/📝)` · `❌ Если не так`. Техника «под капотом»; владельцу — человеческие карточки.
**Цель:** показать, что портал **правильно распознаёт** каждую входящую заявку: с какой **площадки** она пришла (B1 / B2 / B3 / напрямую) и какого **типа** (заявка с сайта / звонок / смс). Распознавание устойчиво даже к «грязным» названиям, которые иногда присылает поставщик.
**Что снимаем глазами:** **Сделки** — у каждой видно источник и тип. Живые 📸.
**Источник сценария:** свод проверок №6 (D-DIRECT/PARSE); R3 «движок».
---
## 🔧 Код-факты (подтверждено чтением 20.06)
Разбор поля заявки — [RouteSupplierLeadJob::parseProjectField :216-249](../../../app/app/Jobs/RouteSupplierLeadJob.php#L216-L249):
- **Площадка** — по префиксу: `B1_…`/`B2_…`/`B3_…` → платформа B1/B2/B3; **без префикса → DIRECT** (заявка напрямую) — [:218-227](../../../app/app/Jobs/RouteSupplierLeadJob.php#L218-L227).
- **Тип заявки** — по форме «хвоста»:
- `^7\d{10}$` (телефон 11 цифр с 7) → **звонок** (`call`) — [:233-235](../../../app/app/Jobs/RouteSupplierLeadJob.php#L233-L235);
- чистый домен `site.ru`**сайт** (`site`) — [:236-238](../../../app/app/Jobs/RouteSupplierLeadJob.php#L236-L238);
- домен, **встроенный в свободный текст** (например `заявка carmoney.ru/`) → всё равно **сайт** (извлекается; регрессия-фикс 18.05.2026, 21 лид) — [:239-242](../../../app/app/Jobs/RouteSupplierLeadJob.php#L239-L242);
- иначе → **смс** (`sms`, короткое имя отправителя) — [:243-246](../../../app/app/Jobs/RouteSupplierLeadJob.php#L243-L246).
---
## Случаи для проверки
| # | Что приходит (поле `project`) | Площадка | Тип |
|---|---|---|---|
| K1 | `B2_test-okna.ru` | B2 | сайт |
| K2 | `B1_79990001122` | B1 | звонок |
| K3 | `B3_TESTSMS` | B3 | смс |
| K4 | `test-direct.ru` (без префикса) | DIRECT | сайт |
| K5 | `B2_заявка carmoney.ru/` (грязный текст) | B2 | сайт (домен извлечён) |
---
## Шаг 6-1 — Сайт / звонок / смс распознаются верно (B-каналы)
**🔧 Код:** [parseProjectField :233-246](../../../app/app/Jobs/RouteSupplierLeadJob.php#L233-L246).
**🎬 Действие (лок):** влить три заявки — K1 (сайт), K2 (звонок), K3 (смс); открыть сделки.
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| K1 | — | сделка: источник B2, тип **сайт** | _(слот прогона)_ | 📸 Сделка K1 (источник/тип) |
| K2 | — | сделка: источник B1, тип **звонок** | _(слот)_ | 📸 Сделка K2 |
| K3 | — | сделка: источник B3, тип **смс** | _(слот)_ | 📸 Сделка K3 |
**💡 Что внутри:** заявки приходят с разных площадок поставщика и в разном виде — где-то телефон для звонка, где-то адрес сайта, где-то короткое смс-имя. Портал по виду заявки сам понимает, что это: звонок, заявка с сайта или смс — и помечает сделку правильно. Клиент сразу видит, откуда пришёл контакт.
**❌ Если не так:** звонок помечен как смс / сайт как звонок → дефект распознавания.
---
## Шаг 6-2 — Заявка «напрямую» (DIRECT) без префикса
**🔧 Код:** [parseProjectField :221-227](../../../app/app/Jobs/RouteSupplierLeadJob.php#L221-L227).
**🎬 Действие (лок):** влить K4 (`test-direct.ru` без `B_`-префикса).
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| K4 | — | площадка **DIRECT** (напрямую), тип сайт | _(слот)_ | 📸 Сделка K4 (источник DIRECT) |
**💡 Что внутри:** часть заявок приходит не через площадки-посредники, а напрямую. Такие портал помечает как «прямые» — и обрабатывает по своему пути (без передачи внешнему поставщику).
**❌ Если не так:** прямая заявка отнесена к B-каналу → дефект.
---
## Шаг 6-3 — Устойчивость: «грязное» название всё равно распознано
**🔧 Код:** извлечение домена из текста — [parseProjectField :239-242](../../../app/app/Jobs/RouteSupplierLeadJob.php#L239-L242).
**🎬 Действие (лок):** влить K5 (`B2_заявка carmoney.ru/` — домен внутри текста).
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| K5 | — | тип **сайт**, домен `carmoney.ru` извлечён | _(слот)_ | 📸 Сделка K5 (тип сайт, источник) |
**💡 Что внутри:** поставщик иногда присылает название площадки неаккуратно — с лишними словами, как «заявка carmoney.ru/». Портал всё равно находит в этой строке адрес сайта и распознаёт заявку правильно. Раньше из-за таких «грязных» названий терялись заявки (находка 18.05) — теперь это учтено.
**❌ Если не так:** грязное название отнесено к смс / заявка потеряна → рецидив регрессии 18.05.
---
## Verification №6 (выход из пункта)
- [ ] 📸 K1 сайт (B2), K2 звонок (B1), K3 смс (B3) — типы и площадки верны.
- [ ] 📸 K4 DIRECT (без префикса) — помечена как прямая.
- [ ] 📸 K5 грязное название → сайт, домен извлечён.
- [ ] 📝 (опц.) сверка `signal_type`/площадки сделки с ожиданием по таблице.
## 📸 vs 📝 в этом пункте
- **📸 Скриншот:** Сделки — источник и тип каждой из 5 заявок.
- **📝 Текст:** распарсенные `platform`/`signal_type` — при необходимости текстом.
## Грабли
- Распознавание идёт по виду строки — для теста слать ровно те формы из таблицы K1–K5.
- DIRECT в распознавании = «без B-префикса»; не путать с площадками B1/B2/B3.
- Грязные названия — реальный кейс поставщика (18.05); проверять именно встроенный-в-текст домен.
@@ -0,0 +1,104 @@
# Приёмка liderra.ru — ПУНКТ №11: Лента денег = база + калькуляторы («хватит на… дней»)
> **Для исполнителя:** формат PR1 (одобрен 20.06). Каждый шаг = `🔧 Код-факт (file:line)` · `🎬 Действие` · `📋 ОТЧЁТ (было→ожидали→стало + 📸/📝)` · `❌ Если не так`. Техника «под капотом»; владельцу — человеческие карточки.
**Цель:** показать, что кошелёк клиента честный: число в кошельке = тому, что в базе; история списаний = реальным операциям, остаток в ней **только уменьшается** (монотонно, без скачков вверх без причины); подсказка «хватит на… дней» считается **правильно** и совпадает между Дашбордом и Биллингом; выгрузку истории в файл (CSV) можно скачать, и она = тому, что на экране.
**Что снимаем глазами:** **Биллинг** (кошелёк + лента списаний с остатком), **Дашборд** («хватит на дней»), файл выгрузки. Живые 📸.
**Источник сценария:** свод проверок №11 (M-LEDGER/M-CALC); R3 «движок».
---
## 🔧 Код-факты (подтверждено чтением 20.06)
- **Кошелёк** — `GET /api/billing/wallet`: `balance_rub` + конверсия в лиды — [BillingController.php:78-103](../../../app/app/Http/Controllers/Api/BillingController.php#L78-L103).
- **История денег** — `GET /api/billing/transactions`: пагинированная лента `balance_transactions` (20/стр), у каждой строки `display_amount_rub` и остаток после — [:223-241](../../../app/app/Http/Controllers/Api/BillingController.php#L223-L241).
- **Список списаний** — `GET /api/billing/charges`, сортировка `charged_at desc` — [TenantChargesController::index :37-46](../../../app/app/Http/Controllers/Api/TenantChargesController.php#L37-L46).
- **Выгрузка CSV списаний** — `POST /api/billing/charges/export`, потоком + чанками, колонки `charged_at, deal_id, tier_no, charge_source, price_rub, balance_rub_after` (остаток после) — [export :63-147](../../../app/app/Http/Controllers/Api/TenantChargesController.php#L63-L147), заголовок — [:85](../../../app/app/Http/Controllers/Api/TenantChargesController.php#L85).
- **«Хватит на… дней»** — `RunwayCalculator::daysLeft(tenant, affordableLeads)`; affordableLeads = ёмкость баланса в лидах по тарифу — [DashboardController :122-124](../../../app/app/Http/Controllers/Api/DashboardController.php#L122-L124). **Единый источник с биллингом** (фикс F3 17.06) — [:110](../../../app/app/Http/Controllers/Api/DashboardController.php#L110).
- **Ёмкость баланса в лидах** — `BalanceToLeadsConverter` (по ступеням, bcmath) — [:86-87](../../../app/app/Services/Billing/BalanceToLeadsConverter.php#L86-L87); статус/дефицит — [BillingController::balanceStatus :125-161](../../../app/app/Http/Controllers/Api/BillingController.php#L125-L161).
---
## Шаг 11-1 — Кошелёк = база
**🔧 Код:** [BillingController::wallet :78-103](../../../app/app/Http/Controllers/Api/BillingController.php#L78-L103).
**🎬 Действие (лок):** открыть Биллинг клиента; сверить число кошелька с базой.
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Кошелёк на экране | — | = `balance_rub` в базе (до копейки) | _(слот прогона)_ | 📸 Биллинг (кошелёк); 📝 SQL `balance_rub` |
**❌ Если не так:** экран ≠ база → дефект отображения денег.
---
## Шаг 11-2 — Лента списаний = операциям, остаток только уменьшается
**🔧 Код:** [BillingController::transactions :223-241](../../../app/app/Http/Controllers/Api/BillingController.php#L223-L241); список — [TenantChargesController::index :37-46](../../../app/app/Http/Controllers/Api/TenantChargesController.php#L37-L46).
**🎬 Действие (лок):** открыть историю списаний; сверить число строк и проследить остаток сверху вниз.
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Число строк | — | = числу операций в базе | _(слот)_ | 📸 лента; 📝 SQL `COUNT balance_transactions` |
| Суммы | — | каждая = реальному списанию (цена тарифа) | _(слот)_ | 📸 строки списаний |
| Остаток после | — | **монотонно уменьшается** по ходу списаний (вверх — только при пополнении) | _(слот)_ | 📸 колонка остатка; 📝 `balance_rub_after` убывает |
**💡 Что внутри:** в истории видно каждое движение денег: сколько списали и сколько осталось после. Остаток идёт ровно вниз — на каждое списание уменьшается на цену заявки. Вверх он прыгает только когда клиент пополняет счёт. Никаких необъяснимых скачков — историю можно сверить копейка в копейку.
**❌ Если не так:** остаток скачет вверх без пополнения / суммы ≠ спискам → денежный дефект.
---
## Шаг 11-3 — «Хватит на… дней» считается верно и одинаково везде
**🔧 Код:** [RunwayCalculator :122-124](../../../app/app/Http/Controllers/Api/DashboardController.php#L122-L124); единый источник — [:110](../../../app/app/Http/Controllers/Api/DashboardController.php#L110); ёмкость — [BalanceToLeadsConverter :86-87](../../../app/app/Services/Billing/BalanceToLeadsConverter.php#L86-L87).
**🎬 Действие (лок):** сверить «хватит на дней» на Дашборде и в Биллинге у одного клиента.
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| «Хватит на дней» (Дашборд) | — | разумное число от баланса и темпа | _(слот)_ | 📸 Дашборд |
| То же в Биллинге | — | **совпадает** с Дашбордом (единый источник) | _(слот)_ | 📸 Биллинг |
| Ёмкость в лидах | — | баланс ÷ цена ступени = верное число лидов | _(слот)_ | 📸 индикатор ёмкости |
**💡 Что внутри:** портал подсказывает клиенту, на сколько ещё дней хватит денег — считая по его балансу и темпу получения заявок. Раньше эта цифра на двух экранах могла различаться (находка F3) — теперь источник один, и Дашборд с Биллингом показывают одно и то же.
**❌ Если не так:** Дашборд ≠ Биллинг по «хватит на дней» (рецидив F3) / ёмкость неверная → дефект калькулятора.
---
## Шаг 11-4 — Выгрузка истории в CSV = экрану
**🔧 Код:** [TenantChargesController::export :63-147](../../../app/app/Http/Controllers/Api/TenantChargesController.php#L63-L147).
**🎬 Действие (лок):** скачать CSV списаний; сверить с лентой на экране.
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Строки в файле | — | = ленте (дата, сделка, ступень, цена, остаток после) | _(слот)_ | 📸 файл + сверка с экраном |
| Только свои | — | чужих списаний нет | _(слот)_ | 📝 все строки своего тенанта |
**❌ Если не так:** в файле чужое / расходится с экраном → дефект.
> ⚠️ Этот CSV пишется через `fputcsv` (как и экспорт сделок №14) — **та же** оговорка про CSV-инъекцию: проверить на прогоне, нейтрализуются ли ведущие `= + - @` (см. №14 Шаг 14-4).
---
## Verification №11 (выход из пункта)
- [ ] 📸 Кошелёк = база (до копейки).
- [ ] 📸 Лента списаний = операциям; остаток монотонно уменьшается.
- [ ] 📸 «Хватит на дней» Дашборд = Биллинг (единый источник, F3 не рецидивит).
- [ ] 📸 CSV списаний = экрану; только свои.
## 📸 vs 📝 в этом пункте
- **📸 Скриншот:** Биллинг (кошелёк, лента, остаток), Дашборд («хватит на дней»), файл CSV.
- **📝 Текст:** `COUNT`/`balance_rub_after`/принадлежность тенанту — частично текстом.
## Грабли
- F3 (расхождение «хватит на дней» Дашборд↔Биллинг) — теперь единый источник; на прогоне явно сверить оба экрана.
- Остаток вверх — только при пополнении (topup); любой иной рост — дефект.
- CSV-инъекция — общая с №14, отдельно подтвердить файлом.
@@ -0,0 +1,89 @@
# Приёмка liderra.ru — ПУНКТ №12: Денежный аудит (цепочка цела, правки задним числом заблокированы)
> **Для исполнителя:** формат PR1 (одобрен 20.06). Каждый шаг = `🔧 Код-факт (file:line)` · `🎬 Действие` · `📋 ОТЧЁТ (было→ожидали→стало + 📸/📝)` · `❌ Если не так`. Техника «под капотом»; владельцу — человеческие карточки.
**Цель:** показать, что денежные и важные записи **нельзя подделать задним числом**. Списания и журналы устроены как «несгораемая лента»: их можно только дописывать, но **нельзя ни изменить, ни удалить**. А если кто-то попытается влезть в базу в обход правил — портал это **обнаружит** (записи связаны в защищённую цепочку, разрыв сразу виден, приходит сигнал-тревога).
**Что снимаем глазами:** это **внутренняя защита базы** — экранов в кабинете нет. Доказываем **текстом** (📝): вывод проверки целостности + отказ при попытке правки. (Отдельного 📸 портала здесь нет — честно помечаем.)
**Источник сценария:** свод проверок №12 (M-AUDIT); R3 «движок».
---
## 🔧 Код-факты (подтверждено чтением 20.06)
- **Запрет правок (append-only) — реестр списаний:** `lead_charges` — только SELECT+INSERT для приложения, **UPDATE/DELETE недопустимы** (гарантия для биллинга/аудита) — [db/schema.sql:1175-1176](../../../db/schema.sql#L1175-L1176).
- **Запрет правок — журналы (триггеры):** 6 audit-таблиц + `balance_transactions` имеют пары триггеров: hash-fill при вставке + **block-mutation BEFORE UPDATE/DELETE → RAISE EXCEPTION** (запись становится неизменяемой) — [db/schema.sql:3301-3339](../../../db/schema.sql#L3301-L3339); функция-страж `audit_block_mutation()` — [:246](../../../db/schema.sql#L246), [:3247](../../../db/schema.sql#L3247).
- **Защищённая цепочка (hash-chain):** каждая запись хранит SHA-256 от «предыдущая запись + эта запись»; разорвать цепочку незаметно нельзя — [db/schema.sql:81-86](../../../db/schema.sql#L81-L86).
- **Обнаружение подмены:** `audit:verify-chains` пересчитывает hash-chain во всех 6 таблицах (по партициям, на стороне PostgreSQL); при разрыве → инцидент + письмо на kdv1@bk.ru + код возврата FAILURE — [VerifyAuditChains.php:87-135](../../../app/app/Console/Commands/VerifyAuditChains.php#L87-L135). Запускается ежедневно 04:00.
- Роль `crm_audit_writer` — только INSERT, REVOKE UPDATE/DELETE даже у неё — [db/schema.sql:86](../../../db/schema.sql#L86).
---
## Шаг 12-1 — Проверка целостности: «всё цело»
**🔧 Код:** [VerifyAuditChains :87-135](../../../app/app/Console/Commands/VerifyAuditChains.php#L87-L135).
**🎬 Действие (лок):** запустить `php artisan audit:verify-chains`.
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Результат проверки | — | «All audit chains intact» (все цепочки целы) | _(слот прогона)_ | 📝 вывод команды (текст) |
| Код возврата | — | успех (0) | _(слот)_ | 📝 exit code 0 |
**💡 Что внутри:** портал каждую ночь сам проверяет, что ни одна денежная или важная запись не была подделана. Если всё в порядке — отвечает «всё цело». Это как ежедневная инвентаризация, которая ловит любое незаконное вмешательство.
**❌ Если не так:** проверка нашла разрыв на чистых данных → разбираться (реальное вмешательство или баг проверки).
---
## Шаг 12-2 — Правку задним числом не дают сделать (запрет)
**🔧 Код:** триггеры block-mutation — [db/schema.sql:3301-3339](../../../db/schema.sql#L3301-L3339); запрет на `lead_charges` — [:1175-1176](../../../db/schema.sql#L1175-L1176).
**🎬 Действие (лок, под обычной ролью приложения):** попробовать изменить или удалить строку списания / журнала.
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Попытка изменить списание | — | **отказ** (нет права / исключение) | _(слот)_ | 📝 ошибка БД (UPDATE/DELETE запрещён) |
| Попытка удалить запись журнала | — | **отказ** (RAISE EXCEPTION) | _(слот)_ | 📝 ошибка «mutation blocked» |
**💡 Что внутри:** даже сам портал (под обычными правами) не может переписать или стереть уже сделанную денежную запись. База физически запрещает это: запись можно только добавить. Поэтому «подправить» сумму списания или замести след задним числом невозможно.
**❌ Если не так:** правка/удаление прошли → денежный/аудиторский дефект (СТОП).
---
## Шаг 12-3 — Если влезть в обход правил — проверка это ловит (обнаружение)
**🔧 Код:** [VerifyAuditChains :101-135](../../../app/app/Console/Commands/VerifyAuditChains.php#L101-L135).
**🎬 Действие (лок, ТОЛЬКО на тестовой базе):** под суперправами подменить одну строку (имитация вмешательства в обход триггеров) → снова запустить `audit:verify-chains`.
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Проверка после подмены | «всё цело» | **разрыв обнаружен** (указан id и таблица) | _(слот)_ | 📝 вывод «mismatch, first broken id=…» |
| Реакция | — | инцидент записан + письмо-тревога + FAILURE | _(слот)_ | 📝 incidents_log + письмо (внутр.) |
**💡 Что внутри:** допустим, кто-то с прямым доступом к базе всё же изменил запись в обход защиты. Записи связаны в цепочку, как звенья: тронул одно — следующее перестаёт сходиться. Ночная проверка это сразу видит, поднимает тревогу (письмо владельцу) и фиксирует инцидент. Скрыть подмену невозможно.
**❌ Если не так:** проверка НЕ заметила подмену → дефект защиты целостности (СТОП).
> ⚠️ Шаг 12-3 выполнять **только на тестовой базе** — это намеренная порча данных для проверки сигнализации. На проде НЕ делать.
---
## Verification №12 (выход из пункта)
- [ ] 📝 `audit:verify-chains` на чистых данных → «all intact», exit 0.
- [ ] 📝 Попытка UPDATE/DELETE списания и журнала → отказ (право/триггер).
- [ ] 📝 (тест-база) Подмена в обход → проверка ловит разрыв + инцидент + письмо.
- [ ] 📝 6 audit-таблиц + lead_charges + balance_transactions под защитой.
## 📸 vs 📝 в этом пункте
- **📝 Текст (весь пункт):** вывод `audit:verify-chains`, отказ на правку, обнаружение разрыва, инцидент — это внутренняя защита базы, **экранов в кабинете нет**.
- **📸 Скриншот:** не применим (нет портального экрана). При желании — снимок окна с выводом команды (опц.).
## Грабли
- Шаг 12-3 (намеренная порча) — **только тест-база**, на проде запрещено.
- Это «под капотом» — владельцу объясняем словами (несгораемая лента + ночная инвентаризация), без скриншотов портала.
- Связка с №7 (тройная запись) и №9 (откат) — защищаются те же денежные таблицы.
@@ -0,0 +1,99 @@
# Приёмка liderra.ru — ПУНКТ №15: Сделки не теряются при удалении проекта
> **Для исполнителя:** формат PR1 (одобрен 20.06). Каждый шаг = `🔧 Код-факт (file:line)` · `🎬 Действие` · `📋 ОТЧЁТ (было→ожидали→стало + 📸/📝)` · `❌ Если не так`. Техника «под капотом»; владельцу — человеческие карточки.
**Цель:** показать, что сделки **никогда не теряются** из-за операций с проектом. Портал не даёт удалить проект, по которому уже есть сделки — вместо удаления предлагает поставить приём на паузу (скрыть проект из работы), а сделки остаются на месте. Удалить можно только проект, по которому сделок нет.
**Что снимаем глазами:** **Проекты** (попытка удаления → подсказка про паузу), **Сделки** (остались на месте). Живые 📸.
**Источник сценария:** свод проверок №15 (T-DELPROJ); R3 «движок».
> ⚠️ **Уточнение по коду (важно):** свод проверок формулировал пункт как «удалить проект со сделками → сделки сохранены». **По коду поведение строже:** проект со сделками **вообще нельзя удалить** (ответ 422 с подсказкой «поставьте на паузу»). Итог тот же — сделки не теряются, — но механизм другой (запрет удаления, а не удаление с сохранением). Свод по этому пункту стоит поправить.
---
## 🔧 Код-факты (подтверждено чтением 20.06)
- **Удаление проекта со сделками запрещено** — `delete` проверяет `deals WHERE project_id` и при наличии бросает **422** «Нельзя удалить проект: по нему есть сделки. Поставьте приём на паузу…» — [ProjectService.php:186-191](../../../app/app/Services/Project/ProjectService.php#L186-L191).
- **Перед этим — гард слепка поставщика** (приоритетнее): если по проекту уже «заказаны лиды», формулировка про слепок — [:184](../../../app/app/Services/Project/ProjectService.php#L184).
- **Удалить можно только пустой проект** (без сделок) — тогда hard delete, каскад чистит pivot/служебное — [:204](../../../app/app/Services/Project/ProjectService.php#L204).
- **Пауза как «уход» проекта** — `PATCH /api/projects/{id}/toggle-active` `is_active=false` скрывает проект из работы, сделки остаются — [ProjectController.php:264-282](../../../app/app/Http/Controllers/Api/ProjectController.php#L264-L282).
---
## Шаг 15-0 — Предусловие: проект со сделками
**🔧 Код:** сделки по проекту — [ProjectService::delete :186](../../../app/app/Services/Project/ProjectService.php#L186).
**🎬 Действие (лок):** взять проект, по которому уже есть доставленные сделки (после раздачи); зафиксировать число сделок.
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Сделки по проекту | — | есть N сделок | _(слот прогона)_ | 📸 Сделки (число); 📝 SQL `COUNT deals WHERE project_id` |
**❌ Если не так:** сделок нет → влить заявку этому проекту сначала.
---
## Шаг 15-1 — Удалить проект со сделками не дают (защита)
**🔧 Код:** [ProjectService::delete :186-191](../../../app/app/Services/Project/ProjectService.php#L186-L191).
**🎬 Действие (лок):** попробовать удалить этот проект в разделе Проекты.
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Попытка удаления | проект есть | **отказ** + подсказка «есть сделки, поставьте на паузу» | _(слот)_ | 📸 Проекты (сообщение об отказе) |
| Проект | — | остался (не удалён) | _(слот)_ | 📸 Проекты (проект на месте) |
**💡 Что внутри:** если по проекту уже прошли сделки, портал не даёт его удалить — иначе клиент мог бы случайно стереть историю своих заявок. Вместо удаления он предлагает поставить приём на паузу: проект перестаёт получать новые заявки, но все прошлые сделки остаются. Историю не потерять.
**❌ Если не так:** проект со сделками удалился → риск потери истории (дефект).
---
## Шаг 15-2 — Сделки на месте
**🔧 Код:** список сделок — [DealController::index :57-210](../../../app/app/Http/Controllers/Api/DealController.php#L57-L210).
**🎬 Действие (лок):** после отказа в удалении открыть Сделки.
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Сделки | N | **те же N** (ничего не потеряно) | _(слот)_ | 📸 Сделки (число не изменилось) |
**❌ Если не так:** число сделок изменилось → дефект.
---
## Шаг 15-3 — Пустой проект удаляется; «уход» проекта со сделками — через паузу
**🔧 Код:** удаление пустого — [ProjectService::delete :204](../../../app/app/Services/Project/ProjectService.php#L204); пауза — [ProjectController::toggleActive :264-282](../../../app/app/Http/Controllers/Api/ProjectController.php#L264-L282).
**🎬 Действие (лок):** (а) удалить проект **без** сделок — проходит; (б) проект со сделками поставить на паузу — скрывается из работы, сделки остаются.
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Удаление пустого проекта | есть | удалён успешно | _(слот)_ | 📸 Проекты (пустого нет) |
| Пауза проекта со сделками | активен | на паузе, сделки целы | _(слот)_ | 📸 Проекты (пауза) + Сделки (на месте) |
**💡 Что внутри:** проект, по которому ещё ничего не приходило, удалить можно спокойно. А «убрать с глаз» проект, по которому уже есть сделки, — через паузу: он перестаёт работать, но история сохраняется. Так клиент управляет списком проектов, не рискуя данными.
**❌ Если не так:** пустой проект не удаляется / пауза теряет сделки → дефект.
---
## Verification №15 (выход из пункта)
- [ ] 📸 Проект со сделками удалить не дают (подсказка про паузу); проект остался.
- [ ] 📸 Сделки на месте (число не изменилось).
- [ ] 📸 Пустой проект удаляется; проект со сделками уходит через паузу (сделки целы).
- [ ] 📝 SQL: `COUNT deals` до и после попытки удаления не изменилось.
## 📸 vs 📝 в этом пункте
- **📸 Скриншот:** Проекты (отказ удаления, пауза, удаление пустого), Сделки (число неизменно).
- **📝 Текст:** `COUNT deals` до/после — текстом.
## Грабли
- **Расхождение со сводом:** свод писал «удалить → сделки сохранены»; по коду — удаление **запрещено** при наличии сделок. Проверять именно запрет + подсказку про паузу; свод поправить.
- Есть второй гард — слепок поставщика («уже заказали лиды») срабатывает раньше has-deals; на прогоне возможно увидеть его формулировку.
- Связка с №4 (пауза) и №14 (удаление сделки не возвращает деньги).
@@ -0,0 +1,102 @@
# Приёмка liderra.ru — ПУНКТ №16: Изоляция (каждый видит только своё)
> **Для исполнителя:** формат PR1 (одобрен 20.06). Каждый шаг = `🔧 Код-факт (file:line)` · `🎬 Действие` · `📋 ОТЧЁТ (было→ожидали→стало + 📸/📝)` · `❌ Если не так`. Техника «под капотом»; владельцу — человеческие карточки.
**Цель:** показать, что клиенты **полностью изолированы** друг от друга: каждый видит только **свои** сделки, проекты, баланс; чужие данные не доступны **никаким способом** — ни по списку, ни по прямой ссылке (чужой номер → «не найдено»); даже когда одна заявка раздана нескольким клиентам, каждый видит **только свою копию**, не зная про других. Это основа доверия: клиент уверен, что его база контактов никому не утечёт.
**Что снимаем глазами:** **все разделы** двух кабинетов (Сделки / Проекты / Баланс) — у каждого свой набор; попытка открыть чужую сделку по ссылке → экран «не найдено». Живые 📸.
**Источник сценария:** свод проверок №16 (ISO-SECTIONS/SHARE/DIRECT/NEG/GLOBAL); R3 «движок».
---
## 🔧 Код-факты (подтверждено чтением 20.06)
- **Изоляция на уровне базы (RLS)** — middleware `SetTenantContext` оборачивает запрос в транзакцию и ставит `app.current_tenant_id` → политика Postgres фильтрует **каждую** выборку по тенанту; PgBouncer-safe через `SET LOCAL` — [SetTenantContext.php:28-49](../../../app/app/Http/Middleware/SetTenantContext.php#L28-L49).
- **Изоляция на уровне приложения** — контроллеры дополнительно ограничивают по `tenant_id` и `findOrFail` → чужой ID = **404** — например [ProjectController.php:170](../../../app/app/Http/Controllers/Api/ProjectController.php#L170), [:240](../../../app/app/Http/Controllers/Api/ProjectController.php#L240), [:248](../../../app/app/Http/Controllers/Api/ProjectController.php#L248); сделки — `where('tenant_id', …)` в [DealController::index :105-114](../../../app/app/Http/Controllers/Api/DealController.php#L105-L114) и `show`.
- **Защита от подмены тенанта** — заголовок `X-Tenant-Id` принимается **только** на dev/testing; на проде игнорируется (иначе спуфинг) — [:69-77](../../../app/app/Http/Middleware/SetTenantContext.php#L69-L77). Нет контекста тенанта → **403** — [:32-34](../../../app/app/Http/Middleware/SetTenantContext.php#L32-L34).
- **Шеринг не течёт** — одна заявка нескольким клиентам создаёт **отдельную** сделку каждому (своя строка `deals` с `tenant_id`); каждый видит только свою — RLS + per-tenant Deal (см. №1/№10).
---
## Шаг 16-1 — Клиент A видит только своё
**🔧 Код:** RLS — [SetTenantContext :28-49](../../../app/app/Http/Middleware/SetTenantContext.php#L28-L49); сделки — [DealController::index :105-114](../../../app/app/Http/Controllers/Api/DealController.php#L105-L114).
**🎬 Действие (лок):** войти клиентом A; открыть Сделки / Проекты / Баланс.
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Сделки A | — | только сделки A (его число) | _(слот прогона)_ | 📸 Сделки A |
| Проекты A | — | только проекты A | _(слот)_ | 📸 Проекты A |
| Баланс A | — | баланс A | _(слот)_ | 📸 Баланс A |
**❌ Если не так:** в списках A мелькают чужие строки → утечка (СТОП).
---
## Шаг 16-2 — Клиент B видит только своё (другой набор)
**🔧 Код:** те же ссылки.
**🎬 Действие (лок):** войти клиентом B; открыть те же разделы.
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Сделки B | — | только сделки B (другой набор, не как у A) | _(слот)_ | 📸 Сделки B |
| Проекты/Баланс B | — | только B | _(слот)_ | 📸 Проекты/Баланс B |
| Пересечение с A | — | нет общих строк | _(слот)_ | 📸 сравнение (разные наборы) |
**❌ Если не так:** наборы A и B пересекаются → утечка.
---
## Шаг 16-3 — Чужая ссылка → «не найдено» (прямой доступ закрыт)
**🔧 Код:** `where tenant_id + findOrFail` → 404 — [ProjectController :170](../../../app/app/Http/Controllers/Api/ProjectController.php#L170); сделки — `show` с tenant-scope.
**🎬 Действие (лок):** под клиентом B открыть по прямой ссылке **сделку/проект клиента A** (подставить чужой номер в URL).
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Чужая сделка по ссылке | — | **«не найдено»** (404), данные не показаны | _(слот)_ | 📸 экран «не найдено» |
| Чужой проект по ссылке | — | **«не найдено»** (404) | _(слот)_ | 📸 экран «не найдено» |
**💡 Что внутри:** даже если кто-то узнает номер чужой сделки и подставит его в адрес — портал ответит «не найдено», как будто этой записи для него не существует. Доступ закрыт на двух уровнях: и сама база отдаёт только твои строки, и портал отдельно проверяет, что запись твоя. Подменить «я — другой клиент» через технический заголовок на боевом нельзя.
**❌ Если не так:** чужая сделка открылась/показала данные → утечка (СТОП, критично перед продажей).
---
## Шаг 16-4 — Шеринг не течёт: общая заявка — у каждого только своя копия
**🔧 Код:** per-tenant Deal + RLS (см. №1/№10).
**🎬 Действие (лок):** влить одну заявку, которую получили и A, и B; под каждым открыть свою сделку.
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Сделка у A | — | своя копия заявки, без упоминания B | _(слот)_ | 📸 Сделки A (эта заявка) |
| Сделка у B | — | своя копия, не видит, что её получил A | _(слот)_ | 📸 Сделки B (эта заявка) |
| Видимость «соседей» | — | никто не знает, кому ещё ушла заявка | _(слот)_ | 📸 карточки (нет данных о других получателях) |
**💡 Что внутри:** одна заявка может уйти нескольким клиентам (до 3). Но это не «общая» запись — каждому делается **отдельная** сделка в его кабинете. Клиент A не видит, что ту же заявку получил клиент B, и наоборот. Конкуренты в системе не пересекаются.
**❌ Если не так:** клиент видит, кому ещё ушла заявка / общую запись → утечка коммерческой тайны.
---
## Verification №16 (выход из пункта)
- [ ] 📸 A видит только своё (Сделки/Проекты/Баланс); B — только своё; наборы не пересекаются.
- [ ] 📸 Чужая сделка/проект по прямой ссылке → «не найдено» (404).
- [ ] 📸 Общая заявка — у A и B отдельные копии; друг про друга не знают.
- [ ] 📝 (внутр.) RLS: прямой запрос без tenant-контекста → 0 строк / 403; счётчик утечек = 0.
## 📸 vs 📝 в этом пункте
- **📸 Скриншот:** разделы обоих кабинетов, экран «не найдено» на чужой ссылке, общая заявка в двух кабинетах.
- **📝 Текст:** RLS-проверка (0 строк без контекста), 403 на спуфинг — глазами не видны.
## Грабли
- На local подмена тенанта через `X-Tenant-Id` РАЗРЕШЕНА (для тестов) — на проде закрыта; проверять «закрытость» именно как находку прод-поведения, не локального.
- Изоляция держится на двух уровнях (RLS + app-guard) — проверять оба: список (RLS) и прямую ссылку (findOrFail).
- Связка с №14 (экспорт только свой) и №26 (API только свои сделки).
@@ -0,0 +1,103 @@
# Приёмка liderra.ru — ПУНКТ №19: Гейт реквизитов + ИНН (от первого проекта до «Готово к оплате»)
> **Для исполнителя:** формат PR1 (одобрен 20.06). Каждый шаг = `🔧 Код-факт (file:line)` · `🎬 Действие` · `📋 ОТЧЁТ (было→ожидали→стало + 📸/📝)` · `❌ Если не так`. Техника «под капотом»; владельцу — человеческие карточки.
**Цель:** показать «лесенку» оформления клиента: пока не заполнены минимальные реквизиты — портал **не даёт создать первый проект** (мягко подсказывает, что нужно); по **ИНН** портал сам подтягивает название и тип организации (не надо вводить вручную); после минимальных реквизитов гейт снимается; а когда заполнены полные платёжные реквизиты — появляется чип **«Готово к оплате»**.
**Что снимаем глазами:** вкладка **Реквизиты**, раздел **Проекты** (гейт), чип «Готово к оплате». Живые 📸.
**Источник сценария:** свод проверок №19 (ONB-REQ-GATE/INN/FULL); R3b «онбординг/UI».
---
## 🔧 Код-факты (подтверждено чтением 20.06)
- **Гейт первого проекта** — без минимальных реквизитов `POST /api/projects`**422 `requisites_required`** — [ProjectController.php:131-133](../../../app/app/Http/Controllers/Api/ProjectController.php#L131-L133).
- **Минимальные («лёгкие») реквизиты** — нужны `subject_type + contact_name + contact_phone`; для ЮЛ/ИП ещё `inn` — [RequisitesService::isLightComplete :31-45](../../../app/app/Services/Requisites/RequisitesService.php#L31-L45).
- **Подтяжка по ИНН** — `POST /api/tenant/requisites/lookup-inn` через DaData (`PartyLookup::findByInn`): возвращает название и подсказку типа (`sole_proprietor`/`legal_entity`), **ничего не сохраняет** (мягкая подтяжка) — [TenantRequisitesController.php:41-54](../../../app/app/Http/Controllers/Api/TenantRequisitesController.php#L41-L54).
- **Чип «Готово к оплате»** — полные платёжные реквизиты: при заполненном `bank_account` ставится `requisites_completed_at` — [RequisitesService::upsert :25](../../../app/app/Services/Requisites/RequisitesService.php#L25).
- ⚠️ DaData локально выключена (засев) — подтяжка ИНН работает на проде; локально ИНН вводим вручную, гейт/чип показываем.
---
## Шаг 19-1 — Без реквизитов первый проект не создать
**🔧 Код:** [ProjectController::store :131-133](../../../app/app/Http/Controllers/Api/ProjectController.php#L131-L133).
**🎬 Действие (лок):** новым клиентом (реквизиты пусты) попытаться создать первый проект.
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Создание проекта | форма | **не даёт**: «нужны реквизиты» | _(слот прогона)_ | 📸 Проекты (подсказка про реквизиты) |
| Куда ведёт | — | на вкладку Реквизиты | _(слот)_ | 📸 переход на Реквизиты |
**💡 Что внутри:** прежде чем начать получать заявки, клиент должен указать, кто он (физлицо/ИП/организация) и как с ним связаться. Пока этого нет — портал мягко не пускает к созданию проекта и показывает, что заполнить. Это нужно, чтобы потом корректно выставлять документы и оплату.
**❌ Если не так:** проект создаётся без реквизитов → дефект гейта.
---
## Шаг 19-2 — По ИНН подтягиваются название и тип (🟦 на проде; лок — вручную)
**🔧 Код:** [lookupInn :41-54](../../../app/app/Http/Controllers/Api/TenantRequisitesController.php#L41-L54).
**🎬 Действие (прод):** ввести ИНН → портал подсказывает название организации и тип лица.
**🎬 Действие (лок):** DaData выключена — ввести данные вручную; шаг подтяжки показать на прод-прогоне.
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Ввод ИНН | пусто | подтянулись название + тип (ЮЛ/ИП) | _(слот боевого прогона)_ | 📸 Реквизиты (автозаполнение по ИНН) |
**💡 Что внутри:** клиенту не нужно вручную набирать название своей фирмы — он вводит ИНН, и портал сам подставляет официальное название и понимает, организация это или ИП. Быстро и без ошибок.
**❌ Если не так:** ИНН не подтягивает данные на проде → DaData недоступна (проверить ключ/лимит).
---
## Шаг 19-3 — Заполнили минимум → гейт снят, проект создаётся
**🔧 Код:** [isLightComplete :31-45](../../../app/app/Services/Requisites/RequisitesService.php#L31-L45); гейт — [ProjectController :131-133](../../../app/app/Http/Controllers/Api/ProjectController.php#L131-L133).
**🎬 Действие (лок):** заполнить минимальные реквизиты (тип лица + ФИО/контакт + телефон, +ИНН для ЮЛ/ИП); снова создать проект.
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Реквизиты | пусты | минимум заполнен | _(слот)_ | 📸 Реквизиты (заполнено) |
| Создание проекта | блок | **проходит** (гейт снят) | _(слот)_ | 📸 Проекты (проект создан) |
**💡 Что внутри:** как только клиент указал базовое — кто он и как связаться — портал снимает ограничение, и можно создавать проекты и получать заявки. Минимум для старта небольшой.
**❌ Если не так:** гейт держится после заполнения минимума → дефект.
---
## Шаг 19-4 — Полные платёжные реквизиты → чип «Готово к оплате»
**🔧 Код:** [upsert :25](../../../app/app/Services/Requisites/RequisitesService.php#L25).
**🎬 Действие (лок):** дозаполнить полные платёжные реквизиты (включая банковский счёт).
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Чип статуса реквизитов | нет | **«Готово к оплате»** | _(слот)_ | 📸 Реквизиты (чип «Готово к оплате») |
**💡 Что внутри:** когда клиент заполнил и платёжные данные (банковский счёт) — портал отмечает его как «готов к оплате». Это значит, что с ним можно проводить расчёты по всем правилам. Появляется наглядная отметка готовности.
**❌ Если не так:** чип не появляется при полных реквизитах → дефект статуса.
---
## Verification №19 (выход из пункта)
- [ ] 📸 Без реквизитов первый проект не создать (подсказка + переход на Реквизиты).
- [ ] 🟦 (прод) ИНН подтягивает название и тип.
- [ ] 📸 После минимума гейт снят, проект создаётся.
- [ ] 📸 При полных платёжных реквизитах — чип «Готово к оплате».
## 📸 vs 📝 в этом пункте
- **📸 Скриншот:** Проекты (гейт), вкладка Реквизиты (заполнение, чип), создание проекта после снятия гейта.
- **📝 Текст:** `requisites_completed_at`, тип лица — при необходимости текстом.
## Грабли
- DaData локально выключена → подтяжку ИНН показываем на прод-прогоне; локально вводим вручную (гейт/чип — локально).
- Для ЮЛ/ИП ИНН обязателен для снятия гейта; для физлица — нет.
- Связка с PR2-0 (то же предусловие) и онбордингом №18.
@@ -0,0 +1,88 @@
# Приёмка liderra.ru — ПУНКТ №20: Колокольчик + дайджест (push убран)
> **Для исполнителя:** формат PR1 (одобрен 20.06). Каждый шаг = `🔧 Код-факт (file:line)` · `🎬 Действие` · `📋 ОТЧЁТ (было→ожидали→стало + 📸/📝)` · `❌ Если не так`. Техника «под капотом»; владельцу — человеческие карточки.
**Цель:** показать, как клиент узнаёт о новых заявках: на **каждую** новую заявку — значок-колокольчик в кабинете (мгновенно); на почту — не на каждую отдельно (чтобы не спамить), а **сводка-дайджест раз в 30 минут**. Нерабочий push-канал (всплывающие уведомления браузера) — **убран**, чтобы не вводить в заблуждение.
**Что снимаем глазами:** **колокольчик** (уведомления в кабинете), письмо-**дайджест**, настройки уведомлений (push отсутствует). Живые 📸.
**Источник сценария:** свод проверок №20 (BELL/DIGEST/PUSH-DEAD); R3b «онбординг/UI».
---
## 🔧 Код-факты (подтверждено чтением 20.06)
- **Колокольчик на каждый лид** — `notifyNewLead` шлёт in-app уведомление (значок-колокольчик) на **каждую** новую сделку; email-канал события «новый лид» переведён на дайджест — [NotificationService.php:83-98](../../../app/app/Services/NotificationService.php#L83-L98).
- **Дайджест вместо пер-лид письма** — `notifyNewLeadsDigest` шлёт **одно** письмо-сводку о новых сделках за окно — [:105-110](../../../app/app/Services/NotificationService.php#L105-L110); задача `SendNewLeadsDigestJob`**каждые 30 минут** — [routes/console.php:152-153](../../../app/routes/console.php#L152-L153).
- **Кому дайджест** — активным пользователям с включённым `new_lead.email` — [:107](../../../app/app/Services/NotificationService.php#L107). ⚠️ schema-default `new_lead = {inapp:true, push:true, email:false}` — [NotificationService:27](../../../app/app/Services/NotificationService.php#L27); «дайджест включён по умолчанию» (свод/G2-A) **сверить на прогоне** с фактическим дефолтом prefs.
- **Push мёртв** — push-канал Post-MVP, требует ServiceWorker + VAPID, не реализован — [:38](../../../app/app/Services/NotificationService.php#L38); нерабочий push-канал убран из UI (G4, деплой 19.06). В prefs флаг `push` может стоять, но **ничего не доставляет**.
---
## Шаг 20-1 — Новый лид → колокольчик в кабинете (на каждую заявку)
**🔧 Код:** [notifyNewLead :83-98](../../../app/app/Services/NotificationService.php#L83-L98).
**🎬 Действие (лок):** влить клиенту 2-3 заявки; открыть колокольчик в кабинете.
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Колокольчик | пусто | по уведомлению на **каждую** заявку (счётчик растёт) | _(слот прогона)_ | 📸 колокольчик со счётчиком/списком |
| Текст уведомления | — | «Новый лид — <проект>», контакт/телефон | _(слот)_ | 📸 раскрытый колокольчик |
**💡 Что внутри:** как только приходит новая заявка, в кабинете сразу загорается колокольчик с уведомлением — клиент видит это мгновенно, не отходя от портала. На каждую заявку — своё уведомление.
**❌ Если не так:** колокольчик не загорается / пропускает заявки → дефект уведомлений.
---
## Шаг 20-2 — Письмо-дайджест раз в 30 минут (а не на каждую)
**🔧 Код:** [notifyNewLeadsDigest :105-110](../../../app/app/Services/NotificationService.php#L105-L110); расписание — [routes/console.php:152-153](../../../app/routes/console.php#L152-L153).
**🎬 Действие (лок):** включить email-уведомления о лидах; влить несколько заявок; запустить задачу дайджеста.
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Письма о лидах | — | **одно письмо-сводка** за окно (не по письму на лид) | _(слот)_ | 📸 письмо-дайджест (список новых сделок) |
| Частота | — | раз в 30 минут | _(слот)_ | 📝 задача `SendNewLeadsDigestJob` everyThirtyMinutes |
**💡 Что внутри:** чтобы почта клиента не была завалена — портал не шлёт письмо на каждую заявку. Вместо этого раз в полчаса отправляет одну сводку: «вот новые заявки за последние 30 минут». Удобно и без спама.
**❌ Если не так:** письмо на каждую заявку (спам) / дайджест не приходит → дефект.
> ⚠️ «Дайджест включён по умолчанию» — проверить фактический дефолт настроек (schema-default email=false). Если по умолчанию выключен — это расхождение со сводом, зафиксировать.
---
## Шаг 20-3 — Push-канал убран (нерабочий не показываем)
**🔧 Код:** push Post-MVP — [NotificationService :38](../../../app/app/Services/NotificationService.php#L38); UI-уборка push — G4 (деплой 19.06).
**🎬 Действие (лок):** открыть настройки уведомлений.
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Push в настройках | раньше был (нерабочий) | **убран** (не предлагается клиенту) | _(слот)_ | 📸 Настройки уведомлений (без push) |
| Доставка push | — | ничего не шлёт (канал не реализован) | _(слот)_ | 📝 push не доставляет |
**💡 Что внутри:** всплывающие уведомления браузера (push) пока не сделаны. Раньше в настройках был переключатель, который ничего не делал — его убрали, чтобы не обманывать клиента «галочкой, которая не работает». Остаются рабочие каналы: колокольчик в кабинете и письмо-дайджест.
**❌ Если не так:** нерабочий push-переключатель снова виден / создаёт ложное ожидание → вернуть уборку (G4).
---
## Verification №20 (выход из пункта)
- [ ] 📸 Колокольчик загорается на каждую новую заявку.
- [ ] 📸 Почта — одно письмо-сводка за окно (раз в 30 мин), не по письму на лид.
- [ ] 📸 В настройках нет нерабочего push-переключателя.
- [ ] 📝 Сверить дефолт email-дайджеста (вкл/выкл) со сводом.
## 📸 vs 📝 в этом пункте
- **📸 Скриншот:** колокольчик (со счётчиком), письмо-дайджест, настройки уведомлений (без push).
- **📝 Текст:** расписание задачи (30 мин), дефолт prefs, отсутствие доставки push.
## Грабли
- ⚠️ Дефолт email-дайджеста: код-default `email=false`, свод говорит «вкл по умолчанию» — **проверить и согласовать** (возможное расхождение, как №14/№15).
- На dev задача дайджеста запускается по расписанию/вручную — для показа дёрнуть `SendNewLeadsDigestJob`.
- Push — намеренно убран (не баг), не путать с «уведомления сломались».
@@ -0,0 +1,120 @@
# Приёмка liderra.ru — ПУНКТ №22: Отчёты (4 типа, очередь, скачивание, квота, изоляция)
> **Для исполнителя:** формат PR1 (одобрен 20.06). Каждый шаг = `🔧 Код-факт (file:line)` · `🎬 Действие` · `📋 ОТЧЁТ (было→ожидали→стало + 📸/📝)` · `❌ Если не так`. Техника «под капотом»; владельцу — человеческие карточки.
**Цель:** показать, что раздел **Отчёты** работает: клиент заказывает отчёт (несколько типов, в разных форматах), он готовится в фоне (статус виден), скачивается по **защищённой ссылке** (живёт сутки), числа в отчёте = тому, что в базе; нельзя заказать слишком много сразу (квота); клиент видит **только свои** отчёты.
**Что снимаем глазами:** раздел **Отчёты** (список, статусы, кнопка скачать), скачанный файл. Живые 📸.
**Источник сценария:** свод проверок №22 (RPT-TYPES/QUEUE/DOWNLOAD/ISO); R3b «онбординг/UI».
---
## 🔧 Код-факты (подтверждено чтением 20.06)
- **4 типа отчётов** — провайдеры: Биллинг-сводка, Выгрузка сделок, Сводка по менеджерам, Сводка по источникам — `ReportJob::TYPES` [ReportJob.php:44](../../../app/app/Models/ReportJob.php#L44); **форматы** `csv/xlsx/json/pdf` — [:51](../../../app/app/Models/ReportJob.php#L51).
- **Очередь и статусы** — заказ создаёт задачу `pending`, фоновая `GenerateReportJob` ведёт `pending → processing → done|failed`, кладёт файл — [GenerateReportJob.php:21](../../../app/app/Jobs/GenerateReportJob.php#L21), [:90](../../../app/app/Jobs/GenerateReportJob.php#L90); на dev выполняется сразу, на проде — фоновым воркером — [ReportJobController.php:174-176](../../../app/app/Http/Controllers/Api/ReportJobController.php#L174-L176).
- **Квота** — нельзя держать больше N **активных** (pending+processing) задач; превышение → отказ `_quota` — [:144-151](../../../app/app/Http/Controllers/Api/ReportJobController.php#L144-L151), [ReportJob::ACTIVE_STATUSES :40](../../../app/app/Models/ReportJob.php#L40).
- **Скачивание по защищённой ссылке 24ч** — `temporarySignedUrl(..., addHours(24))` (подпись = разовый пропуск, без логина) — [:403-413](../../../app/app/Http/Controllers/Api/ReportJobController.php#L403-L413), сам download под `signed`-middleware — [:360-364](../../../app/app/Http/Controllers/Api/ReportJobController.php#L360-L364).
- **Изоляция** — все запросы `where('tenant_id', …)` + RLS `SET LOCAL` — [:65](../../../app/app/Http/Controllers/Api/ReportJobController.php#L65), [:106-110](../../../app/app/Http/Controllers/Api/ReportJobController.php#L106-L110).
- **Сбой формата (например PDF)** — ловится в фоновой задаче → статус `failed` + лог, **без падения системы** — [GenerateReportJob.php:102](../../../app/app/Jobs/GenerateReportJob.php#L102). (Штатность PDF→failed — проверить на прогоне.)
---
## Шаг 22-1 — Заказать отчёт → готовится в очереди → готов
**🔧 Код:** [store :124-176](../../../app/app/Http/Controllers/Api/ReportJobController.php#L124-L176).
**🎬 Действие (лок):** в Отчётах заказать «Выгрузку сделок» в формате xlsx.
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Задача отчёта | нет | создана, статус «готовится» → «готов» | _(слот прогона)_ | 📸 Отчёты (строка со статусом) |
| Файл | — | сформирован | _(слот)_ | 📸 кнопка «Скачать» активна |
**💡 Что внутри:** клиент заказывает отчёт — портал ставит его в очередь и готовит в фоне, чтобы не заставлять ждать у экрана. Видно статус: «готовится» → «готов». Когда готов — появляется кнопка скачать.
**❌ Если не так:** задача зависла в «готовится» / упала без причины → разобрать (воркер/провайдер).
---
## Шаг 22-2 — Скачать по защищённой ссылке (живёт сутки); числа = базе
**🔧 Код:** [download :360-392](../../../app/app/Http/Controllers/Api/ReportJobController.php#L360-L392); ссылка 24ч — [:413](../../../app/app/Http/Controllers/Api/ReportJobController.php#L413).
**🎬 Действие (лок):** скачать готовый отчёт; сверить числа с разделом Сделки/Биллинг.
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Скачивание | — | файл скачивается по защищённой ссылке | _(слот)_ | 📸 файл скачан |
| Числа в отчёте | — | = базе (сделки/деньги совпадают) | _(слот)_ | 📸 файл + сверка с экраном |
| Срок ссылки | — | действует 24 часа | _(слот)_ | 📝 ссылка с подписью (внутр.) |
**💡 Что внутри:** скачать отчёт можно по особой ссылке-пропуску, которая действует сутки — этого хватает, чтобы забрать файл, и потом она перестаёт работать (чтобы ссылка не «утекла» навсегда). Числа в отчёте — ровно те же, что в кабинете.
**❌ Если не так:** числа в отчёте ≠ базе / ссылка не открывается → дефект.
---
## Шаг 22-3 — Квота: слишком много заказов сразу не дают
**🔧 Код:** [store quota :144-151](../../../app/app/Http/Controllers/Api/ReportJobController.php#L144-L151).
**🎬 Действие (лок):** заказать отчётов больше квоты, не дожидаясь готовности предыдущих.
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Лишний заказ | — | **отказ по квоте** (дождитесь готовности) | _(слот)_ | 📸 Отчёты (сообщение о квоте) |
**💡 Что внутри:** чтобы один клиент не завалил систему сотней одновременных заказов, действует ограничение: одновременно «в работе» может быть лишь несколько отчётов. Лишние портал просит подождать. Готовые не считаются.
**❌ Если не так:** квота не срабатывает → риск перегрузки.
---
## Шаг 22-4 — Изоляция: только свои отчёты
**🔧 Код:** [index :65](../../../app/app/Http/Controllers/Api/ReportJobController.php#L65), [show :106-110](../../../app/app/Http/Controllers/Api/ReportJobController.php#L106-L110).
**🎬 Действие (лок):** под клиентом B убедиться, что не видит/не качает отчёты клиента A.
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Список отчётов | — | только свои | _(слот)_ | 📸 Отчёты B (нет чужих) |
| Чужой отчёт по ссылке | — | не доступен | _(слот)_ | 📝 404/нет доступа |
**❌ Если не так:** виден/качается чужой отчёт → утечка (связка с №16).
---
## Шаг 22-5 — Сбой формата (PDF) завершается штатно
**🔧 Код:** [GenerateReportJob :102](../../../app/app/Jobs/GenerateReportJob.php#L102).
**🎬 Действие (лок):** заказать отчёт в формате PDF.
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| PDF-отчёт | — | если PDF не поддержан — статус **failed** штатно (система не падает), можно повторить | _(слот прогона)_ | 📸 Отчёты (статус failed) |
**💡 Что внутри:** если какой-то формат не получается сформировать, портал не «зависает» и не падает — он спокойно помечает отчёт как «не удалось», и клиент может попробовать другой формат или повторить.
**❌ Если не так:** PDF роняет систему/задачу намертво → дефект (должно быть мягкое «failed»).
---
## Verification №22 (выход из пункта)
- [ ] 📸 Отчёт заказывается, проходит «готовится → готов».
- [ ] 📸 Скачивание по защищённой ссылке (24ч); числа = базе.
- [ ] 📸 Квота: лишние заказы отклоняются.
- [ ] 📸 Изоляция: только свои отчёты.
- [ ] 📸 PDF (или иной сбойный формат) → «failed» штатно, без падения.
## 📸 vs 📝 в этом пункте
- **📸 Скриншот:** Отчёты (список/статусы/квота/скачать), скачанный файл.
- **📝 Текст:** срок подписи ссылки (24ч), принадлежность тенанту — текстом.
## Грабли
- На dev отчёт готовится сразу (sync); на проде — фоновым воркером, статус идёт через «готовится».
- PDF→failed — заявлено сводом как «штатно»; **проверить файлом** (не утверждать без прогона).
- Ссылка скачивания — разовый пропуск на 24ч; через сутки недоступна (это правильно).
@@ -0,0 +1,121 @@
# Приёмка liderra.ru — ПУНКТ №23: Импорт сделок из CSV (без списания, без дублей)
> **Для исполнителя:** формат PR1 (одобрен 20.06). Каждый шаг = `🔧 Код-факт (file:line)` · `🎬 Действие` · `📋 ОТЧЁТ (было→ожидали→стало + 📸/📝)` · `❌ Если не так`. Техника «под капотом»; владельцу — человеческие карточки.
**Цель:** показать, что клиент может **загрузить свою историю сделок из файла (CSV)** — например, перенести базу из старой CRM — и при этом: сделки появляются в кабинете; **деньги НЕ списываются** (импорт — это перенос, а не покупка заявок); повторная загрузка того же файла **не задваивает** сделки; статусы из файла раскладываются по воронке портала (неизвестные — через мастер сопоставления).
**Что снимаем глазами:** **Сделки** (импортированные появились), **Баланс** (не изменился), мастер статусов. Живые 📸.
**Источник сценария:** свод проверок №23 (IMP-OK/IDEMP/STATUS/VALID/ISO); R3b «онбординг/UI».
---
## 🔧 Код-факты (подтверждено чтением 20.06)
- **Импорт НЕ списывает деньги** — `upsertRow` создаёт `Deal` напрямую, **без `LedgerService`/charge/balance** — [HistoricalImportService.php:170-179](../../../app/app/Services/Import/HistoricalImportService.php#L170-L179).
- **Без дублей (идемпотентность)** — дедуп по `(tenant_id, source_crm_id)` через `webhook_dedup_keys` + advisory-lock; повтор → **обновляет** существующую сделку (статус/контакт), новую не создаёт — [:146-168](../../../app/app/Services/Import/HistoricalImportService.php#L146-L168).
- **Статусы из файла → воронка** — `StatusRuToSlugMapper` + пер-клиентские правила сопоставления; неизвестные статусы собираются (мастер сопоставления) — [resolveStatus :115-122](../../../app/app/Services/Import/HistoricalImportService.php#L115-L122), [loadStatusOverrides :92-103](../../../app/app/Services/Import/HistoricalImportService.php#L92-L103).
- **Изоляция** — `SET LOCAL app.current_tenant_id` + `where(tenant_id)` на каждом шаге — [:138-139](../../../app/app/Services/Import/HistoricalImportService.php#L138-L139), [:101](../../../app/app/Services/Import/HistoricalImportService.php#L101).
- **Битые строки не валят импорт** — ошибочная строка логируется и пропускается, остальные импортируются — [:77](../../../app/app/Services/Import/HistoricalImportService.php#L77).
- **След в журнале ПДн** — на каждую импортированную сделку — запись обработки ПДн — [:189-198](../../../app/app/Services/Import/HistoricalImportService.php#L189-L198).
---
## Шаг 23-1 — Загрузить CSV → сделки появились
**🔧 Код:** [HistoricalImportService::import :35-88](../../../app/app/Services/Import/HistoricalImportService.php#L35-L88); контроллер — `ImportController`.
**🎬 Действие (лок):** загрузить тест-CSV с несколькими сделками; зафиксировать число сделок «до».
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Сделки | N | N + число строк файла | _(слот прогона)_ | 📸 Сделки (выросли на импорт) |
| Источник | — | помечены как импортные | _(слот)_ | 📸 карточка (проект type=import) |
**💡 Что внутри:** клиент может перенести в портал свою прошлую базу сделок одним файлом — не вбивая руками. Портал читает файл и заводит сделки в кабинете.
**❌ Если не так:** сделки не появились / число не совпало → разобрать (формат файла, валидация).
---
## Шаг 23-2 — Баланс НЕ списан (импорт ≠ покупка)
**🔧 Код:** [upsertRow :170-179](../../../app/app/Services/Import/HistoricalImportService.php#L170-L179) (нет списания).
**🎬 Действие (лок):** сверить баланс «до» и «после» импорта.
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Баланс | X ₽ | **не изменился** (импорт не списывает) | _(слот)_ | 📸 Баланс до = после |
| Списания | — | нет новых | _(слот)_ | 📝 `lead_charges` не выросло |
**💡 Что внутри:** загрузка своей истории — это не покупка новых заявок, поэтому портал за неё денег не берёт. Баланс остаётся прежним: импортируй сколько угодно своих старых сделок — это бесплатно.
**❌ Если не так:** импорт списал деньги → денежный дефект (клиент платит за свою же историю).
---
## Шаг 23-3 — Повторная загрузка не задваивает
**🔧 Код:** дедуп — [:146-168](../../../app/app/Services/Import/HistoricalImportService.php#L146-L168).
**🎬 Действие (лок):** загрузить **тот же** файл ещё раз.
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Число сделок | N+импорт | **не выросло** (дубли не созданы) | _(слот)_ | 📸 Сделки (число то же) |
| Существующие | — | обновились (статус/контакт), не задвоились | _(слот)_ | 📝 один `deal` на `source_crm_id` |
**💡 Что внутри:** если случайно загрузить тот же файл дважды — портал не создаст вторые копии. Он узнаёт уже знакомые сделки по их номеру и просто обновляет, а не плодит дубликаты.
**❌ Если не так:** повтор задвоил сделки → дефект идемпотентности.
---
## Шаг 23-4 — Статусы из файла → воронка (мастер для неизвестных)
**🔧 Код:** [resolveStatus :115-122](../../../app/app/Services/Import/HistoricalImportService.php#L115-L122); правила — [loadStatusOverrides :92-103](../../../app/app/Services/Import/HistoricalImportService.php#L92-L103).
**🎬 Действие (лок):** в файле — разные статусы (известные и нестандартные); пройти мастер сопоставления для неизвестных.
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Известные статусы | — | разложены по воронке портала | _(слот)_ | 📸 Сделки (статусы проставлены) |
| Неизвестные статусы | — | предложены к сопоставлению (мастер) | _(слот)_ | 📸 мастер статусов |
**💡 Что внутри:** в старой системе у клиента статусы могли называться по-своему. Портал известные сам раскладывает по своей воронке, а незнакомые показывает в мастере: «как это назвать у нас?» — клиент один раз сопоставляет, и дальше всё ложится автоматически.
**❌ Если не так:** статусы потеряны / нет мастера для неизвестных → дефект.
---
## Шаг 23-5 — Изоляция и устойчивость (внутр.)
**🔧 Код:** изоляция — [:138-139](../../../app/app/Services/Import/HistoricalImportService.php#L138-L139); пропуск битых строк — [:77](../../../app/app/Services/Import/HistoricalImportService.php#L77).
**🎬 Действие:** проверить, что импорт попал только в свой кабинет; битые строки не сорвали импорт.
**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Принадлежность | — | импорт только своему клиенту | _(слот)_ | 📝 все сделки своего тенанта |
| Битая строка | — | пропущена с логом, остальные импортированы | _(слот)_ | 📝 лог `import.row_failed` |
**❌ Если не так:** импорт ушёл чужому / одна битая строка сорвала весь файл → дефект.
---
## Verification №23 (выход из пункта)
- [ ] 📸 CSV-импорт → сделки появились (помечены импортными).
- [ ] 📸 Баланс не изменился; новых списаний нет.
- [ ] 📸 Повтор того же файла не задваивает (обновляет).
- [ ] 📸 Статусы разложены по воронке; мастер для неизвестных.
- [ ] 📝 Только свой кабинет; битые строки пропущены, не валят импорт.
## 📸 vs 📝 в этом пункте
- **📸 Скриншот:** Сделки (импортные), Баланс до/после, мастер статусов.
- **📝 Текст:** отсутствие списаний, дедуп по `source_crm_id`, лог битых строк, принадлежность тенанту.
## Грабли
- Импорт **не списывает** — ключевой денежный инвариант, сверять баланс до/после.
- Идемпотентность по `source_crm_id` — для теста повтора слать **тот же** файл (те же id).
- Связка с №5 (идемпотентность доставки) и №13 (статусы сделок).