docs(billing-v2): спек B — политика дублей (дизайн)
Правило: дедуп — ответственность поставщика; Лидерра телефон не фильтрует, берём за всё, что прислано. Защита от своих дублей — на уровне БД (замок supplier_lead_deliveries, ключ по поставке, не по телефону). Лимит шеринга = 3 разных клиента, одному клиенту одна копия. Вариант B (железобетонный) утверждён на брейнсторме 23.05.2026. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
# Биллинг v2 — Спек B: политика дублей (дизайн)
|
||||
|
||||
**Дата:** 2026-05-23
|
||||
**Статус:** дизайн утверждён заказчиком, готов к writing-plans
|
||||
**Серия:** Биллинг v2 (Спек A — балансовая модель ✅ Phase A; Спек B — дубли; Спек C — preflight + VTB)
|
||||
**Связано:** `docs/superpowers/specs/2026-05-23-billing-v2-spec-a-balance-rub-design.md`, memory `project-billing-v2`
|
||||
|
||||
---
|
||||
|
||||
## 1. Главное правило (governing rule)
|
||||
|
||||
**Дедупликация лидов — ответственность ПОСТАВЩИКА (crm.bp-gr.ru), не Лидерры.**
|
||||
|
||||
Поставщик чистит повторы в своём окне (≈30 дней). Что поставщик прислал — для Лидерры это «настоящий» лид: мы его раздаём клиентам и берём за него деньги. **Лидерра повторы по телефону НЕ фильтрует.**
|
||||
|
||||
Единственная наша забота — **не наделать своих дублей**: одна поставка одному клиенту = ровно один оплаченный лид. При этом один лид по-прежнему можно продать **до 3 РАЗНЫХ клиентов** (модель шеринга — это норма, не дубль).
|
||||
|
||||
Формулировка заказчика (брейнсторм 23.05.2026):
|
||||
- «убираем все фильтры! но главное нам самим не наделать дублей — нам прислал поставщик в одном экземпляре, а мы клиенту выдали 2 раза! это не касается правила: 1 лид может быть продан 3-м».
|
||||
- «если 5 клиентов заказали лиды с одного источника, то мы можем продать лид только 3-м максимум».
|
||||
|
||||
«Смотри через это правило всё окружение» — спецификация проходит по всем местам, где есть логика дублей/дедупа, и приводит их в соответствие с правилом.
|
||||
|
||||
### Разрез понятий (важно не путать)
|
||||
|
||||
| Понятие | Что это | Решение |
|
||||
|---|---|---|
|
||||
| **Дубль по телефону** (антифрод) | Два разных лида с одинаковым телефоном | НЕ фильтруем — убираем (правило выше) |
|
||||
| **Технический повтор поставки** | Тот же физический лид пришёл дважды (один `vid`) | Идемпотентность по `vid` — оставляем |
|
||||
| **Наш собственный дубль** | Одна поставка → одному клиенту 2 копии | Запрещаем (новый замок в БД) |
|
||||
| **Шеринг** | Один лид → до 3 РАЗНЫХ клиентов | Норма, сохраняем (лимит теперь по клиентам) |
|
||||
| **CSV-восстановление повтора** | CSV тащит то, что уже принято вебхуком | Дедуп CSV по (телефон, проект) — оставляем |
|
||||
|
||||
---
|
||||
|
||||
## 2. Текущее состояние (as-is)
|
||||
|
||||
- **`app/app/Services/DuplicateDetector.php`** — телефонный фильтр. `findMaster(tenantId, phone, now)` ищет master-сделку `(tenant_id, phone)` с `received_at >= now − 24h` и `duplicate_of_id IS NULL`. `WINDOW_HOURS = 24`.
|
||||
- Вызывается в двух местах:
|
||||
- **`ProcessWebhookJob`** (прямой вебхук, `WebhookReceiveController` → `ProcessWebhookJob`; legacy-путь на `balance_leads`): `handle()` находит master → `markAsDuplicate()` (проставляет `duplicate_of_id`, НЕ списывает). Идемпотентность по `vid` обеспечена `webhook_dedup_keys (tenant_id, source_crm_id)`.
|
||||
- **`RouteSupplierLeadJob`** (шеринг, `SupplierWebhookController` → `SupplierLead` → `RouteSupplierLeadJob`; новый путь на `LedgerService`): `createDealCopyForProject()` находит master → помечает дубль, без `chargeForDelivery`.
|
||||
- **`LeadRouter::matchEligibleProjects`** — отдаёт ВСЕ подходящие проекты (по всем клиентам), среди них могут быть несколько проектов одного клиента.
|
||||
- **`LeadDistributor::selectRecipients`** — `CAP = 3`, берёт 3 случайных **проекта** (не клиента). → один лид может попасть в 2+ проекта одного клиента = «наш дубль».
|
||||
- **`CsvReconcileJob`** — дедуп CSV-строк по (телефон, проект) против уже принятых `supplier_leads` за окно 2 дня; недостающие → `SupplierLead(vid=NULL, source='csv_recovery')` → `RouteSupplierLeadJob`.
|
||||
- **Схема:** `deals.duplicate_of_id BIGINT` (без FK — `deals` партиционирована), индекс `ON deals (duplicate_of_id) WHERE duplicate_of_id IS NOT NULL`; индекс `(tenant_id, phone, received_at)`.
|
||||
- **UI:** строка `duplicate_detected` в матрице уведомлений `SettingsView.vue` (8×3). Уведомление **нигде в коде не отправляется** (мёртвая строка). На карточке сделки/в списке отображения дублей нет.
|
||||
|
||||
---
|
||||
|
||||
## 3. Целевое состояние (to-be)
|
||||
|
||||
### 3.1. Убираем наш антифрод-фильтр
|
||||
|
||||
- Удаляем сервис `DuplicateDetector`.
|
||||
- В `ProcessWebhookJob`: убираем вызов `findMaster` + метод `markAsDuplicate`; новая сделка всегда списывается через `chargeNewLead`. Защита от своих дублей здесь — существующая идемпотентность по `vid` (один `vid` → одна сделка на клиента; разные `vid` с одним телефоном → обе списываются — целевое поведение).
|
||||
- В `RouteSupplierLeadJob::createDealCopyForProject`: убираем вызов `findMaster` + ветку пометки дубля.
|
||||
|
||||
### 3.2. Раздача по клиентам, а не по проектам
|
||||
|
||||
- Подбор получателей схлопывается **до одного проекта на клиента**: среди подходящих проектов клиента берём проект с **наибольшим остатком дневного лимита** (`COALESCE(effective_daily_limit_today, daily_limit_target) − delivered_today` по убыванию; при равенстве — `created_at, id` по возрастанию). Детерминированно.
|
||||
- Лимит `CAP = 3` теперь применяется к **разным клиентам**. 5 клиентов под один источник → ровно 3 получают лид (случайный выбор среди клиентов через инъектируемый `Randomizer`, как сейчас).
|
||||
- Реализация (на усмотрение writing-plans): `DISTINCT ON (tenant_id)` в `LeadRouter` с соответствующим `ORDER BY`, либо явное схлопывание по `tenant_id` в отдельном шаге; `LeadDistributor::selectRecipients` остаётся cap=3 поверх уже «один-на-клиента» списка.
|
||||
|
||||
### 3.3. Замок в БД — «одна поставка одному клиенту = один раз» (ядро Варианта B)
|
||||
|
||||
Новая таблица-замок `supplier_lead_deliveries`:
|
||||
|
||||
| Колонка | Тип | Назначение |
|
||||
|---|---|---|
|
||||
| `supplier_lead_id` | BIGINT NOT NULL | поставка (FK на `supplier_leads(id)` ON DELETE CASCADE — `supplier_leads` не партиционирована) |
|
||||
| `tenant_id` | BIGINT NOT NULL | клиент |
|
||||
| `deal_id` | BIGINT NULL | созданная сделка (без FK — `deals` партиционирована, паттерн как `duplicate_of_id`) |
|
||||
| `created_at` | TIMESTAMPTZ NOT NULL DEFAULT now() | |
|
||||
|
||||
- **PRIMARY KEY (`supplier_lead_id`, `tenant_id`)** — уникальность «поставка ↔ клиент».
|
||||
- **RLS** `tenant_isolation` по `tenant_id` (USING + WITH CHECK на `app.current_tenant_id`), как у прочих tenant-таблиц.
|
||||
- GRANT'ы для 5 ролей (`crm_app_user`, `crm_app_admin`, `crm_supplier_worker` BYPASSRLS, `crm_readonly`, `crm_migrator`).
|
||||
|
||||
**Логика в `createDealCopyForProject`** (внутри той же транзакции с `SET LOCAL app.current_tenant_id`, ПОСЛЕ lock'а tenant и recheck'а лимита проекта, ДО создания сделки):
|
||||
|
||||
1. `INSERT INTO supplier_lead_deliveries (supplier_lead_id, tenant_id, created_at) VALUES (...) ON CONFLICT (supplier_lead_id, tenant_id) DO NOTHING`.
|
||||
2. Если вставлено 0 строк → эта поставка этому клиенту уже выдавалась → `return false` (сделку не создаём, баланс не списываем).
|
||||
3. Иначе → создаём `Deal`, `UPDATE supplier_lead_deliveries SET deal_id = ...`, затем `LedgerService::chargeForDelivery` + счётчики + уведомление.
|
||||
|
||||
**Почему ключ по `supplier_lead_id`, а не по телефону:** это НЕ возвращает телефонный фильтр. Два разных лида с одним телефоном — две разные поставки, два разных `supplier_lead_id`, обе оплачиваются. А одну и ту же поставку клиенту дважды БД физически не пропустит — даже при гонках, перезапусках задачи (`tries=3`) и CSV-восстановлении (где `vid=NULL`, но `supplier_lead_id` всегда есть).
|
||||
|
||||
**Scope:** замок нужен только в шеринг-пути (`RouteSupplierLeadJob`). Прямой вебхук (`ProcessWebhookJob`) не идёт через `supplier_leads` и уже защищён `webhook_dedup_keys (tenant_id, vid)` — там замок не требуется.
|
||||
|
||||
### 3.4. Чистка следов концепции дублей
|
||||
|
||||
- Перестаём писать `deals.duplicate_of_id`. Колонку оставляем **спящей** (решение заказчика 23.05 — безопаснее; удалить можно отдельной задачей, как `balance_leads` в Спеке A Phase B). Индекс `ON deals (duplicate_of_id) WHERE NOT NULL` становится лишним — удаляем сразу.
|
||||
- Убираем строку `duplicate_detected` из матрицы уведомлений `SettingsView.vue` (8×3 → 7×3). Старый ключ в сохранённых `users.notification_preferences` JSONB просто игнорируем (не ломает).
|
||||
|
||||
### 3.5. Что НЕ трогаем
|
||||
|
||||
- Идемпотентность по `vid` в `ProcessWebhookJob` (`webhook_dedup_keys`).
|
||||
- Дедуп CSV по (телефон, проект) в `CsvReconcileJob` — это защита от наших дублей, в духе правила.
|
||||
- Рабочие дни (`delivery_days_mask`), дневные лимиты, проверку баланса (eligibility) — настройки клиента/биллинг, не фильтры дублей.
|
||||
|
||||
---
|
||||
|
||||
## 4. Крайние случаи
|
||||
|
||||
| Случай | Поведение |
|
||||
|---|---|
|
||||
| Один телефон у РАЗНЫХ клиентов | Каждый клиент оплачивает свою копию (без изменений — корректно) |
|
||||
| Один телефон, ДВЕ разные поставки (`vid` A и B), один клиент | Обе списываются (НОВОЕ — раньше глушилось телефонным фильтром) |
|
||||
| Одна поставка, у клиента 2+ подходящих проекта | Один проект (max остаток лимита), одно списание (замок + раздача-по-клиентам) |
|
||||
| 5 клиентов под один источник | Ровно 3 получают и оплачивают |
|
||||
| CSV-восстановленный лид (`vid=NULL`) | Замок по `supplier_lead_id` работает; повторная выдача тому же клиенту не пройдёт |
|
||||
| Перезапуск задачи (`tries=3`) после частичного успеха | `processed_at` + замок не дают повторных списаний |
|
||||
| Прямой вебхук, тот же `vid` повторно | Одна сделка (идемпотентность `vid`), без изменений |
|
||||
|
||||
---
|
||||
|
||||
## 5. Тесты
|
||||
|
||||
Добавить:
|
||||
- Один телефон, две разные поставки, один клиент → списано дважды.
|
||||
- Одна поставка, у клиента 2 подходящих проекта → одна сделка + одно списание; выбран проект с наибольшим остатком лимита (тай-брейк).
|
||||
- 5 клиентов eligible под один источник → ровно 3 списания у 3 разных клиентов.
|
||||
- Замок: повторный `INSERT` той же `(supplier_lead_id, tenant_id)` → `DO NOTHING`, сделка не создаётся, баланс не тронут.
|
||||
- CSV-восстановление: лид с `vid=NULL`, повторная выдача клиенту → замок срабатывает.
|
||||
|
||||
Удалить:
|
||||
- Тесты телефонного фильтра в `ProcessWebhookJobTest`, `RouteSupplierLeadJobTest`, `RouteSupplierLeadJobBillingTest`, `SupplierLeadFlowTest`, `AutoPauseFlowTest`, `DealCreatePdLogTest` (по факту наличия — verify в writing-plans).
|
||||
|
||||
Регрессия: Pest на затронутом коде зелёный; Larastan/Pint/ESLint clean; Vitest на `SettingsView` (после правки матрицы).
|
||||
|
||||
---
|
||||
|
||||
## 6. Выкатка
|
||||
|
||||
- Изменение **одна-фазное**: код + новая аддитивная таблица `supplier_lead_deliveries`. Двухфазность (как в Спеке A с DROP COLUMN) не нужна — ничего разрушающего (`duplicate_of_id` остаётся в БД спящей).
|
||||
- `db/CHANGELOG_schema.md` — запись о новой таблице (правило проекта §4.2).
|
||||
- **Бизнес-эффект:** после выката клиенты начнут платить за то, что раньше глушилось как «дубль» — это и есть заказанная правка. Предупредить заказчика перед прод-merge.
|
||||
|
||||
---
|
||||
|
||||
## 7. Границы (out of scope)
|
||||
|
||||
- `DuplicateDetector.WINDOW_HOURS` тюнинг — N/A, сервис удаляется целиком.
|
||||
- Балансовая модель, тарифные ступени — Спек A.
|
||||
- Preflight баланса, `SupplierQuotaAllocator`, VTB-эквайринг — Спек C.
|
||||
- Физическое удаление `deals.duplicate_of_id` — потенциальная отдельная cleanup-задача.
|
||||
|
||||
---
|
||||
|
||||
## 8. Ключевые решения брейнсторма (зафиксированы)
|
||||
|
||||
1. Дедуп — ответственность поставщика; Лидерра телефон не фильтрует.
|
||||
2. `DuplicateDetector` удаляется полностью (Вариант B принят над «лёгким» A и отвергнутым C «помечать но списывать»).
|
||||
3. Лимит шеринга «3» = 3 РАЗНЫХ клиента; одному клиенту одна копия.
|
||||
4. Тай-брейк проекта внутри клиента — максимальный остаток дневного лимита (детерминированно).
|
||||
5. Защита от своих дублей — на уровне БД (замок `supplier_lead_deliveries`, ключ по поставке, не по телефону).
|
||||
6. `duplicate_of_id` остаётся спящей; индекс по ней удаляется.
|
||||
7. Одна-фазная выкатка.
|
||||
Reference in New Issue
Block a user