# Приёмка liderra.ru — ПУНКТ №5: Идемпотентность (повтор заявки = одно списание, без дублей)

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

**Цель:** показать, что если **одна и та же заявка придёт повторно** (поставщик может прислать дубль, может быть техническая ретрансляция) — портал **не задвоит**: не создаст вторую сделку и **не спишет деньги ещё раз**. Один реальный лид = одна сделка = одно списание, при любом числе повторов.

**Что снимаем глазами:** **Сделки** (после повтора — та же одна сделка, не две) и **Баланс** (после повтора — не изменился, второго списания нет). Живые 📸.

**Источник сценария:** свод проверок №5 (D-IDEMP/DELIV/MERGE); R3 «движок».

---

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

- **Дедуп по `vid` на входе** — у `supplier_leads.vid` UNIQUE INDEX; повтор того же `vid` → ответ **200 `already_processed`** без повторной обработки — [SupplierWebhookController.php:29-30](../../../app/app/Http/Controllers/Api/SupplierWebhookController.php#L29-L30), [:107-112](../../../app/app/Http/Controllers/Api/SupplierWebhookController.php#L107-L112). Новый `vid` → **202 `accepted`** — [:130-132](../../../app/app/Http/Controllers/Api/SupplierWebhookController.php#L130-L132).
- **Защита от повторной обработки (ретраи джоба)** — если лид уже `processed_at` → выходим, не создаём дубли сделок — [RouteSupplierLeadJob.php:113-121](../../../app/app/Jobs/RouteSupplierLeadJob.php#L113-L121).
- **Замок поставки на (заявка × клиент)** — `supplier_lead_deliveries` через `insertOrIgnore`; если строка уже есть (повтор/гонка/CSV-восстановление) → `0` → доставка считается уже сделанной, **без второго списания** — [:404-418](../../../app/app/Jobs/RouteSupplierLeadJob.php#L404-L418), коммент «без второго списания» — [:401](../../../app/app/Jobs/RouteSupplierLeadJob.php#L401).
- Сделка несёт `source_crm_id = vid` — [:439](../../../app/app/Jobs/RouteSupplierLeadJob.php#L439) (связь сделки с исходным id заявки).

---

## Шаг 5-1 — Первая заявка проходит нормально

**🔧 Код:** приём — [SupplierWebhookController :90-132](../../../app/app/Http/Controllers/Api/SupplierWebhookController.php#L90-L132); списание — [LedgerService :53-95](../../../app/app/Services/Billing/LedgerService.php#L53-L95).
**🎬 Действие (лок):** влить заявку с конкретным `vid` (например 555000111) одному клиенту; зафиксировать баланс «до» и число сделок.

**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Ответ на приём | — | **202 accepted** (новая заявка) | _(слот прогона)_ | 📝 ответ вебхука 202 |
| Сделка | нет | 1 сделка создана | _(слот)_ | 📸 Сделки клиента (1 новая) |
| Баланс | X ₽ | X − цена (одно списание) | _(слот)_ | 📸 Биллинг (1 списание) |

**❌ Если не так:** заявка не принялась / нет сделки → проверить источник/секрет (Фаза 2).

---

## Шаг 5-2 — Повтор той же заявки (тот же vid) → НЕ задвоилось

**🔧 Код:** дедуп — [SupplierWebhookController :107-112](../../../app/app/Http/Controllers/Api/SupplierWebhookController.php#L107-L112); ретрай-guard — [RouteSupplierLeadJob :113-121](../../../app/app/Jobs/RouteSupplierLeadJob.php#L113-L121).
**🎬 Действие (лок):** влить **ту же** заявку с **тем же `vid`** (555000111) ещё раз (можно 2–3 раза).

**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Ответ на повтор | — | **200 already_processed** (не принят как новый) | _(слот)_ | 📝 ответ вебхука 200 already_processed |
| Сделки | 1 | **всё ещё 1** (вторая не создана) | _(слот)_ | 📸 Сделки клиента (по-прежнему 1) |
| Баланс | X − цена | **не изменился** (второго списания нет) | _(слот)_ | 📸 Биллинг (по-прежнему 1 списание) |

**💡 Что внутри:** портал помнит каждую заявку по её номеру (`vid`). Когда приходит точно такая же — он видит «это я уже обработал» и просто отвечает «ок, уже принято», но **ничего не делает заново**: ни второй сделки, ни второго списания. Поэтому даже если поставщик пришлёт один и тот же лид десять раз — клиент заплатит за него **один раз**.

**❌ Если не так:** повтор создал вторую сделку / списал деньги ещё раз / вернул 202 как новый → **СТОП, денежный дефект** (задвоение списаний — критично).

---

## Шаг 5-3 — Защита и при технических повторах (ретрай/гонка/восстановление) — 📝

**🔧 Код:** замок поставки — [RouteSupplierLeadJob :404-418](../../../app/app/Jobs/RouteSupplierLeadJob.php#L404-L418).
**🎬 Действие:** внутренняя проверка — повтор доставки одному клиенту (ретрай фоновой задачи, гонка, восстановление из CSV) не создаёт второе списание.

**📋 ОТЧЁТ:**
| Что | Было | Ожидалось | Стало (факт) | Чем подтверждаем |
|---|---|---|---|---|
| Повторная доставка клиенту | — | замок `supplier_lead_deliveries` ловит → второго списания нет | _(слот)_ | 📝 лог `delivery_already_locked`; в БД одна строка доставки |

**💡 Что внутри:** даже если технически (сбой связи, повторный запуск фоновой задачи) портал попытается доставить ту же заявку тому же клиенту дважды — внутри стоит «замок»: на пару «заявка + клиент» поставка возможна только один раз. Второй раз деньги не спишутся.

**❌ Если не так:** двойная доставка одному клиенту со вторым списанием → денежный дефект.

---

## Verification №5 (выход из пункта)
- [ ] 📝 Первый приём — 202 accepted; повтор того же vid — 200 already_processed.
- [ ] 📸 После повтора сделок столько же (1), не две.
- [ ] 📸 После повтора баланс не изменился (одно списание на реальный лид).
- [ ] 📝 Замок `supplier_lead_deliveries`: одна строка на (заявка×клиент); `delivery_already_locked` при повторе.

## 📸 vs 📝 в этом пункте
- **📸 Скриншот:** Сделки (число не растёт на повторе), Биллинг (одно списание).
- **📝 Текст:** коды ответа вебхука (202/200), замок доставки, processed_at — глазами не видны.

## Грабли
- Идемпотентность завязана на `vid` — для теста повтора слать **тот же** `vid`; разный `vid` = разные заявки (так и должно).
- Случай слияния с восстановлением из CSV (D-MERGE) — частный случай того же замка; глазами не показать, проверяется логом/БД.
- «Won»-сделка не списывает повторно — это отдельный пункт №13 (статусы).
