refactor(billing-v2): remove DuplicateDetector — trust supplier dedup (Spec B)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-23 20:38:56 +03:00
parent 8fce10f5a0
commit e1fdb5ca8e
9 changed files with 45 additions and 327 deletions
+1 -53
View File
@@ -13,7 +13,6 @@ use App\Models\RejectedDealsLog;
use App\Models\SupplierLeadCost;
use App\Models\SystemSetting;
use App\Models\Tenant;
use App\Services\DuplicateDetector;
use App\Services\NotificationService;
use App\Services\Pd\PdAuditLogger;
use App\Services\SupplierResolver;
@@ -39,11 +38,6 @@ use Throwable;
* 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.
*
* Уведомления (ТЗ §18.5, событие new_lead): после успешного chargeNewLead
* вызывается NotificationService::notifyNewLead, который рассылает email
* всем активным user'ам тенанта с включённым каналом email для new_lead.
@@ -76,9 +70,7 @@ class ProcessWebhookJob implements ShouldQueue
public function handle(): void
{
$duplicateDetector = app(DuplicateDetector::class);
DB::transaction(function () use ($duplicateDetector): void {
DB::transaction(function (): void {
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenantId);
$tenant = Tenant::query()
@@ -116,54 +108,10 @@ class ProcessWebhookJob implements ShouldQueue
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(),
]);
app(PdAuditLogger::class)->record(
action: 'created', subjectType: 'lead', subjectId: $deal->id,
purpose: 'lead_create_webhook', tenantId: (int) $deal->tenant_id,
actorTenantUserId: null, actorAdminUserId: null, ip: null,
);
}
private function logRejection(Tenant $tenant, string $reason): void
{
$rejected = RejectedDealsLog::create([
+5 -44
View File
@@ -11,7 +11,6 @@ use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\Tenant;
use App\Services\Billing\LedgerService;
use App\Services\DuplicateDetector;
use App\Services\LeadDistributor;
use App\Services\LeadRouter;
use App\Services\NotificationService;
@@ -44,9 +43,7 @@ use Throwable;
* 5. Для каждого Project DB::transaction с SET LOCAL app.current_tenant_id:
* - lockForUpdate Tenant.
* - Создать Deal (source_crm_id=vid).
* - DuplicateDetector::findMaster если найден master !== deal, mark
* duplicate_of_id (без charge/counter/notify, ActivityLog с duplicate_of).
* - Иначе: LedgerService::chargeForDelivery(tenant, deal, lead) dual-balance
* - LedgerService::chargeForDelivery(tenant, deal, lead) dual-balance
* списание (prepaid balance_leads-- ИЛИ rub balance_rub-=tier_price), INSERT
* lead_charges + balance_transactions + supplier_lead_costs внутри той же
* транзакции. На InsufficientBalanceException Log::warning + rethrow
@@ -86,7 +83,6 @@ class RouteSupplierLeadJob implements ShouldQueue
public function handle(
LeadRouter $router,
SupplierProjectResolver $resolver,
DuplicateDetector $duplicateDetector,
NotificationService $notifier,
LedgerService $ledger,
LeadDistributor $distributor,
@@ -135,7 +131,7 @@ class RouteSupplierLeadJob implements ShouldQueue
$failures = [];
foreach ($selected as $project) {
try {
if ($this->createDealCopyForProject($lead, $project, $duplicateDetector, $notifier, $ledger, $subjectCode)) {
if ($this->createDealCopyForProject($lead, $project, $notifier, $ledger, $subjectCode)) {
$createdCount++;
}
} catch (Throwable $e) {
@@ -205,19 +201,18 @@ class RouteSupplierLeadJob implements ShouldQueue
/**
* Создаёт deal-копию в одной транзакции для конкретного Project.
* Возвращает true если копия не дубль (баланс списан, счётчики выросли).
* false если копия помечена дублем (без списания).
* Возвращает true если deal создан и баланс списан, счётчики выросли.
* false если лимит исчерпан под блокировкой (deal не создаётся).
*/
private function createDealCopyForProject(
SupplierLead $lead,
Project $project,
DuplicateDetector $duplicateDetector,
NotificationService $notifier,
LedgerService $ledger,
?int $subjectCode,
): bool {
try {
return DB::transaction(function () use ($lead, $project, $duplicateDetector, $notifier, $ledger, $subjectCode): bool {
return DB::transaction(function () use ($lead, $project, $notifier, $ledger, $subjectCode): bool {
DB::statement("SET LOCAL app.current_tenant_id = '{$project->tenant_id}'");
/** @var Tenant $tenant */
@@ -271,40 +266,6 @@ class RouteSupplierLeadJob implements ShouldQueue
'subject_code' => $subjectCode,
]);
$master = $duplicateDetector->findMaster(
tenantId: (int) $tenant->id,
phone: (string) $lead->phone,
now: $receivedAt,
);
// Только что созданный $deal сам попадает в выборку DuplicateDetector
// (он уже в БД к моменту lookup'а), поэтому master может ===$deal.
// Дубль — только если master найден И это НЕ сам deal.
if ($master !== null && $master->id !== $deal->id) {
$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' => 'supplier_webhook',
'duplicate_of' => $master->id,
'supplier_lead_id' => $lead->id,
],
'created_at' => now(),
]);
app(PdAuditLogger::class)->record(
action: 'created', subjectType: 'lead', subjectId: $deal->id,
purpose: 'lead_create_supplier', tenantId: (int) $deal->tenant_id,
actorTenantUserId: null, actorAdminUserId: null, ip: null,
);
return false;
}
// Task 6: $ledger->chargeForDelivery бросит InsufficientBalanceException —
// транзакция откатится, и outer catch ниже отловит для auto-pause flow.
$ledger->chargeForDelivery($tenant, $deal, $lead);
-54
View File
@@ -1,54 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Deal;
use Illuminate\Support\Carbon;
/**
* Антифрод-дедуп лидов по `(tenant_id, phone)` в окне 24 ч (Биз-19, §10.8.1).
*
* Цель: в pay-per-lead-сегменте поставщик может прислать одно физлицо дважды
* (двойной submit формы / повторный звонок) без защиты клиент платит за оба.
*
* Стратегия: ищем master-сделку (запись без `duplicate_of_id`) с тем же
* `(tenant_id, phone)` и `received_at >= 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();
}
}
@@ -9,7 +9,6 @@ use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Billing\LedgerService;
use App\Services\DuplicateDetector;
use App\Services\LeadDistributor;
use App\Services\LeadRouter;
use App\Services\NotificationService;
@@ -38,7 +37,6 @@ function runRouteJob(int $supplierLeadId): void
(new RouteSupplierLeadJob($supplierLeadId))->handle(
app(LeadRouter::class),
app(SupplierProjectResolver::class),
app(DuplicateDetector::class),
app(NotificationService::class),
app(LedgerService::class),
app(LeadDistributor::class),
@@ -157,54 +155,6 @@ it('charges balance_rub for tenant after routing', function (): void {
expect((string) $tenant->fresh()->balance_rub)->toBe('99500.00');
});
it('marks duplicate via DuplicateDetector — no charge, no counter increment', function (): void {
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => 'test.ru',
]);
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'supplier_b1_project_id' => $supplier->id,
'signal_type' => 'site',
'signal_identifier' => 'test.ru',
'is_active' => true,
'delivered_today' => 0,
]);
linkProjectToSupplier($project, $supplier);
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
$master = Deal::create([
'tenant_id' => $tenant->id,
'source_crm_id' => 999,
'project_id' => $project->id,
'phone' => '79991234567',
'phones' => ['79991234567'],
'status' => 'new',
'received_at' => now()->subHours(2),
]);
$vid = 1000;
$lead = SupplierLead::factory()->create([
'supplier_project_id' => null,
'platform' => 'B1',
'vid' => $vid,
'phone' => '79991234567',
'raw_payload' => ['vid' => $vid, 'project' => 'B1_test.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
]);
runRouteJob($lead->id);
expect((string) $tenant->fresh()->balance_rub)->toBe('100000.00');
expect($project->fresh()->delivered_today)->toBe(0);
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
$duplicate = Deal::where('source_crm_id', $vid)->first();
expect($duplicate)->not->toBeNull();
expect($duplicate->duplicate_of_id)->toBe($master->id);
});
it('throws DomainException when payload encodes B1+SMS combo', function (): void {
$vid = 1;
$lead = SupplierLead::factory()->create([
@@ -236,7 +186,7 @@ it('handles orphan supplier_project (no matching liderra-projects) — 0 deals,
expect($lead->supplier_project_id)->not->toBeNull();
});
it('handles mixed routing: 3 projects, 1 with pre-existing master (dup), 2 clean', function (): void {
it('same phone pre-existing does not suppress new delivery (Spec B)', function (): void {
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'site',
@@ -260,14 +210,14 @@ it('handles mixed routing: 3 projects, 1 with pre-existing master (dup), 2 clean
linkProjectToSupplier($projects->last(), $supplier);
}
// Tenant #0 имеет master deal с тем же phone в окне 24 ч — будет дубль.
$masterTenant = $tenants[0];
$masterProject = $projects[0];
DB::statement("SET LOCAL app.current_tenant_id = '{$masterTenant->id}'");
$master = Deal::create([
'tenant_id' => $masterTenant->id,
// Tenant #0 имеет pre-existing deal с тем же phone — под новым правилом НЕ подавляет.
$firstTenant = $tenants[0];
$firstProject = $projects[0];
DB::statement("SET LOCAL app.current_tenant_id = '{$firstTenant->id}'");
Deal::create([
'tenant_id' => $firstTenant->id,
'source_crm_id' => 555,
'project_id' => $masterProject->id,
'project_id' => $firstProject->id,
'phone' => '79991234567',
'phones' => ['79991234567'],
'status' => 'new',
@@ -287,22 +237,19 @@ it('handles mixed routing: 3 projects, 1 with pre-existing master (dup), 2 clean
$lead->refresh();
expect($lead->processed_at)->not->toBeNull();
expect($lead->deals_created_count)->toBe(2); // 2 чистых, 1 дубль не считается
// Spec B: pre-existing master does NOT suppress — all 3 charged.
expect($lead->deals_created_count)->toBe(3);
// Tenant #0: deal помечен duplicate_of_id, balance НЕ списан, delivered_today = 0
expect((string) $masterTenant->fresh()->balance_rub)->toBe('100000.00');
expect($masterProject->fresh()->delivered_today)->toBe(0);
DB::statement("SET LOCAL app.current_tenant_id = '{$masterTenant->id}'");
$dupDeal = Deal::query()->where('source_crm_id', $vid)->first();
expect($dupDeal->duplicate_of_id)->toBe($master->id);
// Tenant #1, #2: balance списан, delivered_today инкрементирован
foreach ([1, 2] as $i) {
// All 3 tenants: balance decremented, delivered_today incremented.
foreach (range(0, 2) as $i) {
$t = $tenants[$i];
$p = $projects[$i];
expect((string) $t->fresh()->balance_rub)->toBe('99500.00');
expect($p->fresh()->delivered_today)->toBe(1);
}
// 3 deal rows exist for this vid (one per tenant).
expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(3);
});
it('idempotent on retry — second handle() returns early, no ghost duplicate deals (Plan 2.5 fix #3)', function (): void {
@@ -575,7 +522,6 @@ it('caps deal creation at 3 recipients and tags deal with subject from payload',
(new RouteSupplierLeadJob($lead->id))->handle(
app(LeadRouter::class),
app(SupplierProjectResolver::class),
app(DuplicateDetector::class),
app(NotificationService::class),
app(LedgerService::class),
app(LeadDistributor::class),
@@ -17,7 +17,6 @@ use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Billing\LedgerService;
use App\Services\DuplicateDetector;
use App\Services\LeadDistributor;
use App\Services\LeadRouter;
use App\Services\NotificationService;
@@ -107,7 +106,6 @@ it('writes pd_processing_log created (supplier) when deal created via RouteSuppl
(new RouteSupplierLeadJob($lead->id))->handle(
app(LeadRouter::class),
app(SupplierProjectResolver::class),
app(DuplicateDetector::class),
app(NotificationService::class),
app(LedgerService::class),
app(LeadDistributor::class),
+24 -99
View File
@@ -275,112 +275,37 @@ test('SupplierLeadCost НЕ создаётся если у проекта нет
});
// =============================================================================
// Биз-19: антифрод-дедуп по phone в окне 24 ч (DuplicateDetector, §10.8.1)
// Spec B: no phone dedup — supplier owns dedup, Лидерра charges everything delivered
// =============================================================================
test('Биз-19: master в окне 24ч → дубль, баланс НЕ списывается', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
$phone = '79007770001';
test('charges both leads with same phone but different vid (no phone dedup, Spec B)', function () {
$tenant = Tenant::factory()->create(['balance_leads' => 5]);
$phone = '79007770010';
// Master: пришёл вчера в 12:00.
$masterPayload = makePayload(vid: 901, time: now()->subHours(12)->timestamp);
$masterPayload['phone'] = $phone;
$masterPayload['phones'] = [$phone];
(new ProcessWebhookJob($tenant->id, $masterPayload))->handle();
// First webhook — distinct vid
$payload1 = makePayload(vid: 951);
$payload1['phone'] = $phone;
$payload1['phones'] = [$phone];
(new ProcessWebhookJob($tenant->id, $payload1))->handle();
// Second webhook — same phone, different vid
$payload2 = makePayload(vid: 952);
$payload2['phone'] = $phone;
$payload2['phones'] = [$phone];
(new ProcessWebhookJob($tenant->id, $payload2))->handle();
$tenant->refresh();
expect($tenant->balance_leads)->toBe(9);
// Both charged — balance_leads decremented twice.
expect($tenant->balance_leads)->toBe(3);
// Дубль: пришёл сейчас, в окне 24 ч.
$dupPayload = makePayload(vid: 902, time: now()->timestamp);
$dupPayload['phone'] = $phone;
$dupPayload['phones'] = [$phone];
(new ProcessWebhookJob($tenant->id, $dupPayload))->handle();
// Two distinct deals exist for this tenant.
$deals = Deal::query()->where('tenant_id', $tenant->id)->get();
expect($deals)->toHaveCount(2);
$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]);
// Neither deal has duplicate_of_id set.
foreach ($deals as $deal) {
expect($deal->duplicate_of_id)->toBeNull();
}
});
// =============================================================================
@@ -10,7 +10,6 @@ use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Billing\LedgerService;
use App\Services\DuplicateDetector;
use App\Services\LeadDistributor;
use App\Services\LeadRouter;
use App\Services\NotificationService;
@@ -66,7 +65,6 @@ function runJob(int $leadId): void
(new RouteSupplierLeadJob($leadId))->handle(
app(LeadRouter::class),
app(SupplierProjectResolver::class),
app(DuplicateDetector::class),
app(NotificationService::class),
app(LedgerService::class),
app(LeadDistributor::class),
@@ -12,7 +12,6 @@ use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Billing\LedgerService;
use App\Services\DuplicateDetector;
use App\Services\LeadDistributor;
use App\Services\LeadRouter;
use App\Services\NotificationService;
@@ -90,7 +89,6 @@ function dispatchJob(int $supplierLeadId): void
(new RouteSupplierLeadJob($supplierLeadId))->handle(
app(LeadRouter::class),
app(SupplierProjectResolver::class),
app(DuplicateDetector::class),
app(NotificationService::class),
app(LedgerService::class),
app(LeadDistributor::class),
@@ -10,7 +10,6 @@ use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Billing\LedgerService;
use App\Services\DuplicateDetector;
use App\Services\LeadDistributor;
use App\Services\LeadRouter;
use App\Services\NotificationService;
@@ -29,7 +28,6 @@ function runRouteJobB(int $id): void
(new RouteSupplierLeadJob($id))->handle(
app(LeadRouter::class),
app(SupplierProjectResolver::class),
app(DuplicateDetector::class),
app(NotificationService::class),
app(LedgerService::class),
app(LeadDistributor::class),