phase1(antifraud): DuplicateDetector сервис (Биз-19) — антифрод-дедуп по phone в окне 24ч

Закрыт Биз-19 (§10.8.1) на код-уровне. При создании НОВОЙ сделки сервис
DuplicateDetector ищет master по (tenant_id, phone) в окне 24 ч. Если
найден — новой сделке проставляется duplicate_of_id = master.id, баланс
НЕ списывается, SupplierLeadCost НЕ создаётся. ActivityLog пишется с
context.duplicate_of = master.id.

Реализация:
  - app/app/Services/DuplicateDetector.php — отдельный сервис:
    findMaster(tenantId, phone, ?Carbon $now): ?Deal. Ищет deals с
    duplicate_of_id IS NULL и received_at >= now - 24h. Возвращает
    первую по received_at ASC или null. WINDOW_HOURS = 24 — константа.
  - App\Jobs\ProcessWebhookJob::handle() — после upsertDeal() для новой
    сделки вызывает findMaster(). Если master !== создаваемая сделка —
    markAsDuplicate(): UPDATE duplicate_of_id + ActivityLog с context.
  - DI через app(DuplicateDetector::class) внутри handle() (не в
    сигнатуре — для совместимости с прямыми вызовами из Pest без
    Bus::dispatchSync).

4 новых Pest-теста:
  - master в окне 24ч → дубль, баланс НЕ списывается
  - master старше 24ч → НЕ дубль, баланс списан дважды
  - дубли изолированы по tenant_id
  - ActivityLog для дубля содержит context.duplicate_of

Pest 41/41 зелёные за 4.1 сек. Pint + Larastan чисто.

CLAUDE.md v1.13 → v1.14. Реестр Открытые_вопросы v1.22 → v1.23.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-08 15:41:37 +03:00
parent 1ba25e6b4e
commit 2d9e84ef1d
5 changed files with 233 additions and 7 deletions
+109
View File
@@ -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]);
});