# Приёмка liderra.ru — ПУНКТ №14: Ручная / массовые действия / экспорт

> **Для исполнителя:** формат PR1 (одобрен 20.06). Каждый шаг = `🔧 Код-факт (file:line)` · `🎬 Действие` · `📋 ОТЧЁТ (было→ожидали→стало + 📸/📝)` · `❌ Если не так`. Техника «под капотом»; владельцу — человеческие карточки.

**Цель:** показать, что «ручные» операции со сделками безопасны для денег: **ручная сделка не списывает баланс** (клиент сам завёл — это не покупка лида); **удаление сделки не возвращает деньги** (нельзя «отменить» оплату удалением); **массовые действия** (удалить/восстановить/сменить статус пачкой) работают корректно; **экспорт совпадает с базой**. Отдельно — проверка на **безопасность выгрузки** (CSV-инъекция).

**Что снимаем глазами:** **Сделки** (ручная появилась, массовые действия), **Баланс** (не меняется), файл **экспорта**. Живые 📸.

**Источник сценария:** свод проверок №14 (T-MANUAL/BULK/EXPORT); R3 «движок».

> ⚠️ **Найдено при чтении кода (важно владельцу):** защита выгрузки от CSV-инъекции (экранирование ведущих `= + - @`) в коде **не обнаружена** — см. Шаг 14-4. Это вероятная находка, проверяется на прогоне, не блокирует деньги, но важна перед продажей.

---

## 🔧 Код-факты (подтверждено чтением 20.06)

