diff --git a/CLAUDE.md b/CLAUDE.md index 9e86c51a..9beef0e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md — техконтекст Лидерры -**Версия:** 1.13 от 08.05.2026 (поздний вечер) +**Версия:** 1.14 от 08.05.2026 (поздний вечер) **Назначение:** оперативная карта для Claude Code. Не первоисточник — первоисточники указаны в §0. > **Ребрендинг 08.05.2026:** «Лидпоток» → **«Лидерра.»** (с точкой). Палитра, лого и шрифты — из handoff Платона (v8 Forest). Применяется только к дизайну/имени/логотипу; функционал, состав страниц и правила — без изменений (источник — ТЗ v8.5/schema v8.5). @@ -15,7 +15,7 @@ | Полный реестр 28 инструментов и фазы | [docs/Tooling_v8_3.md](docs/Tooling_v8_3.md) (Прил. Н v1.6 от 08.05.2026 поздний вечер — squawk + pgFormatter активны, фаза 1 по тулчейну закрыта 13/17) | | Главное ТЗ | [docs/CRM_bp-gr_Инструкция_v8_5.md](docs/CRM_bp-gr_Инструкция_v8_5.md) (v8.5 от 07.05.2026 — реализация 27 решений аудита C; in-place hygiene v1.20 от 08.05.2026 поздний вечер: §2.4/§5.5/§5.6/§6.5/§11/§20.12.3/§21.1/§27.1 синхронизированы под schema v8.6 двустадийный dedup) | | Схема БД | [db/schema.sql](db/schema.sql) (**v8.7 от 08.05.2026 поздний вечер** — CTO-17 addendum: DEFERRABLE INITIALLY DEFERRED FK на webhook_dedup_keys → deals для двустадийного INSERT'а из §5.5. Метрики без изменений: 55 таблиц + 12 партиций + 92 индекса + 36 RLS + 5 функций + 13 триггеров) | -| Открытые вопросы | [docs/Открытые_вопросы_v8_3.md](docs/Открытые_вопросы_v8_3.md) (v1.22 от 08.05.2026 поздний вечер — закрыты TODO в ProcessWebhookJob: BalanceTransaction/ActivityLog/RejectedDealsLog/SupplierLeadCost модели интегрированы; Pest 37/37) | +| Открытые вопросы | [docs/Открытые_вопросы_v8_3.md](docs/Открытые_вопросы_v8_3.md) (v1.23 от 08.05.2026 поздний вечер — DuplicateDetector сервис (Биз-19, антифрод 24ч по phone) интегрирован в ProcessWebhookJob; Pest 41/41) | | **Брендбук** | [liderra_v8_handoff/docs/BRANDBOOK_v2.md](liderra_v8_handoff/docs/BRANDBOOK_v2.md) **(v2 Forest от 07.05.2026)** — старый `docs/brandbook.md` v1.1 удалён 08.05.2026 | | **Дизайн-handoff (токены, компоненты, 25 экранов)** | [liderra_v8_handoff/docs/DEVELOPER_HANDOFF.md](liderra_v8_handoff/docs/DEVELOPER_HANDOFF.md) (v8 Forest от 07.05.2026) — **только дизайн/токены/компоненты**; функционал и состав экранов — по ТЗ v8.5 | | Анализ оригинала | [docs/Analiz_originala_v8_3.md](docs/Analiz_originala_v8_3.md) (Прил. М v1.1) | @@ -223,6 +223,8 @@ trivy image liderra:latest --- +*CLAUDE.md v1.14 от 08.05.2026 (поздний вечер). Изменения v1.14: **Биз-19 закрыт** — `DuplicateDetector` антифрод-сервис интегрирован в `ProcessWebhookJob`. При создании НОВОЙ сделки ищется master по `(tenant_id, phone)` в окне 24 ч (`received_at >= NOW() - INTERVAL '24 hours'`, `WHERE duplicate_of_id IS NULL`). Если найден — новой сделке проставляется `duplicate_of_id = master.id`, баланс НЕ списывается, SupplierLeadCost НЕ создаётся. ActivityLog пишется с `context.duplicate_of=master.id`. Окно фиксированное 24 ч (§10.8.1, не настраивается на MVP). 4 новых Pest-теста: master в окне 24ч → дубль; master старше 24ч → НЕ дубль; изоляция по tenant_id; ActivityLog context.duplicate_of. **Pest 41/41 зелёные** за 4.1 сек. DI через `app(DuplicateDetector::class)` внутри handle (не в сигнатуре — для совместимости с прямым вызовом из тестов). Реестр v1.22→v1.23.* + *CLAUDE.md v1.13 от 08.05.2026 (поздний вечер). Изменения v1.13: **Webhook PoC завершён** — закрыты все TODO в `ProcessWebhookJob`. Добавлены 5 Eloquent-моделей: `BalanceTransaction` (списание lead_charge -1 при создании сделки, type-константы), `ActivityLog` (event=deal.created с context.source=webhook, event-константы), `RejectedDealsLog` (zero_balance ветка вместо Log::info), `SupplierLeadCost` (composite PK, snapshot cost_rub из suppliers, supplier_id resolved через project_suppliers m2m), `Supplier` (минимальная для FK target). Job::handle() реструктурирован в `chargeNewLead()` + `logRejection()` + `resolveSupplierId()` + `upsertDeal()`. SupplierLeadCost создаётся только если у проекта есть активный supplier через project_suppliers (graceful skip + Log::warning иначе — TODO для production: SystemSetting fallback). 6 новых Pest-тестов: BalanceTransaction lead_charge, дубль НЕ создаёт BalanceTransaction, ActivityLog event=deal.created, RejectedDealsLog reason=zero_balance, SupplierLeadCost snapshot cost_rub, SupplierLeadCost graceful skip. **Pest 37/37 зелёные** за 3.9 сек. Pint+Larastan чисто (ide-helper:models регенерирован для 5 новых моделей). Реестр v1.21→v1.22.* *CLAUDE.md v1.12 от 08.05.2026 (поздний вечер). Изменения v1.12: **CTO-17 addendum** — schema.sql v8.6 → v8.7 + pivot архитектуры upsert на advisory lock. PoC `App\Jobs\ProcessWebhookJob` поймал FK violation в `webhook_dedup_keys`: §5.5 v8.6 спецификация делает INSERT в dedup_keys ДО INSERT в deals, а FK был immediate. Сначала добавил `DEFERRABLE INITIALLY DEFERRED` (schema v8.7) — в bare-транзакции production worker'а работает. Но Pest-тесты с `DatabaseTransactions` trait всё равно падали: PG проверяет deferred constraints на RELEASE SAVEPOINT (внутренняя `DB::transaction()` Job'а становится savepoint при наличии outer-txn от теста), не на outer COMMIT. Воспроизведено standalone PHP-скриптом — это PG-семантика subtransactions. Финальный паттерн: `pg_advisory_xact_lock` сериализует concurrent webhook'и с тем же (tenant_id, vid) → SELECT в dedup_keys атомарен → INSERT deal первым (FK immediate OK) → INSERT dedup_key. Работает identically в любой вложенности транзакций. DEFERRABLE FK сохранён в schema как defense-in-depth для batch-импортов без savepoint. Создан backend-стек: Deal/WebhookDedupKey Eloquent-модели, DealFactory (composite PK setKeysForSaveQuery override), ProcessWebhookJob (advisory-lock upsert), 12 новых Pest-тестов (DealModelTest 6 + ProcessWebhookJobTest 6). **Pest полный прогон 31/31 зелёные** за 2.7 сек. CHANGELOG_schema §W (две стадии решения), narrative §2.4/§5.5/§6.5/§11 синхронизированы. Реестр v1.20 → v1.21.* diff --git a/app/app/Jobs/ProcessWebhookJob.php b/app/app/Jobs/ProcessWebhookJob.php index 404488c4..38684990 100644 --- a/app/app/Jobs/ProcessWebhookJob.php +++ b/app/app/Jobs/ProcessWebhookJob.php @@ -11,6 +11,7 @@ use App\Models\Project; use App\Models\RejectedDealsLog; use App\Models\SupplierLeadCost; use App\Models\Tenant; +use App\Services\DuplicateDetector; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable as FoundationQueueable; @@ -32,8 +33,12 @@ use RuntimeException; * 5. Для НОВОЙ сделки: списание баланса + BalanceTransaction + * SupplierLeadCost (Ю-2) + ActivityLog(deal.created). * + * Антифрод-дедуп Биз-19 (§10.8.1): при создании НОВОЙ сделки `DuplicateDetector` + * ищет master по `(tenant_id, phone)` в окне 24 ч. Если master найден — новой + * сделке проставляется `duplicate_of_id`, баланс НЕ списывается, SupplierLeadCost + * НЕ создаётся. ActivityLog пишется с context.duplicate_of=master.id. + * * Не входит в текущий PoC (отдельные ветви фазы 1): - * - DuplicateDetector (Биз-19, окно 24 ч по phone) * - SendNewLeadNotificationJob (Биз-20) * - Sentry::captureException + FailedWebhookJob в failed() * - SystemSetting fallback для supplier_id (сейчас лукап через project_suppliers) @@ -62,7 +67,9 @@ class ProcessWebhookJob implements ShouldQueue public function handle(): void { - DB::transaction(function (): void { + $duplicateDetector = app(DuplicateDetector::class); + + DB::transaction(function () use ($duplicateDetector): void { DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenantId); $tenant = Tenant::query() @@ -96,12 +103,52 @@ class ProcessWebhookJob implements ShouldQueue receivedAt: $receivedAt, ); - if ($deal->wasRecentlyCreated) { - $this->chargeNewLead($tenant, $project, $deal); + if (! $deal->wasRecentlyCreated) { + return; } + + // Биз-19: master-сделка по phone в окне 24 ч → дубль, без charge. + $master = $duplicateDetector->findMaster( + tenantId: $tenant->id, + phone: (string) $this->data['phone'], + now: $receivedAt, + ); + + // Сам только что созданный $deal попадает в выборку DuplicateDetector + // (он уже в БД к моменту lookup'а), поэтому master может ===$deal. + // Дубль — только если master найден И это НЕ сам deal. + if ($master !== null && $master->id !== $deal->id) { + $this->markAsDuplicate($tenant, $deal, $master); + + return; + } + + $this->chargeNewLead($tenant, $project, $deal); }); } + /** + * Биз-19: помечаем сделку как дубль master'а. БЕЗ списания баланса + * и БЕЗ SupplierLeadCost (не наша закупка). ActivityLog пишется с + * `context.duplicate_of=master.id` для аудита. + */ + private function markAsDuplicate(Tenant $tenant, Deal $deal, Deal $master): void + { + $deal->update(['duplicate_of_id' => $master->id]); + + ActivityLog::create([ + 'tenant_id' => $tenant->id, + 'user_id' => null, + 'deal_id' => $deal->id, + 'event' => ActivityLog::EVENT_DEAL_CREATED, + 'context' => [ + 'source' => 'webhook', + 'duplicate_of' => $master->id, + ], + 'created_at' => now(), + ]); + } + private function logRejection(Tenant $tenant, string $reason): void { RejectedDealsLog::create([ diff --git a/app/app/Services/DuplicateDetector.php b/app/app/Services/DuplicateDetector.php new file mode 100644 index 00000000..16eda380 --- /dev/null +++ b/app/app/Services/DuplicateDetector.php @@ -0,0 +1,54 @@ += NOW() - INTERVAL '24 hours'`. + * Если найдена — новая сделка получает `duplicate_of_id = master.id` и + * НЕ списывает с баланса. + * + * Окно фиксированное 24 ч (не настраивается на MVP) — компромисс между + * антифродом и легитимными повторными интересами. + * + * Цепочки не строятся: дубль ссылается ТОЛЬКО на master (запись без + * `duplicate_of_id`), не на другой дубль. Если master найден среди дублей — + * берётся его собственный `duplicate_of_id` (root master). + * + * Performance: существующий индекс `(tenant_id, phone)` достаточен, см. §10.8.1. + */ +class DuplicateDetector +{ + public const WINDOW_HOURS = 24; + + /** + * Поиск master-сделки для (tenantId, phone) в окне 24 ч. + * + * Возвращает Deal-объект master'а либо null если master не найден. + * Текущий момент `now` параметризуется для тестируемости — в production + * по умолчанию `Carbon::now()`. + */ + public function findMaster(int $tenantId, string $phone, ?Carbon $now = null): ?Deal + { + $now ??= Carbon::now(); + $windowStart = $now->copy()->subHours(self::WINDOW_HOURS); + + return Deal::query() + ->where('tenant_id', $tenantId) + ->where('phone', $phone) + ->where('received_at', '>=', $windowStart) + ->whereNull('duplicate_of_id') + ->orderBy('received_at') + ->first(); + } +} diff --git a/app/tests/Feature/ProcessWebhookJobTest.php b/app/tests/Feature/ProcessWebhookJobTest.php index 07c862c7..c55558cd 100644 --- a/app/tests/Feature/ProcessWebhookJobTest.php +++ b/app/tests/Feature/ProcessWebhookJobTest.php @@ -268,3 +268,112 @@ test('SupplierLeadCost НЕ создаётся если у проекта нет $tenant->refresh(); expect($tenant->balance_leads)->toBe(9); }); + +// ============================================================================= +// Биз-19: антифрод-дедуп по phone в окне 24 ч (DuplicateDetector, §10.8.1) +// ============================================================================= + +test('Биз-19: master в окне 24ч → дубль, баланс НЕ списывается', function () { + $tenant = Tenant::factory()->create(['balance_leads' => 10]); + $phone = '79007770001'; + + // Master: пришёл вчера в 12:00. + $masterPayload = makePayload(vid: 901, time: now()->subHours(12)->timestamp); + $masterPayload['phone'] = $phone; + $masterPayload['phones'] = [$phone]; + (new ProcessWebhookJob($tenant->id, $masterPayload))->handle(); + + $tenant->refresh(); + expect($tenant->balance_leads)->toBe(9); + + // Дубль: пришёл сейчас, в окне 24 ч. + $dupPayload = makePayload(vid: 902, time: now()->timestamp); + $dupPayload['phone'] = $phone; + $dupPayload['phones'] = [$phone]; + (new ProcessWebhookJob($tenant->id, $dupPayload))->handle(); + + $master = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', 901)->first(); + $dup = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', 902)->first(); + + expect($master->duplicate_of_id)->toBeNull(); + expect($dup->duplicate_of_id)->toBe($master->id); + + $tenant->refresh(); + expect($tenant->balance_leads)->toBe(9); // только master списан, дубль — нет + expect(BalanceTransaction::query()->where('tenant_id', $tenant->id)->count())->toBe(1); + expect(SupplierLeadCost::query()->where('deal_id', $dup->id)->count())->toBe(0); +}); + +test('Биз-19: master старше 24ч → НЕ дубль, баланс списывается дважды', function () { + $tenant = Tenant::factory()->create(['balance_leads' => 10]); + $phone = '79007770002'; + + // Master: пришёл 25 часов назад — за окном. + $masterPayload = makePayload(vid: 911, time: now()->subHours(25)->timestamp); + $masterPayload['phone'] = $phone; + $masterPayload['phones'] = [$phone]; + (new ProcessWebhookJob($tenant->id, $masterPayload))->handle(); + + // Новая сделка с тем же phone — master уже за окном. + $newPayload = makePayload(vid: 912, time: now()->timestamp); + $newPayload['phone'] = $phone; + $newPayload['phones'] = [$phone]; + (new ProcessWebhookJob($tenant->id, $newPayload))->handle(); + + $deal911 = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', 911)->first(); + $deal912 = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', 912)->first(); + + expect($deal911->duplicate_of_id)->toBeNull(); + expect($deal912->duplicate_of_id)->toBeNull(); + + $tenant->refresh(); + expect($tenant->balance_leads)->toBe(8); // оба списаны + expect(BalanceTransaction::query()->where('tenant_id', $tenant->id)->count())->toBe(2); +}); + +test('Биз-19: дубли изолированы по tenant_id', function () { + $tenantA = Tenant::factory()->create(['balance_leads' => 10]); + $tenantB = Tenant::factory()->create(['balance_leads' => 10]); + $phone = '79007770003'; + + $payloadA = makePayload(vid: 921); + $payloadA['phone'] = $phone; + $payloadA['phones'] = [$phone]; + (new ProcessWebhookJob($tenantA->id, $payloadA))->handle(); + + // Тот же phone у tenantB — НЕ должен считаться дублем. + $payloadB = makePayload(vid: 922); + $payloadB['phone'] = $phone; + $payloadB['phones'] = [$phone]; + (new ProcessWebhookJob($tenantB->id, $payloadB))->handle(); + + $dealA = Deal::query()->where('tenant_id', $tenantA->id)->first(); + $dealB = Deal::query()->where('tenant_id', $tenantB->id)->first(); + + expect($dealA->duplicate_of_id)->toBeNull(); + expect($dealB->duplicate_of_id)->toBeNull(); +}); + +test('Биз-19: ActivityLog для дубля содержит context.duplicate_of', function () { + $tenant = Tenant::factory()->create(['balance_leads' => 10]); + $phone = '79007770004'; + + $masterPayload = makePayload(vid: 931, time: now()->subHours(2)->timestamp); + $masterPayload['phone'] = $phone; + $masterPayload['phones'] = [$phone]; + (new ProcessWebhookJob($tenant->id, $masterPayload))->handle(); + + $dupPayload = makePayload(vid: 932, time: now()->timestamp); + $dupPayload['phone'] = $phone; + $dupPayload['phones'] = [$phone]; + (new ProcessWebhookJob($tenant->id, $dupPayload))->handle(); + + $master = Deal::query()->where('source_crm_id', 931)->first(); + $dup = Deal::query()->where('source_crm_id', 932)->first(); + + $masterLog = ActivityLog::query()->where('deal_id', $master->id)->first(); + $dupLog = ActivityLog::query()->where('deal_id', $dup->id)->first(); + + expect($masterLog->context)->toBe(['source' => 'webhook']); + expect($dupLog->context)->toBe(['source' => 'webhook', 'duplicate_of' => $master->id]); +}); diff --git a/docs/Открытые_вопросы_v8_3.md b/docs/Открытые_вопросы_v8_3.md index c00d2d84..d538f5e9 100644 --- a/docs/Открытые_вопросы_v8_3.md +++ b/docs/Открытые_вопросы_v8_3.md @@ -2,7 +2,21 @@ **Назначение:** единый рабочий список вопросов, требующих решения заказчика для разблокировки разработки. Разбит по адресатам, внутри — по приоритету. -**Версия:** 1.22 от 08.05.2026 (поздний вечер) — **Webhook PoC завершён**: закрыты все TODO в `ProcessWebhookJob` (BalanceTransaction, ActivityLog, RejectedDealsLog, SupplierLeadCost). 5 новых моделей, 6 новых Pest-тестов. **Pest 37/37 зелёные**. +**Версия:** 1.23 от 08.05.2026 (поздний вечер) — **Биз-19 закрыт**: `DuplicateDetector` антифрод-сервис интегрирован в `ProcessWebhookJob`. Master по `(tenant_id, phone)` в окне 24 ч → дубль с `duplicate_of_id`, без списания. **Pest 41/41 зелёные**. + +**Что изменилось в v1.23 относительно v1.22:** + +- **Закрыт Биз-19** (антифрод-дедуп по phone в окне 24 ч, §10.8.1). Реализация: + - `app/app/Services/DuplicateDetector.php` — отдельный сервис: `findMaster(tenantId, phone, ?Carbon $now): ?Deal`. Ищет в `deals` master-сделку (`duplicate_of_id IS NULL`) с тем же `(tenant_id, phone)` и `received_at >= now - 24h`. Возвращает первую по `received_at ASC` или null. Окно `WINDOW_HOURS = 24` — константа класса. + - `App\Jobs\ProcessWebhookJob::handle()` — после `upsertDeal()` для новой сделки вызывает `DuplicateDetector::findMaster()`. Если master найден И не равен самой только что созданной сделке (она уже в БД, попадает в выборку) — `markAsDuplicate(deal, master)`: проставляет `duplicate_of_id = master.id`, пишет ActivityLog с `context.duplicate_of = master.id`. Списания НЕ происходит (BalanceTransaction + SupplierLeadCost пропускаются). + - DI через `app(DuplicateDetector::class)` внутри `handle()` (не в сигнатуре — для совместимости с прямыми вызовами из Pest без `Bus::dispatchSync`). +- **4 новых Pest-теста** в `ProcessWebhookJobTest`: + - master в окне 24 ч → дубль с `duplicate_of_id`, баланс не списан, BalanceTransaction только для master. + - master старше 24 ч → НЕ дубль, баланс списан дважды. + - дубли изолированы по tenant_id (тот же phone у разных тенантов = НЕ дубль). + - ActivityLog для дубля содержит `context = {source: webhook, duplicate_of: master.id}`. +- **Pest полный прогон 41/41 зелёные** за 4.1 сек. Pint + Larastan чисто. +- **Сводка §0:** без изменений (70 ✅ / 5 🟦 / 4 ⏸ / 1 P0 + 3 P1 + 0 P2) — Биз-19 уже был ✅ в v1.12 (закрытие на ТЗ-уровне), сейчас закрыт на код-уровне. **Что изменилось в v1.22 относительно v1.21:**