# Приёмка 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` не выросло |

> **NB (№23-tx):** импорт пишет **одну нулевую** строку `type=historical_import, amount_rub=0` в `balance_transactions` ([HistoricalImportService.php:281-295](../../../app/app/Services/Import/HistoricalImportService.php#L281)) — это аудит-след, **баланс не меняется** и `lead_charges` не растёт. Сверять надо **значение баланса и `lead_charges`**, НЕ число строк `balance_transactions` (одна нулевая строка появится — это норма). При импорте 0 валидных строк — строка не пишется.

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

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

---

## Шаг 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 (статусы сделок).
