2d9e84ef1d
Закрыт Биз-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>
55 lines
2.3 KiB
PHP
55 lines
2.3 KiB
PHP
<?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();
|
||
}
|
||
}
|