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:
@@ -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]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user