From d7263ad7c0d95d4a0097bc482173ef77cc556c8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 23 May 2026 19:15:59 +0300 Subject: [PATCH] =?UTF-8?q?docs(billing-v2):=20=D1=81=D0=BF=D0=B5=D0=BA=20?= =?UTF-8?q?B=20=E2=80=94=20=D0=BF=D0=BE=D0=BB=D0=B8=D1=82=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=B4=D1=83=D0=B1=D0=BB=D0=B5=D0=B9=20(=D0=B4=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D0=B9=D0=BD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Правило: дедуп — ответственность поставщика; Лидерра телефон не фильтрует, берём за всё, что прислано. Защита от своих дублей — на уровне БД (замок supplier_lead_deliveries, ключ по поставке, не по телефону). Лимит шеринга = 3 разных клиента, одному клиенту одна копия. Вариант B (железобетонный) утверждён на брейнсторме 23.05.2026. Co-Authored-By: Claude Opus 4.7 --- ...-23-billing-v2-spec-b-duplicates-design.md | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-23-billing-v2-spec-b-duplicates-design.md diff --git a/docs/superpowers/specs/2026-05-23-billing-v2-spec-b-duplicates-design.md b/docs/superpowers/specs/2026-05-23-billing-v2-spec-b-duplicates-design.md new file mode 100644 index 00000000..0ac8a0f4 --- /dev/null +++ b/docs/superpowers/specs/2026-05-23-billing-v2-spec-b-duplicates-design.md @@ -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. Одна-фазная выкатка.