- **Ручная сделка не списывает** — `store` создаёт `Deal` с `source_crm_id=null` (manual), **без вызова списания**: в `DealController` нет `LedgerService`/charge/balance — [DealController::store :470-567](../../../app/app/Http/Controllers/Api/DealController.php#L470-L567).
- **Удаление — soft-delete, без возврата денег** — `destroy`/`restore` меняют только `deleted_at`, денег не касаются — [DealBulkActionController::destroy :136-190](../../../app/app/Http/Controllers/Api/DealBulkActionController.php#L136-L190), [restore :200-255](../../../app/app/Http/Controllers/Api/DealBulkActionController.php#L200-L255) (нет balance/charge/refund во всём контроллере).
- **Массовая смена статуса** — `transition` отбирает сделки только своего тенанта, bulk-UPDATE — [transition :49-130](../../../app/app/Http/Controllers/Api/DealBulkActionController.php#L49-L130).
- **Экспорт = из базы** — `GET /api/deals/export` (csv/xlsx) стримит прямо из `deals` своего тенанта с теми же фильтрами, что лента — [DealExportController::export :44-138](../../../app/app/Http/Controllers/Api/DealExportController.php#L44-L138); строки — [:119-134](../../../app/app/Http/Controllers/Api/DealExportController.php#L119-L134).
- **CSV-инъекция:** ячейки пишутся «как есть» через OpenSpout `Row::fromValues` — [:125-132](../../../app/app/Http/Controllers/Api/DealExportController.php#L125-L132) и [DealsExportProvider::rows :67-85](../../../app/app/Services/Reports/Providers/DealsExportProvider.php#L67-L85). **Экранирования ведущих `= + - @` в коде не найдено** (искал по всему `app/`). OpenSpout экранирует разделители/кавычки, но не нейтрализует формулы.

---

## Шаг 14-1 — Ручная сделка не трогает баланс

**🔧 Код:** [DealController::store :470-567](../../../app/app/Http/Controllers/Api/DealController.php#L470-L567).
**🎬 Действие (лок):** в кабинете создать сделку **вручную** (телефон + контакт + статус); зафиксировать баланс «до».

**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Сделка | нет | ручная сделка появилась в ленте | _(слот прогона)_ | 📸 Сделки (новая ручная) |
| Баланс | X ₽ | **не изменился** (ручная ≠ покупка лида) | _(слот)_ | 📸 Баланс/Биллинг до = после |
| Списание | — | нет (нет записи в `lead_charges`) | _(слот)_ | 📝 SQL `lead_charges` не выросло |

**💡 Что внутри:** клиент может сам занести в портал сделку, которую получил мимо нас (например по своей рекламе) — чтобы вести её в одном месте. За такую сделку портал денег **не берёт**: платят только за заявки, которые мы доставили. Поэтому ручная сделка появляется в списке, но баланс остаётся прежним.

**❌ Если не так:** ручная сделка списала деньги → дефект (клиент платит за свой же контакт).

---

## Шаг 14-2 — Массовые действия; удаление НЕ возвращает деньги

**🔧 Код:** [DealBulkActionController :49-255](../../../app/app/Http/Controllers/Api/DealBulkActionController.php#L49-L255).
**🎬 Действие (лок):** выделить несколько **платных** (доставленных) сделок → массово сменить статус; затем массово удалить; зафиксировать баланс.

**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Массовая смена статуса | — | у всех выбранных статус сменился | _(слот)_ | 📸 Сделки (новый статус у пачки) |
| Массовое удаление | в списке | ушли из активного списка (в корзину) | _(слот)_ | 📸 Сделки (список уменьшился) |
| Баланс после удаления | X ₽ | **не вырос** (удаление ≠ возврат денег) | _(слот)_ | 📸 Баланс до = после; 📝 нет записи возврата |
| Восстановление | удалены | вернулись в список | _(слот)_ | 📸 Сделки (восстановлены) |

**💡 Что внутри:** клиент может пачкой навести порядок — сменить статус или убрать ненужные сделки в корзину (и вернуть обратно). Но удаление сделки **не возвращает деньги**: оплата за доставленную заявку уже состоялась, удалением её не «отменить». Иначе можно было бы получать заявки бесплатно — удаляя их после получения. Этого портал не допускает.

**❌ Если не так:** удаление вернуло деньги на баланс → денежный дефект (бесплатные лиды через удаление).

---

## Шаг 14-3 — Экспорт совпадает с базой

**🔧 Код:** [DealExportController::export :44-138](../../../app/app/Http/Controllers/Api/DealExportController.php#L44-L138).
**🎬 Действие (лок):** выгрузить сделки клиента (CSV и XLSX); сверить число строк и содержимое с лентой.

**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Число строк в файле | — | = числу сделок клиента (с теми же фильтрами) | _(слот)_ | 📸 файл (число строк); 📝 сверка с `COUNT(*)` |
| Содержимое | — | телефон/источник/город/статус/коммент/дата как в ленте | _(слот)_ | 📸 открытый файл |
| Изоляция | — | только свои сделки (чужих нет) | _(слот)_ | 📝 все строки своего тенанта |

**❌ Если не так:** в файле чужие сделки / число ≠ базе → дефект экспорта/изоляции.

---

## Шаг 14-4 — ⚠️ Безопасность выгрузки: CSV-инъекция (проверить на прогоне)

**🔧 Код:** ячейки «как есть» — [DealExportController :125-132](../../../app/app/Http/Controllers/Api/DealExportController.php#L125-L132); экранирование формул в коде **не найдено**.
**🎬 Действие (лок):** создать сделку, где в **комментарии/контакте** есть текст, начинающийся с `=`, `+`, `-` или `@` (например `=1+1` или `=HYPERLINK(...)`); выгрузить CSV; открыть в Excel/LibreOffice.

**📋 ОТЧЁТ:**
| Что | Было | Ожидалось (безопасно) | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Ячейка с ведущим `=`/`+`/`-`/`@` | — | в таблице показывается как **текст**, не как формула | _(слот прогона)_ | 📸 открытый файл (ячейка = текст) |
| Поведение Excel | — | формула НЕ исполняется | _(слот)_ | 📸 (нет результата формулы) |

**💡 Что внутри:** если в данных заявки есть текст вроде `=1+1`, то при открытии выгрузки в Excel он может восприняться как **формула** и выполниться — это известная уязвимость выгрузок («CSV-инъекция»). Безопасно — когда портал помечает такие ячейки как обычный текст. **По коду такой защиты сейчас не видно** — поэтому на прогоне проверяем реальным файлом; если формула исполнится — это находка, чинится экранированием ведущего символа.

**❌ Если формула исполнилась:** зафиксировать как **находку безопасности** (CSV-инъекция) — отдельный дефект, чинится экранированием в писателе экспорта. Не блокирует деньги, но важно перед продажей.

---

## Verification №14 (выход из пункта)
- [ ] 📸 Ручная сделка появилась; баланс не изменился; нет списания.
- [ ] 📸 Массовая смена статуса/удаление/восстановление работают; удаление не вернуло деньги.
- [ ] 📸 Экспорт CSV/XLSX = базе по числу и содержимому; только свои сделки.
- [ ] ⚠️ CSV-инъекция: проверено реальным файлом; результат зафиксирован (текст = безопасно / исполнилось = находка).

## 📸 vs 📝 в этом пункте
- **📸 Скриншот:** Сделки (ручная, массовые), Баланс до/после, открытый файл экспорта.
- **📝 Текст:** число `lead_charges`/строк, отсутствие возврата, принадлежность тенанту.

## Грабли
- **CSV-инъекция** — защиты в коде не найдено; не утверждать «экранировано» без проверки файлом. Свод проверок помечал это как «экранирована» — **расходится с кодом**, проверить и при необходимости завести находку.
- Удаление — soft-delete (в корзину), восстановимо; «возврат денег» — отдельная операция (refund админа), удалением не делается.
- Экспорт берёт те же фильтры, что лента — сверять с одинаковым фильтром.
