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:
@@ -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([
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user