feat: G1/SP1 самозапись клиента с подтверждением почты 6-значным кодом

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-18 19:33:33 +03:00
parent ae0a4174ea
commit 53fb7b7760
53 changed files with 3476 additions and 1119 deletions
@@ -7,7 +7,6 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Concerns\WritesAuthLog;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use App\Http\Requests\Auth\RegisterRequest;
use App\Mail\SuspiciousLoginNotification;
use App\Models\Tenant;
use App\Models\User;
@@ -131,38 +130,6 @@ class AuthController extends Controller
]);
}
public function register(RegisterRequest $request): JsonResponse
{
// На MVP — attach нового user'а к первому tenant'у (для UI-разводки).
// Production: wizard с tenant_name + ИНН + создание Tenant + первый user owner-роли.
$tenant = Tenant::first();
if (! $tenant) {
return response()->json([
'message' => 'Tenants не настроены. Обратитесь к администратору.',
], 503);
}
$user = User::create([
'tenant_id' => $tenant->id,
'email' => $request->string('email')->toString(),
'password_hash' => Hash::make($request->string('password')->toString()),
'first_name' => 'Новый',
'last_name' => 'Пользователь',
'is_active' => true,
'totp_enabled' => false,
]);
Auth::login($user);
$request->session()->regenerate();
$this->logAuthEvent('register_success', $user->id, $user->tenant_id, $user->email, $request->ip(), $request->userAgent(), null);
return response()->json([
'user' => $this->userResource($user),
'requires_2fa' => false,
], 201);
}
public function me(Request $request): JsonResponse
{
/** @var User $user */
@@ -13,6 +13,7 @@ use App\Models\User;
use App\Repositories\PricingTierRepository;
use App\Services\Billing\BalanceToLeadsConverter;
use App\Services\Billing\BillingTopupService;
use App\Services\Billing\RunwayCalculator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -318,6 +319,6 @@ class BillingController extends Controller
{
// F3 (17.06.2026): единый источник расчёта — RunwayCalculator (общий с дашбордом),
// чтобы прогноз «хватит на дни» не расходился между биллингом и дашбордом.
return app(\App\Services\Billing\RunwayCalculator::class)->daysLeft((int) $tenant->id, $affordableLeads);
return app(RunwayCalculator::class)->daysLeft((int) $tenant->id, $affordableLeads);
}
}
@@ -6,6 +6,10 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Tenant;
use App\Repositories\PricingTierRepository;
use App\Services\Billing\BalanceToLeadsConverter;
use App\Services\Billing\RunwayCalculator;
use Carbon\Carbon;
use Carbon\CarbonImmutable;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -108,15 +112,15 @@ class DashboardController extends Controller
// для рублёвых тенантов) → расходился с биллингом «0 дней ↔ N дней».
// Теперь — affordable leads от рублёвого баланса по тарифу
// (BalanceToLeadsConverter) + общий RunwayCalculator.
$activeTiers = app(\App\Repositories\PricingTierRepository::class)
->activeAt(\Carbon\Carbon::now('Europe/Moscow'));
$conversion = app(\App\Services\Billing\BalanceToLeadsConverter::class)->convert(
$activeTiers = app(PricingTierRepository::class)
->activeAt(Carbon::now('Europe/Moscow'));
$conversion = app(BalanceToLeadsConverter::class)->convert(
(string) $tenant->balance_rub,
(int) ($tenant->delivered_in_month ?? 0),
$activeTiers,
);
$affordableLeads = (int) $conversion['leads'];
$runwayDays = app(\App\Services\Billing\RunwayCalculator::class)
$runwayDays = app(RunwayCalculator::class)
->daysLeft($tenantId, $affordableLeads) ?? 0;
// --- средняя стоимость лида (F5): среднее фактически списанных rub-сумм
@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\ActivityLog;
use App\Models\Deal;
use App\Models\LeadCharge;
use App\Models\Project;
use App\Models\SupplierLeadCost;
use App\Models\User;
@@ -270,7 +271,7 @@ class DealController extends Controller
// F2: реальная стоимость лида — снимок списания из lead_charges
// (rub-провенанс). Запрос в транзакции, где выставлен app.current_tenant_id.
$charge = \App\Models\LeadCharge::query()
$charge = LeadCharge::query()
->where('tenant_id', $tenantId)
->where('deal_id', $id)
->first();
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Concerns\WritesAuthLog;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\ConfirmEmailRequest;
use App\Http\Requests\Auth\RegisterRequest;
use App\Http\Requests\Auth\ResendCodeRequest;
use App\Models\User;
use App\Services\Auth\RegistrationException;
use App\Services\Auth\RegistrationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
/**
* Самозапись клиента (G1/SP1): register confirm-email (вход).
* Подтверждение почты 6-значным кодом; новый тенант создаётся в статусе
* pending_email_confirm, активируется и получает 300 при подтверждении.
*/
class RegistrationController extends Controller
{
use WritesAuthLog;
public function register(RegisterRequest $request, RegistrationService $service): JsonResponse
{
try {
$result = $service->register(
$request->string('email')->toString(),
$request->string('password')->toString(),
$request->input('captcha_token'),
$request->ip(),
);
} catch (RegistrationException $e) {
return $this->registrationError($e);
}
$payload = [
'status' => $result['status'],
'email' => $result['user']->email,
'expires_at' => $result['verification']->expires_at->toIso8601String(),
];
if ($result['dev_code'] !== null) {
$payload['_dev_plain_code'] = $result['dev_code'];
}
return response()->json($payload, 201);
}
public function confirmEmail(ConfirmEmailRequest $request, RegistrationService $service): JsonResponse
{
try {
$user = $service->confirm(
$request->string('email')->toString(),
$request->string('code')->toString(),
);
} catch (RegistrationException $e) {
$payload = ['message' => 'Код подтверждения недействителен.', 'reason' => $e->reason];
if ($e->attemptsRemaining !== null) {
$payload['attempts_remaining'] = $e->attemptsRemaining;
}
return response()->json($payload, 422);
}
Auth::login($user);
$request->session()->regenerate();
$this->logAuthEvent('register_success', $user->id, $user->tenant_id, $user->email, $request->ip(), $request->userAgent(), null);
return response()->json([
'user' => $this->userResource($user),
'requires_2fa' => false,
]);
}
public function resendCode(ResendCodeRequest $request, RegistrationService $service): JsonResponse
{
$devCode = $service->resend($request->string('email')->toString());
$payload = ['message' => 'Если аккаунт ожидает подтверждения, мы отправили новый код на указанный email.'];
if ($devCode !== null) {
$payload['_dev_plain_code'] = $devCode;
}
return response()->json($payload);
}
private function registrationError(RegistrationException $e): JsonResponse
{
$map = [
'captcha_failed' => ['captcha_token', 'Проверка «я не робот» не пройдена.'],
'email_taken' => ['email', 'Аккаунт с таким email уже существует.'],
];
[$field, $message] = $map[$e->reason] ?? ['email', 'Не удалось зарегистрировать аккаунт.'];
return response()->json([
'message' => $message,
'errors' => [$field => [$message]],
], 422);
}
/** @return array<string, mixed> */
private function userResource(User $user): array
{
return [
'id' => $user->id,
'email' => $user->email,
'first_name' => $user->first_name,
'last_name' => $user->last_name,
'phone' => $user->phone,
'timezone' => $user->timezone,
'tenant_id' => $user->tenant_id,
'totp_enabled' => $user->totp_enabled,
'last_login_at' => $user->last_login_at,
'notification_preferences' => $user->notification_preferences,
'sound_enabled' => $user->sound_enabled,
];
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Auth;
use Illuminate\Foundation\Http\FormRequest;
/**
* Валидация POST /api/auth/confirm-email подтверждение почты 6-значным кодом.
*/
class ConfirmEmailRequest extends FormRequest
{
/** @return array<string, mixed> */
public function rules(): array
{
return [
'email' => ['required', 'string', 'email', 'max:255'],
'code' => ['required', 'string', 'regex:/^\d{6}$/'],
];
}
/** @return array<string, string> */
public function messages(): array
{
return [
'email.required' => 'Укажите email.',
'code.required' => 'Укажите код из письма.',
'code.regex' => 'Код состоит из 6 цифр.',
];
}
}
+10 -3
View File
@@ -18,14 +18,21 @@ class RegisterRequest extends FormRequest
{
use HasPasswordRules;
/** @return array<string, mixed> */
/**
* @return array<string, mixed>
*
* NB: уникальность email НЕ через DB-rule её решает RegistrationService
* (активный email 422 email_taken; неподтверждённый перевыпуск кода).
* Капча проверяется на КАЖДОМ register-запросе (это независимый публичный POST).
*/
public function rules(): array
{
return [
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
'email' => ['required', 'string', 'email', 'max:255'],
'password' => $this->passwordRules(),
'accept_offer' => ['required', 'accepted'],
'accept_pdn' => ['required', 'accepted'],
'captcha_token' => ['required', 'string'],
];
}
@@ -35,9 +42,9 @@ class RegisterRequest extends FormRequest
return array_merge($this->passwordMessages(), [
'email.required' => 'Укажите email.',
'email.email' => 'Email указан некорректно.',
'email.unique' => 'Аккаунт с таким email уже существует.',
'accept_offer.accepted' => 'Необходимо принять оферту.',
'accept_pdn.accepted' => 'Необходимо согласие на обработку персональных данных.',
'captcha_token.required' => 'Подтвердите, что вы не робот.',
]);
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Auth;
use Illuminate\Foundation\Http\FormRequest;
/**
* Валидация POST /api/auth/resend-code повторная отправка кода подтверждения.
*/
class ResendCodeRequest extends FormRequest
{
/** @return array<string, mixed> */
public function rules(): array
{
return [
'email' => ['required', 'string', 'email', 'max:255'],
];
}
/** @return array<string, string> */
public function messages(): array
{
return [
'email.required' => 'Укажите email.',
'email.email' => 'Email указан некорректно.',
];
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Письмо с 6-значным кодом подтверждения почты при самозаписи (G1/SP1).
*/
final class EmailVerificationCodeMail extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(
public readonly string $code,
public readonly string $email,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Код подтверждения регистрации в Лидерре',
to: [$this->email],
);
}
public function content(): Content
{
return new Content(
view: 'emails.email_verification_code',
with: ['code' => $this->code],
);
}
}
+2 -1
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Mail;
use App\Models\Deal;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Bus\Queueable;
@@ -17,7 +18,7 @@ use Illuminate\Support\Collection;
* Письмо-сводка о новых сделках за окно (G2-A дайджест).
* Заменяет пер-лид NewLeadNotification как email-канал события new_lead.
*
* @property Collection<int, \App\Models\Deal> $deals
* @property Collection<int, Deal> $deals
*/
class NewLeadsDigestMail extends Mailable
{
+71
View File
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
/**
* Код подтверждения почты при самозаписи клиента (G1/SP1).
*
* 6-значный код, bcrypt-хеш в code_hash, plain уходит письмом. TTL 15 мин,
* 5 попыток. Механика зеркалит ImpersonationToken. Таблица RLS-изолирована
* (через user_id→users.tenant_id) на публичном роуте читается/пишется через
* BYPASSRLS pgsql_supplier.
*
* @property int $id
* @property int $user_id
* @property string $email
* @property string $token
* @property string|null $code_hash
* @property int $failed_attempts
* @property Carbon $expires_at
* @property Carbon|null $verified_at
* @property Carbon $created_at
*/
class EmailVerification extends Model
{
/** schema-таблица не имеет updated_at. */
public const UPDATED_AT = null;
protected $fillable = [
'user_id',
'email',
'token',
'code_hash',
'failed_attempts',
'expires_at',
'verified_at',
];
protected function casts(): array
{
return [
'failed_attempts' => 'integer',
'expires_at' => 'datetime',
'verified_at' => 'datetime',
'created_at' => 'datetime',
];
}
public function isExpired(): bool
{
return $this->expires_at->isPast();
}
public function isUsable(): bool
{
return $this->verified_at === null
&& $this->failed_attempts < 5
&& ! $this->isExpired();
}
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+10 -1
View File
@@ -2,6 +2,8 @@
namespace App\Providers;
use App\Services\Captcha\CaptchaVerifier;
use App\Services\Captcha\NullCaptchaVerifier;
use App\Services\Supplier\Channel\AjaxProjectChannel;
use App\Services\Supplier\Channel\FailoverProjectChannel;
use App\Services\Supplier\Channel\FormProjectChannel;
@@ -37,6 +39,13 @@ class AppServiceProvider extends ServiceProvider
$app->make(Mailer::class),
),
);
// Шов капчи самозаписи (G1/SP1). SP1 — Null-драйвер; реальный
// Yandex SmartCaptcha встанет сюда позже через match по драйверу.
$this->app->bind(
CaptchaVerifier::class,
NullCaptchaVerifier::class,
);
}
/**
@@ -49,7 +58,7 @@ class AppServiceProvider extends ServiceProvider
// login / 2fa / password. Применение — throttle:<name> в routes/web.php.
// 20/мин — стартовое значение (выше максимума тестовых циклов), снижать по
// боевому трафику. Runbook: docs/superpowers/runbooks/2026-06-17-auth-throttle-limits.md
foreach (['auth-login', 'auth-2fa', 'auth-password'] as $limiterName) {
foreach (['auth-login', 'auth-2fa', 'auth-password', 'auth-register'] as $limiterName) {
RateLimiter::for(
$limiterName,
fn (Request $request) => Limit::perMinute(20)->by($request->ip() ?: 'unknown'),
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Services\Auth;
use RuntimeException;
/**
* Доменная ошибка самозаписи. reason машинный код для ответа контроллера:
* email_taken | captcha_failed | not_found | expired | too_many_attempts | invalid_code.
*/
final class RegistrationException extends RuntimeException
{
public function __construct(
public readonly string $reason,
public readonly ?int $attemptsRemaining = null,
) {
parent::__construct($reason);
}
}
@@ -0,0 +1,256 @@
<?php
declare(strict_types=1);
namespace App\Services\Auth;
use App\Mail\EmailVerificationCodeMail;
use App\Models\EmailVerification;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Captcha\CaptchaVerifier;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
/**
* Оркестрация самозаписи (G1/SP1): register / confirm / resend.
*
* Все обращения к tenants/users/email_verifications через BYPASSRLS-подключение
* pgsql_supplier: публичные роуты не выставляют app.current_tenant_id, и под RLS
* (роль crm_app_user) SELECT/INSERT по этим таблицам не прошёл бы.
*
* Гонка дублей: в схеме нет глобального UNIQUE(users.email) (только
* UNIQUE(tenant_id,email)), поэтому «проверка-потом-вставка» сериализуется
* advisory-локом по email внутри транзакции два параллельных register на один
* новый email не создадут два тенанта (лок снимается на commit/rollback).
*/
class RegistrationService
{
private const DB_CONNECTION = 'pgsql_supplier';
private const CODE_TTL_MINUTES = 15;
private const MAX_FAILED_ATTEMPTS = 5;
private const START_BALANCE_RUB = '300.00';
public function __construct(private readonly CaptchaVerifier $captcha) {}
/**
* @return array{status:string, user:User, verification:EmailVerification, dev_code:?string}
*/
public function register(string $email, string $password, ?string $captchaToken, ?string $ip): array
{
if (! $this->captcha->verify($captchaToken, $ip)) {
throw new RegistrationException('captcha_failed');
}
$email = mb_strtolower(trim($email));
$conn = DB::connection(self::DB_CONNECTION);
// Сериализация одновременных регистраций одного email (TOCTOU-защита, см. docblock).
// Письмо отправляем ПОСЛЕ commit — не держим SMTP внутри транзакции.
$issued = $this->atomic(function () use ($conn, $email, $password) {
$conn->statement('SELECT pg_advisory_xact_lock(hashtext(?))', ['liderra:self-register:'.$email]);
$existing = User::on(self::DB_CONNECTION)->where('email', $email)->first();
if ($existing && $existing->is_active) {
throw new RegistrationException('email_taken');
}
$user = $existing ?: $this->createPendingTenantOwner($email, $password);
return $this->createCodeRecord($user);
});
$this->sendCode($issued['user']->email, $issued['plain']);
return [
'status' => 'pending_email_confirm',
'user' => $issued['user'],
'verification' => $issued['record'],
'dev_code' => $issued['dev_code'],
];
}
public function confirm(string $email, string $code): User
{
$email = mb_strtolower(trim($email));
$user = User::on(self::DB_CONNECTION)->where('email', $email)->first();
if (! $user) {
throw new RegistrationException('not_found');
}
$record = EmailVerification::on(self::DB_CONNECTION)
->where('user_id', $user->id)
->whereNull('verified_at')
->orderByDesc('id')
->first();
if (! $record || ! $record->isUsable()) {
$reason = $record === null ? 'not_found'
: ($record->isExpired() ? 'expired' : 'too_many_attempts');
throw new RegistrationException($reason);
}
if (! Hash::check($code, (string) $record->code_hash)) {
// increment ВНЕ транзакции: счётчик должен пережить 422 (откат сбросил
// бы failed_attempts и сломал лимит 5 попыток).
$record->increment('failed_attempts');
throw new RegistrationException(
'invalid_code',
max(0, self::MAX_FAILED_ATTEMPTS - $record->failed_attempts),
);
}
// Успех — атомарно: пометка кода + активация владельца + статус/баланс тенанта.
$this->atomic(function () use ($record, $user): void {
$record->update(['verified_at' => now()]);
$user->update(['is_active' => true]);
Tenant::on(self::DB_CONNECTION)->where('id', $user->tenant_id)->update([
'status' => 'active',
'balance_rub' => self::START_BALANCE_RUB,
]);
});
return $user->fresh();
}
/** @return ?string dev-код (только local/testing), иначе null. Anti-enumeration: тихо для active/missing. */
public function resend(string $email): ?string
{
$email = mb_strtolower(trim($email));
$conn = DB::connection(self::DB_CONNECTION);
$issued = $this->atomic(function () use ($conn, $email) {
$conn->statement('SELECT pg_advisory_xact_lock(hashtext(?))', ['liderra:self-register:'.$email]);
$user = User::on(self::DB_CONNECTION)->where('email', $email)->first();
if ($user && ! $user->is_active) {
return $this->createCodeRecord($user);
}
return null;
});
if ($issued === null) {
return null;
}
$this->sendCode($issued['user']->email, $issued['plain']);
return $issued['dev_code'];
}
/**
* Выполнить $work атомарно на pgsql_supplier.
*
* Прод-путь: соединение НЕ в транзакции открываем свою (`transaction()`),
* advisory xact-lock держится до commit/rollback корректная сериализация.
*
* Если PDO УЖЕ в транзакции (внешний caller обернул нас ИЛИ тест-харнес
* SharesSupplierPdo делит уже-открытый PDO под DatabaseTransactions)
* участвуем в существующей транзакции без вложенного beginTransaction:
* pgsql_supplier-connection не отслеживает уровень внешней транзакции, и
* `transaction()` попытался бы `PDO::beginTransaction()` поверх открытой
* «There is already an active transaction». Это nested-transaction-safety,
* не тест-специфичная ветка: повторный вызов внутри открытой транзакции
* корректно переиспользует её.
*
* @template T
*
* @param callable():T $work
* @return T
*/
private function atomic(callable $work): mixed
{
$conn = DB::connection(self::DB_CONNECTION);
if ($conn->getPdo()->inTransaction()) {
return $work();
}
return $conn->transaction($work);
}
private function createPendingTenantOwner(string $email, string $password): User
{
$tenant = Tenant::on(self::DB_CONNECTION)->create([
'subdomain' => $this->generateSubdomain($email),
'organization_name' => $email, // плейсхолдер; уточняется в SP2 (реквизиты)
'contact_email' => $email,
'balance_rub' => 0,
'is_trial' => true,
]);
// tenants.status НЕ в $fillable модели Tenant (колонка DEFAULT 'active') —
// выставляем явно, минуя mass-assignment; иначе самозапись активировала бы
// тенанта до подтверждения почты (баг: tenant создавался 'active').
$tenant->status = 'pending_email_confirm';
$tenant->save();
return User::on(self::DB_CONNECTION)->create([
'tenant_id' => $tenant->id,
'email' => $email,
'password_hash' => Hash::make($password),
'first_name' => 'Новый',
'last_name' => 'клиент',
'is_active' => false,
'totp_enabled' => false,
]);
}
/**
* @return array{user:User, record:EmailVerification, plain:string, dev_code:?string}
*/
private function createCodeRecord(User $user): array
{
// Гасим прежние непогашенные коды этого пользователя (делаем неюзабельными).
EmailVerification::on(self::DB_CONNECTION)
->where('user_id', $user->id)
->whereNull('verified_at')
->update(['failed_attempts' => self::MAX_FAILED_ATTEMPTS]);
$plain = (string) random_int(100_000, 999_999);
$record = EmailVerification::on(self::DB_CONNECTION)->create([
'user_id' => $user->id,
'email' => $user->email,
'token' => (string) Str::uuid(),
'code_hash' => Hash::make($plain),
'failed_attempts' => 0,
'expires_at' => now()->addMinutes(self::CODE_TTL_MINUTES),
]);
return [
'user' => $user,
'record' => $record,
'plain' => $plain,
'dev_code' => app()->environment('local', 'testing') ? $plain : null,
];
}
private function sendCode(string $email, string $plain): void
{
Mail::to($email)->send(new EmailVerificationCodeMail($plain, $email));
}
private function generateSubdomain(string $email): string
{
$base = Str::of($email)->before('@')->lower()->replaceMatches('/[^a-z0-9]/', '')->value();
if ($base === '') {
$base = 'client';
}
$base = Str::limit($base, 50, '');
$candidate = $base;
$i = 0;
while (Tenant::on(self::DB_CONNECTION)->where('subdomain', $candidate)->exists()) {
$i++;
$candidate = $base.$i;
}
return Str::limit($candidate, 63, '');
}
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Services\Captcha;
/**
* Шов проверки капчи. SP1 dev-драйвер NullCaptchaVerifier; реальный
* Yandex SmartCaptcha-драйвер подключается позже (SP3/ops) без изменения
* контроллера/сервиса они зовут только этот интерфейс.
*/
interface CaptchaVerifier
{
/** true — капча пройдена; false — отклонить регистрацию. */
public function verify(?string $token, ?string $ip = null): bool;
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Services\Captcha;
/**
* Dev/test-драйвер: пустой токен всегда провал; непустой исход из конфига
* services.captcha.fake_passes (тест переключает на false, чтобы проверить
* ветку отклонения). На prod вместо него встанет Yandex SmartCaptcha-драйвер.
*/
final class NullCaptchaVerifier implements CaptchaVerifier
{
public function verify(?string $token, ?string $ip = null): bool
{
if ($token === null || $token === '') {
return false;
}
return (bool) config('services.captcha.fake_passes', true);
}
}
+3 -2
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Services;
use App\Mail\InvoicePaidNotification;
use App\Mail\NewLeadsDigestMail;
use App\Mail\ReminderDueNotification;
use App\Mail\TopupSuccessNotification;
use App\Mail\ZeroBalancePausedMail;
@@ -107,10 +108,10 @@ class NotificationService
* активному пользователю тенанта с включённым new_lead.email. Заменяет
* пер-лид email-канал события new_lead.
*/
public function notifyNewLeadsDigest(Tenant $tenant, \Illuminate\Support\Collection $deals): void
public function notifyNewLeadsDigest(Tenant $tenant, Collection $deals): void
{
foreach ($this->recipientsForEvent($tenant, self::EVENT_NEW_LEAD, self::CHANNEL_EMAIL) as $user) {
$this->sendEmail($user, self::EVENT_NEW_LEAD, new \App\Mail\NewLeadsDigestMail($user, $tenant, $deals));
$this->sendEmail($user, self::EVENT_NEW_LEAD, new NewLeadsDigestMail($user, $tenant, $deals));
}
}
+7 -5
View File
@@ -1,5 +1,7 @@
<?php
use App\Logging\PiiScrubbingProcessor;
use App\Logging\ScrubPii;
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
@@ -64,7 +66,7 @@ return [
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
// PII-scrubbing: маскирует телефоны/email в записях laravel.log.
'tap' => [\App\Logging\ScrubPii::class],
'tap' => [ScrubPii::class],
],
'daily' => [
@@ -74,7 +76,7 @@ return [
'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
// PII-scrubbing: маскирует телефоны/email в записях laravel.log.
'tap' => [\App\Logging\ScrubPii::class],
'tap' => [ScrubPii::class],
],
'slack' => [
@@ -85,7 +87,7 @@ return [
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
// PII-scrubbing: маскирует телефоны/email в сообщениях в Slack.
'tap' => [\App\Logging\ScrubPii::class],
'tap' => [ScrubPii::class],
],
'papertrail' => [
@@ -98,7 +100,7 @@ return [
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
],
// PII-scrubbing добавлен после PsrLogMessageProcessor.
'processors' => [PsrLogMessageProcessor::class, \App\Logging\PiiScrubbingProcessor::class],
'processors' => [PsrLogMessageProcessor::class, PiiScrubbingProcessor::class],
],
'stderr' => [
@@ -110,7 +112,7 @@ return [
],
'formatter' => env('LOG_STDERR_FORMATTER'),
// PII-scrubbing добавлен после PsrLogMessageProcessor.
'processors' => [PsrLogMessageProcessor::class, \App\Logging\PiiScrubbingProcessor::class],
'processors' => [PsrLogMessageProcessor::class, PiiScrubbingProcessor::class],
],
'syslog' => [
+8
View File
@@ -35,6 +35,14 @@ return [
],
],
// Капча самозаписи (G1/SP1). driver=null → NullCaptchaVerifier (dev/test).
// Реальный Yandex SmartCaptcha подключается позже (SP3/ops).
'captcha' => [
'driver' => env('CAPTCHA_DRIVER', 'null'),
'fake_passes' => filter_var(env('CAPTCHA_FAKE_PASSES', true), FILTER_VALIDATE_BOOL),
'yandex_server_key' => env('YANDEX_SMARTCAPTCHA_SERVER_KEY'),
],
'supplier' => [
'login' => env('SUPPLIER_LOGIN'),
'password' => env('SUPPLIER_PASSWORD'),
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
// DDL через pgsql_supplier (урок Спека B — prod-роли + избегаем deadlock смешивания
// соединений). На dev pgsql_supplier = postgres superuser, на prod — crm_supplier_worker.
$supplier = DB::connection('pgsql_supplier');
$supplier->statement('ALTER TABLE email_verifications ADD COLUMN IF NOT EXISTS code_hash VARCHAR(255)');
$supplier->statement('ALTER TABLE email_verifications ADD COLUMN IF NOT EXISTS failed_attempts SMALLINT NOT NULL DEFAULT 0');
// tenants.status был VARCHAR(20), но CHECK допускает 'pending_email_confirm' (21 симв.) —
// латентный дефект: значение не влезало в колонку. Самозапись (G1/SP1) первой реально
// ставит этот статус, поэтому расширяем до VARCHAR(30). Идемпотентно (повторный ALTER TYPE — no-op).
$supplier->statement('ALTER TABLE tenants ALTER COLUMN status TYPE VARCHAR(30)');
// Самозапись пишет/читает email_verifications через BYPASSRLS-роль (нет tenant-GUC
// на публичном роуте). Гарантируем права на проде (на dev — superuser, no-op).
foreach (['crm_app_user', 'crm_supplier_worker', 'crm_migrator', 'crm_admin_user'] as $role) {
$supplier->statement(<<<SQL
DO \$\$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{$role}') THEN
GRANT SELECT, INSERT, UPDATE ON email_verifications TO {$role};
END IF;
END
\$\$
SQL);
}
}
public function down(): void
{
$supplier = DB::connection('pgsql_supplier');
$supplier->statement('ALTER TABLE email_verifications DROP COLUMN IF EXISTS failed_attempts');
$supplier->statement('ALTER TABLE email_verifications DROP COLUMN IF EXISTS code_hash');
}
};
@@ -0,0 +1,4 @@
<p>Здравствуйте!</p>
<p>Ваш код подтверждения регистрации в Лидерре:</p>
<p style="font-size:24px;font-weight:bold;letter-spacing:3px;">{{ $code }}</p>
<p>Код действует 15 минут. Если вы не регистрировались просто проигнорируйте это письмо.</p>
+2 -1
View File
@@ -1,5 +1,6 @@
<?php
use App\Jobs\SendNewLeadsDigestJob;
use App\Jobs\SnapshotProjectRoutingJob;
use App\Jobs\Supplier\CleanupInactiveSupplierProjectsJob;
use App\Jobs\Supplier\CsvReconcileJob;
@@ -149,7 +150,7 @@ Schedule::job(new CsvReconcileJob)->everyThirtyMinutes()
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\CsvReconcileJob', false, 'Job failed', null));
// G2-A: дайджест новых сделок клиентам — каждые 30 минут.
Schedule::job(new \App\Jobs\SendNewLeadsDigestJob)->everyThirtyMinutes()
Schedule::job(new SendNewLeadsDigestJob)->everyThirtyMinutes()
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\SendNewLeadsDigestJob', true, null, null))
->onFailure(fn () => $hb->recordRunResult('App\Jobs\SendNewLeadsDigestJob', false, 'Job failed', null));
+6 -1
View File
@@ -22,7 +22,12 @@ Route::prefix('/api/auth')->group(function () {
// в контроллерах. Именованные лимитеры — в AppServiceProvider::boot.
Route::post('/login', 'App\Http\Controllers\Api\AuthController@login')
->middleware('throttle:auth-login');
Route::post('/register', 'App\Http\Controllers\Api\AuthController@register');
Route::post('/register', 'App\Http\Controllers\Api\RegistrationController@register')
->middleware('throttle:auth-register');
Route::post('/confirm-email', 'App\Http\Controllers\Api\RegistrationController@confirmEmail')
->middleware('throttle:auth-register');
Route::post('/resend-code', 'App\Http\Controllers\Api\RegistrationController@resendCode')
->middleware('throttle:auth-register');
// /2fa/verify публичный — у user'а ещё нет полноценной session-auth, только
// pending_user_id в session. Verify завершает login после проверки TOTP.
//
@@ -119,57 +119,6 @@ test('POST /api/auth/login обновляет last_login_at у user', function (
expect($user->fresh()->last_login_at)->not->toBeNull();
});
test('POST /api/auth/register создаёт user + возвращает 201', function () {
$response = $this->postJson('/api/auth/register', [
'email' => 'new-signup@example.ru',
'password' => 'fresh-pass-123',
'accept_offer' => true,
'accept_pdn' => true,
]);
$response->assertStatus(201);
$response->assertJsonPath('user.email', 'new-signup@example.ru');
$response->assertJsonPath('requires_2fa', false);
$user = User::where('email', 'new-signup@example.ru')->first();
expect($user)->not->toBeNull();
expect(Hash::check('fresh-pass-123', $user->password_hash))->toBeTrue();
});
test('POST /api/auth/register отвергает существующий email (unique)', function () {
User::factory()->create([
'tenant_id' => $this->tenant->id,
'email' => 'duplicate@example.ru',
]);
$response = $this->postJson('/api/auth/register', [
'email' => 'duplicate@example.ru',
'password' => 'any-password-123',
'accept_offer' => true,
'accept_pdn' => true,
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['email']);
});
test('POST /api/auth/register требует accept_offer=true И accept_pdn=true (ТЗ §1.5/§4.1)', function () {
$base = [
'email' => 'no-consent@example.ru',
'password' => 'fresh-pass-123',
];
// Без оферты.
$this->postJson('/api/auth/register', array_merge($base, ['accept_pdn' => true]))
->assertStatus(422)
->assertJsonValidationErrors(['accept_offer']);
// Без ПДн.
$this->postJson('/api/auth/register', array_merge($base, ['accept_offer' => true]))
->assertStatus(422)
->assertJsonValidationErrors(['accept_pdn']);
});
test('GET /api/auth/me возвращает 401 без авторизации', function () {
$this->getJson('/api/auth/me')->assertStatus(401);
});
+186
View File
@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
// Самозапись пишет tenants/users/email_verifications через BYPASSRLS pgsql_supplier
// (на публичном роуте нет tenant-GUC). SharesSupplierPdo шарит PDO под DatabaseTransactions.
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
beforeEach(function () {
config(['services.captcha.fake_passes' => true]);
});
function registerPayload(array $over = []): array
{
return array_merge([
'email' => 'newclient@example.ru',
'password' => 'fresh-pass-123',
'accept_offer' => true,
'accept_pdn' => true,
'captcha_token' => 'tok-123',
], $over);
}
test('register создаёт pending-тенанта + неактивного владельца + код, без входа', function () {
$r = $this->postJson('/api/auth/register', registerPayload());
$r->assertStatus(201);
$r->assertJsonPath('status', 'pending_email_confirm');
$r->assertJsonPath('email', 'newclient@example.ru');
expect($r->json('_dev_plain_code'))->toMatch('/^\d{6}$/');
$user = User::where('email', 'newclient@example.ru')->first();
expect($user)->not->toBeNull();
expect($user->is_active)->toBeFalse();
$tenant = Tenant::find($user->tenant_id);
expect($tenant->status)->toBe('pending_email_confirm');
expect((float) $tenant->balance_rub)->toBe(0.0);
// Вход НЕ выполнен.
$this->getJson('/api/auth/me')->assertStatus(401);
});
test('register пишет email_verifications через pgsql_supplier (BYPASSRLS)', function () {
$connections = [];
DB::listen(function ($q) use (&$connections) {
if (str_contains($q->sql, 'email_verifications')) {
$connections[] = $q->connectionName;
}
});
$this->postJson('/api/auth/register', registerPayload())->assertStatus(201);
expect($connections)->not->toBeEmpty();
expect(array_values(array_unique($connections)))->toBe(['pgsql_supplier']);
});
test('register отклоняет неверную капчу (422), аккаунт не создаётся', function () {
config(['services.captcha.fake_passes' => false]);
$this->postJson('/api/auth/register', registerPayload())
->assertStatus(422)
->assertJsonValidationErrors(['captcha_token']);
expect(User::where('email', 'newclient@example.ru')->exists())->toBeFalse();
});
test('register требует accept_offer, accept_pdn, captcha_token', function () {
$this->postJson('/api/auth/register', registerPayload(['accept_offer' => false]))
->assertStatus(422)->assertJsonValidationErrors(['accept_offer']);
$this->postJson('/api/auth/register', registerPayload(['accept_pdn' => false]))
->assertStatus(422)->assertJsonValidationErrors(['accept_pdn']);
$payload = registerPayload();
unset($payload['captcha_token']);
$this->postJson('/api/auth/register', $payload)
->assertStatus(422)->assertJsonValidationErrors(['captcha_token']);
});
test('confirm верным кодом активирует тенанта/владельца, баланс 300, выполняет вход', function () {
$reg = $this->postJson('/api/auth/register', registerPayload())->assertStatus(201);
$code = $reg->json('_dev_plain_code');
$r = $this->postJson('/api/auth/confirm-email', [
'email' => 'newclient@example.ru',
'code' => $code,
]);
$r->assertOk();
$r->assertJsonPath('user.email', 'newclient@example.ru');
$r->assertJsonPath('requires_2fa', false);
$user = User::where('email', 'newclient@example.ru')->first();
expect($user->is_active)->toBeTrue();
$tenant = Tenant::find($user->tenant_id);
expect($tenant->status)->toBe('active');
expect((float) $tenant->balance_rub)->toBe(300.0);
});
test('confirm неверным кодом → 422 + failed_attempts++', function () {
$reg = $this->postJson('/api/auth/register', registerPayload())->assertStatus(201);
$real = $reg->json('_dev_plain_code');
$wrong = $real === '000000' ? '111111' : '000000';
$r = $this->postJson('/api/auth/confirm-email', [
'email' => 'newclient@example.ru',
'code' => $wrong,
]);
$r->assertStatus(422);
expect($r->json('attempts_remaining'))->toBe(4);
});
test('5 неверных кодов инвалидируют запись (даже верный код больше не проходит)', function () {
$reg = $this->postJson('/api/auth/register', registerPayload())->assertStatus(201);
$real = $reg->json('_dev_plain_code');
$wrong = $real === '000000' ? '111111' : '000000';
for ($i = 0; $i < 5; $i++) {
$this->postJson('/api/auth/confirm-email', [
'email' => 'newclient@example.ru',
'code' => $wrong,
])->assertStatus(422);
}
$this->postJson('/api/auth/confirm-email', [
'email' => 'newclient@example.ru',
'code' => $real,
])->assertStatus(422)->assertJsonPath('reason', 'too_many_attempts');
});
test('протухший код отклоняется, resend выдаёт рабочий', function () {
$reg = $this->postJson('/api/auth/register', registerPayload())->assertStatus(201);
$user = User::where('email', 'newclient@example.ru')->first();
DB::table('email_verifications')->where('user_id', $user->id)
->update(['expires_at' => now()->subMinutes(5)]);
$this->postJson('/api/auth/confirm-email', [
'email' => 'newclient@example.ru',
'code' => $reg->json('_dev_plain_code'),
])->assertStatus(422)->assertJsonPath('reason', 'expired');
$resend = $this->postJson('/api/auth/resend-code', ['email' => 'newclient@example.ru'])->assertOk();
$newCode = $resend->json('_dev_plain_code');
expect($newCode)->toMatch('/^\d{6}$/');
$this->postJson('/api/auth/confirm-email', [
'email' => 'newclient@example.ru',
'code' => $newCode,
])->assertOk();
});
test('повторный register на неподтверждённый email не создаёт второго тенанта', function () {
$this->postJson('/api/auth/register', registerPayload())->assertStatus(201);
$this->postJson('/api/auth/register', registerPayload())->assertStatus(201);
expect(User::where('email', 'newclient@example.ru')->count())->toBe(1);
expect(Tenant::where('contact_email', 'newclient@example.ru')->count())->toBe(1);
});
test('register на активный email → 422', function () {
$tenant = Tenant::factory()->create();
User::factory()->create([
'tenant_id' => $tenant->id,
'email' => 'active@example.ru',
'is_active' => true,
]);
$this->postJson('/api/auth/register', registerPayload(['email' => 'active@example.ru']))
->assertStatus(422)->assertJsonValidationErrors(['email']);
});
test('resend на несуществующий email → унифицированный ответ (anti-enumeration)', function () {
$this->postJson('/api/auth/resend-code', ['email' => 'nobody@example.ru'])
->assertOk()
->assertJsonMissingPath('_dev_plain_code');
});
+10 -8
View File
@@ -3,11 +3,13 @@
declare(strict_types=1);
use App\Models\Deal;
use App\Models\LeadCharge;
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use Carbon\Carbon;
use Carbon\CarbonImmutable;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
uses(DatabaseTransactions::class);
@@ -141,10 +143,10 @@ it('runway считается от рублёвого баланса (едины
// 14250₽, tier1 500₽, delivered=0 → affordable = floor(1425000/50000) = 28 лидов.
// 30 списаний за 30 дней → avg 1 лид/день → runway = floor(28/1) = 28.
// Те же числа, что в BillingOverviewControllerTest — доказывает единый источник.
$this->seed(\Database\Seeders\PricingTierSeeder::class);
$this->seed(PricingTierSeeder::class);
$tenant = Tenant::factory()->create(['balance_rub' => '14250.00', 'balance_leads' => 285]);
actingForTenant($tenant);
\App\Models\LeadCharge::factory()->count(30)->create([
LeadCharge::factory()->count(30)->create([
'tenant_id' => $tenant->id,
'charged_at' => now()->subDays(rand(1, 30)),
]);
@@ -161,31 +163,31 @@ it('avg_lead_cost_rub = среднее rub-списаний окна, без pre
// 500 + 700 + 600 = 1800 / 3 = 600 ₽. prepaid (0 ₽) и списание вне окна — не считаются.
$tenant = Tenant::factory()->create();
actingForTenant($tenant);
\App\Models\LeadCharge::factory()->create([
LeadCharge::factory()->create([
'tenant_id' => $tenant->id,
'charge_source' => 'rub',
'price_per_lead_kopecks' => 50000,
'charged_at' => now()->subDays(1),
]);
\App\Models\LeadCharge::factory()->create([
LeadCharge::factory()->create([
'tenant_id' => $tenant->id,
'charge_source' => 'rub',
'price_per_lead_kopecks' => 70000,
'charged_at' => now()->subDays(2),
]);
\App\Models\LeadCharge::factory()->create([
LeadCharge::factory()->create([
'tenant_id' => $tenant->id,
'charge_source' => 'rub',
'price_per_lead_kopecks' => 60000,
'charged_at' => now()->subDays(3),
]);
// prepaid (цена 0) — не участвует в средней
\App\Models\LeadCharge::factory()->prepaid()->create([
LeadCharge::factory()->prepaid()->create([
'tenant_id' => $tenant->id,
'charged_at' => now()->subDays(1),
]);
// rub-списание вне окна 7d (8 дней назад) — не участвует
\App\Models\LeadCharge::factory()->create([
LeadCharge::factory()->create([
'tenant_id' => $tenant->id,
'charge_source' => 'rub',
'price_per_lead_kopecks' => 100000,
@@ -201,7 +203,7 @@ it('avg_lead_cost_rub = null если нет rub-списаний в окне',
$tenant = Tenant::factory()->create();
actingForTenant($tenant);
// только prepaid — rub-списаний нет → среднего нет
\App\Models\LeadCharge::factory()->prepaid()->create([
LeadCharge::factory()->prepaid()->create([
'tenant_id' => $tenant->id,
'charged_at' => now()->subDays(1),
]);
+3 -2
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
use App\Models\ActivityLog;
use App\Models\Deal;
use App\Models\LeadCharge;
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
@@ -228,7 +229,7 @@ test('GET /api/deals/{id} отдаёт cost_kopecks из lead_charges для rub
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create();
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
\App\Models\LeadCharge::create([
LeadCharge::create([
'tenant_id' => $this->tenant->id,
'deal_id' => $deal->id,
'deal_received_at' => $deal->received_at,
@@ -258,7 +259,7 @@ test('GET /api/deals/{id} cost_kopecks = null для prepaid-списания',
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create();
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
\App\Models\LeadCharge::create([
LeadCharge::create([
'tenant_id' => $this->tenant->id,
'deal_id' => $deal->id,
'deal_received_at' => $deal->received_at,
@@ -6,9 +6,9 @@ use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Services\DaData\DaDataPhoneClient;
use App\Services\LeadRegionResolver;
use Tests\Support\Imitation\FakeDaDataPhoneClient;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\Concerns\SharesSupplierPdo;
use Tests\Support\Imitation\FakeDaDataPhoneClient;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
@@ -19,11 +19,10 @@ uses(SharesSupplierPdo::class);
*
* Task 1: Подставной DaData-клиент (group imitation).
*/
it('resolves region via dadata stub qc=0 for Москва', function (): void {
config(['services.dadata.enabled' => true]);
$fake = new FakeDaDataPhoneClient();
$fake = new FakeDaDataPhoneClient;
$fake->stub('79990000077', qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fake);
@@ -31,8 +30,8 @@ it('resolves region via dadata stub qc=0 for Москва', function (): void {
$sp = SupplierProject::factory()->create();
$lead = SupplierLead::factory()->create([
'supplier_project_id' => $sp->id,
'phone' => '79990000077',
'raw_payload' => ['tag' => ''],
'phone' => '79990000077',
'raw_payload' => ['tag' => ''],
]);
$res = app(LeadRegionResolver::class)->resolve($lead);
@@ -42,7 +41,7 @@ it('resolves region via dadata stub qc=0 for Москва', function (): void {
})->group('imitation');
it('fake dadata phone client stub method returns self for fluent api', function (): void {
$fake = new FakeDaDataPhoneClient();
$fake = new FakeDaDataPhoneClient;
$result = $fake->stub('79990000001', qc: 0, region: 'Москва', provider: null);
expect($result)->toBeInstanceOf(FakeDaDataPhoneClient::class);
})->group('imitation');
@@ -50,7 +49,7 @@ it('fake dadata phone client stub method returns self for fluent api', function
it('falls through to tag-fallback on qc=2 stub (empty tag → unknown)', function (): void {
config(['services.dadata.enabled' => true]);
$fake = new FakeDaDataPhoneClient();
$fake = new FakeDaDataPhoneClient;
// qc=2 → мусор/иностранец → резолвер сразу уходит на tag-fallback (Россвязь не зовётся).
$fake->stub('79990000077', qc: 2, region: null, provider: null);
@@ -59,8 +58,8 @@ it('falls through to tag-fallback on qc=2 stub (empty tag → unknown)', functio
$sp = SupplierProject::factory()->create();
$lead = SupplierLead::factory()->create([
'supplier_project_id' => $sp->id,
'phone' => '79990000077',
'raw_payload' => ['tag' => ''],
'phone' => '79990000077',
'raw_payload' => ['tag' => ''],
]);
$res = app(LeadRegionResolver::class)->resolve($lead);
@@ -4,8 +4,8 @@ declare(strict_types=1);
use App\Services\DaData\DaDataException;
use App\Services\DaData\DaDataPhoneClient;
use Tests\Support\Imitation\FakeDaDataPhoneClient;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\Support\Imitation\FakeDaDataPhoneClient;
uses(DatabaseTransactions::class);
@@ -14,24 +14,23 @@ uses(DatabaseTransactions::class);
* Integration tests are in FakeDaDataClientTest.php.
* Task 1 Phase 1 Portal Client Imitation Harness.
*/
it('fake phone client is subtype of DaDataPhoneClient', function (): void {
expect(new FakeDaDataPhoneClient())->toBeInstanceOf(DaDataPhoneClient::class);
expect(new FakeDaDataPhoneClient)->toBeInstanceOf(DaDataPhoneClient::class);
})->group('imitation');
it('stub method returns self for fluent chaining', function (): void {
$fake = new FakeDaDataPhoneClient();
$fake = new FakeDaDataPhoneClient;
$result = $fake->stub('79990000001', qc: 0, region: 'Москва', provider: 'МТС');
expect($result)->toBeInstanceOf(FakeDaDataPhoneClient::class);
})->group('imitation');
it('cleanPhone throws DaDataException when no stub registered', function (): void {
$fake = new FakeDaDataPhoneClient();
$fake = new FakeDaDataPhoneClient;
expect(fn () => $fake->cleanPhone('79990000099'))->toThrow(DaDataException::class);
})->group('imitation');
it('stubThrows registers exception for cleanPhone', function (): void {
$fake = new FakeDaDataPhoneClient();
$fake = new FakeDaDataPhoneClient;
$fake->stubThrows('79990000002');
expect(fn () => $fake->cleanPhone('79990000002'))->toThrow(DaDataException::class);
})->group('imitation');
@@ -12,7 +12,7 @@ use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
beforeEach(function (): void {
(new PricingTierSeeder())->run();
(new PricingTierSeeder)->run();
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
});
@@ -22,7 +22,7 @@ beforeEach(function (): void {
$this->seed(PricingTierSeeder::class);
// DaData stub: phone '79991112233' resolves to Moscow (code 82)
$fake = new FakeDaDataPhoneClient();
$fake = new FakeDaDataPhoneClient;
$fake->stub('79991112233', qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fake);
@@ -61,7 +61,7 @@ it('site() creates SupplierLead, dispatches job, returns processed lead with a d
createRoutingSnapshotFromProject($project, null, 'site', 'vashinvestor.ru');
// Act: inject a synthetic site lead (dispatchSync = synchronous)
$lead = (new LeadInjector())->site(
$lead = (new LeadInjector)->site(
domain: 'vashinvestor.ru',
phone: '79991112233',
tag: 'Москва',
@@ -32,12 +32,13 @@ declare(strict_types=1);
* Same reason. FINDING (F3).
*/
use App\Jobs\RouteSupplierLeadJob;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Services\DaData\DaDataPhoneClient;
use App\Services\Jobs\RouteSupplierLeadJob as RouteSupplierLeadJobAlias;
use App\Services\LeadRegionResolver;
use App\Support\RussianRegions;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
@@ -53,34 +54,34 @@ uses(DatabaseTransactions::class, SharesSupplierPdo::class);
* Insert a phone_ranges row correctly, creating a phone_ranges_imports record first.
* ImitationTestCase::seedPhoneRange() uses wrong column names (FINDING F1).
*
* @param int $defCode e.g. 999
* @param int $from e.g. 5550000
* @param int $to e.g. 5559999
* @param int $defCode e.g. 999
* @param int $from e.g. 5550000
* @param int $to e.g. 5559999
* @param int $subjectCode ordinal 1..89
*/
function insertPhoneRange(int $defCode, int $from, int $to, int $subjectCode): void
{
// Ensure a phone_ranges_imports anchor row exists.
$importId = DB::table('phone_ranges_imports')->insertGetId([
'imported_at' => now(),
'source_url' => 'test://rossvyaz',
'rows_inserted' => 1,
'rows_updated' => 0,
'imported_at' => now(),
'source_url' => 'test://rossvyaz',
'rows_inserted' => 1,
'rows_updated' => 0,
'checksum_sha256' => hash('sha256', "test-{$defCode}-{$from}-{$to}-{$subjectCode}"),
'status' => 'completed',
'completed_at' => now(),
'status' => 'completed',
'completed_at' => now(),
]);
DB::table('phone_ranges')->insert([
'def_code' => $defCode,
'from_num' => $from,
'to_num' => $to,
'operator' => 'test-operator',
'region' => RussianRegions::CODE_TO_NAME[$subjectCode] ?? 'test-region',
'region_normalized'=> null,
'subject_code' => $subjectCode,
'imported_at' => now(),
'import_id' => $importId,
'def_code' => $defCode,
'from_num' => $from,
'to_num' => $to,
'operator' => 'test-operator',
'region' => RussianRegions::CODE_TO_NAME[$subjectCode] ?? 'test-region',
'region_normalized' => null,
'subject_code' => $subjectCode,
'imported_at' => now(),
'import_id' => $importId,
]);
}
@@ -93,8 +94,8 @@ function makeLeadWithPhone(string $phone, string $tag = ''): SupplierLead
return SupplierLead::factory()->create([
'supplier_project_id' => $sp->id,
'phone' => $phone,
'raw_payload' => ['tag' => $tag],
'phone' => $phone,
'raw_payload' => ['tag' => $tag],
]);
}
@@ -118,7 +119,7 @@ it('flag services.dadata.enabled=false falls through to tag-fallback (empty tag
config(['services.dadata.enabled' => false]);
$lead = makeLeadWithPhone('79990000001', tag: '');
$res = app(LeadRegionResolver::class)->resolve($lead);
$res = app(LeadRegionResolver::class)->resolve($lead);
expect($res->source)->toBe('unknown')
->and($res->subjectCode)->toBeNull()
@@ -130,7 +131,7 @@ it('flag services.dadata.enabled=false with a valid tag resolves to source=tag',
config(['services.dadata.enabled' => false]);
$lead = makeLeadWithPhone('79990000002', tag: 'Москва');
$res = app(LeadRegionResolver::class)->resolve($lead);
$res = app(LeadRegionResolver::class)->resolve($lead);
expect($res->source)->toBe('tag')
->and($res->subjectCode)->toBe(RussianRegions::nameToCode()['Москва']);
@@ -146,11 +147,11 @@ it('qc=0 + region Москва (unambiguous, maps to subject_code 82) → source
config(['services.dadata.enabled' => true]);
$fake = (new FakeDaDataPhoneClient())->stub('79990000010', qc: 0, region: 'Москва', provider: 'МТС');
$fake = (new FakeDaDataPhoneClient)->stub('79990000010', qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fake);
$lead = makeLeadWithPhone('79990000010');
$res = app(LeadRegionResolver::class)->resolve($lead);
$res = app(LeadRegionResolver::class)->resolve($lead);
expect($res->source)->toBe('dadata')
->and($res->subjectCode)->toBe($moscowCode)
@@ -168,11 +169,11 @@ it('qc=0 + ambiguous region (Санкт-Петербург и область) fa
$spbCode = RussianRegions::nameToCode()['Санкт-Петербург'];
insertPhoneRange(defCode: 999, from: 6660000, to: 6669999, subjectCode: $spbCode);
$fake = (new FakeDaDataPhoneClient())->stub('79996660020', qc: 0, region: 'Санкт-Петербург и область', provider: 'МегаФон');
$fake = (new FakeDaDataPhoneClient)->stub('79996660020', qc: 0, region: 'Санкт-Петербург и область', provider: 'МегаФон');
app()->instance(DaDataPhoneClient::class, $fake);
$lead = makeLeadWithPhone('79996660020');
$res = app(LeadRegionResolver::class)->resolve($lead);
$res = app(LeadRegionResolver::class)->resolve($lead);
expect($res->source)->toBe('rossvyaz')
->and($res->rossvyazMatched)->toBeTrue()
@@ -191,11 +192,11 @@ it('qc=1 + phone inside seeded phone_ranges range → source=rossvyaz, rossvyazM
insertPhoneRange(defCode: 999, from: 5550000, to: 5559999, subjectCode: $tyumenCode);
$fake = (new FakeDaDataPhoneClient())->stub('79995550011', qc: 1);
$fake = (new FakeDaDataPhoneClient)->stub('79995550011', qc: 1);
app()->instance(DaDataPhoneClient::class, $fake);
$lead = makeLeadWithPhone('79995550011');
$res = app(LeadRegionResolver::class)->resolve($lead);
$res = app(LeadRegionResolver::class)->resolve($lead);
expect($res->source)->toBe('rossvyaz')
->and($res->rossvyazMatched)->toBeTrue()
@@ -212,11 +213,11 @@ it('qc=2 with empty tag → source=unknown immediately (no rossvyaz)', function
// The key invariant IS correct: Россвязь is NOT called for qc=2 (mусор).
config(['services.dadata.enabled' => true]);
$fake = (new FakeDaDataPhoneClient())->stub('79990000020', qc: 2);
$fake = (new FakeDaDataPhoneClient)->stub('79990000020', qc: 2);
app()->instance(DaDataPhoneClient::class, $fake);
$lead = makeLeadWithPhone('79990000020', tag: '');
$res = app(LeadRegionResolver::class)->resolve($lead);
$res = app(LeadRegionResolver::class)->resolve($lead);
// qc=2 → tagFallback → empty tag → source='unknown' (NOT 'tag')
expect($res->source)->toBe('unknown')
@@ -228,11 +229,11 @@ it('qc=2 with valid tag → source=tag (Россвязь skipped)', function ():
// When qc=2 but tag resolves to a region, source='tag' (still no Россвязь).
config(['services.dadata.enabled' => true]);
$fake = (new FakeDaDataPhoneClient())->stub('79990000021', qc: 2);
$fake = (new FakeDaDataPhoneClient)->stub('79990000021', qc: 2);
app()->instance(DaDataPhoneClient::class, $fake);
$lead = makeLeadWithPhone('79990000021', tag: 'Москва');
$res = app(LeadRegionResolver::class)->resolve($lead);
$res = app(LeadRegionResolver::class)->resolve($lead);
expect($res->source)->toBe('tag')
->and($res->subjectCode)->toBe(RussianRegions::nameToCode()['Москва'])
@@ -250,11 +251,11 @@ it('DaDataException (degradation) falls through to rossvyaz when range seeded',
$voronezCode = RussianRegions::nameToCode()['Воронежская область']; // code 42
insertPhoneRange(defCode: 999, from: 7770000, to: 7779999, subjectCode: $voronezCode);
$fake = (new FakeDaDataPhoneClient())->stubThrows('79997770030');
$fake = (new FakeDaDataPhoneClient)->stubThrows('79997770030');
app()->instance(DaDataPhoneClient::class, $fake);
$lead = makeLeadWithPhone('79997770030');
$res = app(LeadRegionResolver::class)->resolve($lead);
$res = app(LeadRegionResolver::class)->resolve($lead);
expect($res->source)->toBe('rossvyaz')
->and($res->rossvyazMatched)->toBeTrue()
@@ -265,11 +266,11 @@ it('DaDataException with no rossvyaz range falls through to tag-fallback', funct
// No phone_ranges seeded → rossvyaz returns null → tagFallback.
config(['services.dadata.enabled' => true]);
$fake = (new FakeDaDataPhoneClient())->stubThrows('79998880040');
$fake = (new FakeDaDataPhoneClient)->stubThrows('79998880040');
app()->instance(DaDataPhoneClient::class, $fake);
$lead = makeLeadWithPhone('79998880040', tag: '');
$res = app(LeadRegionResolver::class)->resolve($lead);
$res = app(LeadRegionResolver::class)->resolve($lead);
expect($res->source)->toBe('unknown')
->and($res->subjectCode)->toBeNull()
@@ -284,14 +285,14 @@ it('cache hit: resolving the same phone twice returns cacheHit=true on second ca
config(['services.dadata.enabled' => true]);
$phone = '79990000050';
$fake = (new FakeDaDataPhoneClient())->stub($phone, qc: 0, region: 'Москва', provider: 'МТС');
$fake = (new FakeDaDataPhoneClient)->stub($phone, qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fake);
$sp = SupplierProject::factory()->create();
$sp = SupplierProject::factory()->create();
$lead = SupplierLead::factory()->create([
'supplier_project_id' => $sp->id,
'phone' => $phone,
'raw_payload' => ['tag' => ''],
'phone' => $phone,
'raw_payload' => ['tag' => ''],
]);
// First resolution — populates cache; cacheHit must be false.
@@ -302,8 +303,8 @@ it('cache hit: resolving the same phone twice returns cacheHit=true on second ca
// Second lead with the SAME phone (different row, same cache key).
$lead2 = SupplierLead::factory()->create([
'supplier_project_id' => $sp->id,
'phone' => $phone,
'raw_payload' => ['tag' => ''],
'phone' => $phone,
'raw_payload' => ['tag' => ''],
]);
// Second resolution — must come from cache; DaData NOT called again.
@@ -333,36 +334,36 @@ it('RouteSupplierLeadJob persists resolved_subject_code/region_source/dadata_qc/
// Seed pricing tiers so LedgerService doesn't crash on boot.
try {
$seeder = new \Database\Seeders\PricingTierSeeder();
$seeder = new PricingTierSeeder;
$seeder->run();
} catch (\Throwable) {
} catch (Throwable) {
// Already seeded or not required for this path.
}
$phone = '79990000060';
$moscowCode = RussianRegions::nameToCode()['Москва'];
$fake = (new FakeDaDataPhoneClient())->stub($phone, qc: 0, region: 'Москва', provider: 'МТС');
$fake = (new FakeDaDataPhoneClient)->stub($phone, qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fake);
$sp = SupplierProject::factory()->create();
$sp = SupplierProject::factory()->create();
$lead = SupplierLead::factory()->create([
'supplier_project_id' => $sp->id,
'phone' => $phone,
'raw_payload' => [
'tag' => '',
'phone' => $phone,
'raw_payload' => [
'tag' => '',
'project' => 'B1_test.example.com',
'time' => now()->getTimestamp(),
'vid' => 123456789,
'time' => now()->getTimestamp(),
'vid' => 123456789,
],
'vid' => 123456789,
'vid' => 123456789,
'processed_at' => null,
]);
// Dispatch the job synchronously. It will run the full handle() path.
// LeadRouter::matchEligibleProjects() will return empty (no snapshot seeded) → 0 deals created.
// The resolver + persistence UPDATE still executes before the routing loop.
\App\Jobs\RouteSupplierLeadJob::dispatchSync($lead->id);
RouteSupplierLeadJob::dispatchSync($lead->id);
$lead->refresh();
@@ -106,9 +106,9 @@ it('splits leads weighted by remaining limit, small clients are not cut off', fu
// One shared supplier project (B1 site signal).
$supplier = SupplierProject::factory()->create([
'platform' => SUPPLIER_PLATFORM,
'platform' => SUPPLIER_PLATFORM,
'signal_type' => 'site',
'unique_key' => SUPPLIER_DOMAIN,
'unique_key' => SUPPLIER_DOMAIN,
]);
// Create 5 tenants + projects, one per limit tier.
@@ -118,24 +118,24 @@ it('splits leads weighted by remaining limit, small clients are not cut off', fu
foreach ($limits as $idx => $limit) {
$tenant = Tenant::factory()->create([
'balance_rub' => '99999.00', // ample balance — billing must not block
'balance_rub' => '99999.00', // ample balance — billing must not block
'frozen_by_balance_at' => null,
]);
$tenants[] = $tenant;
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => SUPPLIER_DOMAIN,
'daily_limit_target' => $limit,
'tenant_id' => $tenant->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => SUPPLIER_DOMAIN,
'daily_limit_target' => $limit,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127, // all days
'preflight_blocked_at' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127, // all days
'preflight_blocked_at' => null,
// PostgresIntArray cast: pass PHP array, cast serialises to '{82}'.
'regions' => [MOSCOW_SUBJECT_CODE],
'regions' => [MOSCOW_SUBJECT_CODE],
]);
// Link project to supplier.
@@ -156,32 +156,32 @@ it('splits leads weighted by remaining limit, small clients are not cut off', fu
signalType: 'site',
signalIdentifier: SUPPLIER_DOMAIN,
dailyLimit: $limits[$idx],
regions: '{' . MOSCOW_SUBJECT_CODE . '}',
regions: '{'.MOSCOW_SUBJECT_CODE.'}',
);
}
// Build a FakeDaDataPhoneClient that maps all test phones to Москва (qc=0).
// We generate deterministic phone numbers: 7(916)000XXXX where XXXX = index 0001..0300.
$fakeDaData = new FakeDaDataPhoneClient();
$fakeDaData = new FakeDaDataPhoneClient;
for ($i = 1; $i <= LEAD_COUNT; $i++) {
$phone = '7916' . str_pad((string) $i, 7, '0', STR_PAD_LEFT);
$phone = '7916'.str_pad((string) $i, 7, '0', STR_PAD_LEFT);
$fakeDaData->stub($phone, qc: 0, region: 'Москва', provider: 'МТС');
}
app()->instance(DaDataPhoneClient::class, $fakeDaData);
// ── ACT ─────────────────────────────────────────────────────────────────────
$injector = new LeadInjector();
$vidBase = 9_000_000_000; // high range, outside real supplier VIDs
$injector = new LeadInjector;
$vidBase = 9_000_000_000; // high range, outside real supplier VIDs
for ($i = 1; $i <= LEAD_COUNT; $i++) {
$phone = '7916' . str_pad((string) $i, 7, '0', STR_PAD_LEFT);
$phone = '7916'.str_pad((string) $i, 7, '0', STR_PAD_LEFT);
$injector->site(
domain: SUPPLIER_DOMAIN,
phone: $phone,
tag: 'Москва',
domain: SUPPLIER_DOMAIN,
phone: $phone,
tag: 'Москва',
platform: SUPPLIER_PLATFORM,
vid: $vidBase + $i,
vid: $vidBase + $i,
);
}
@@ -202,12 +202,12 @@ it('splits leads weighted by remaining limit, small clients are not cut off', fu
// ── REPORT ──────────────────────────────────────────────────────────────────
// Print observed distribution for the required report.
fwrite(STDOUT, PHP_EOL . '=== SCENARIO A DISTRIBUTION REPORT ===' . PHP_EOL);
fwrite(STDOUT, PHP_EOL.'=== SCENARIO A DISTRIBUTION REPORT ==='.PHP_EOL);
fwrite(STDOUT, sprintf('Total leads injected: %d%s', LEAD_COUNT, PHP_EOL));
fwrite(STDOUT, sprintf('Total deals created: %d (≤ %d × cap=3 = %d)%s',
$totalDeals, LEAD_COUNT, LEAD_COUNT * 3, PHP_EOL));
fwrite(STDOUT, PHP_EOL . sprintf('%-10s %-12s %-10s %-10s%s', 'Project', 'Limit', 'Deals', 'Share%', PHP_EOL));
fwrite(STDOUT, str_repeat('-', 45) . PHP_EOL);
fwrite(STDOUT, PHP_EOL.sprintf('%-10s %-12s %-10s %-10s%s', 'Project', 'Limit', 'Deals', 'Share%', PHP_EOL));
fwrite(STDOUT, str_repeat('-', 45).PHP_EOL);
foreach ($projects as $idx => $project) {
$deals = $dealCounts[$idx];
$share = $totalDeals > 0 ? round($deals / $totalDeals * 100, 1) : 0.0;
@@ -220,7 +220,7 @@ it('splits leads weighted by remaining limit, small clients are not cut off', fu
PHP_EOL
));
}
fwrite(STDOUT, '=== END DISTRIBUTION ===' . PHP_EOL . PHP_EOL);
fwrite(STDOUT, '=== END DISTRIBUTION ==='.PHP_EOL.PHP_EOL);
// ── ASSERTIONS ───────────────────────────────────────────────────────────────
@@ -228,12 +228,12 @@ it('splits leads weighted by remaining limit, small clients are not cut off', fu
// After the two limit-3 projects hit their cap, P0 competes with two limit-30
// projects; but even before that, its weight (300) vastly outweighs theirs.
$bigProjectDeals = $dealCounts[0]; // limit=300
$maxSmallDeals = max($dealCounts[1], $dealCounts[2]); // limit=30 × 2
$maxSmallDeals = max($dealCounts[1], $dealCounts[2]); // limit=30 × 2
expect($bigProjectDeals)->toBeGreaterThan($maxSmallDeals,
"FINDING: big-limit project (limit=300) should receive more deals than any limit-30 project. " .
"Got: P0={$dealCounts[0]}, P1={$dealCounts[1]}, P2={$dealCounts[2]}. " .
"This would indicate the weighted lottery is not proportional."
'FINDING: big-limit project (limit=300) should receive more deals than any limit-30 project. '.
"Got: P0={$dealCounts[0]}, P1={$dealCounts[1]}, P2={$dealCounts[2]}. ".
'This would indicate the weighted lottery is not proportional.'
);
// (b) Both smallest projects (limit=3) received > 0 deals.
@@ -241,13 +241,13 @@ it('splits leads weighted by remaining limit, small clients are not cut off', fu
// They CAN only receive at most 3 deals each (their limit), so they'll
// hit their limit early and then drop out of eligibility.
expect($dealCounts[3])->toBeGreaterThan(0,
"FINDING: small-limit project P3 (limit=3) received 0 deals. " .
"The spec guarantees weight≥1 so small clients are NOT cut off. " .
'FINDING: small-limit project P3 (limit=3) received 0 deals. '.
'The spec guarantees weight≥1 so small clients are NOT cut off. '.
"Actual: P3={$dealCounts[3]}. This is a bug."
);
expect($dealCounts[4])->toBeGreaterThan(0,
"FINDING: small-limit project P4 (limit=3) received 0 deals. " .
"The spec guarantees weight≥1 so small clients are NOT cut off. " .
'FINDING: small-limit project P4 (limit=3) received 0 deals. '.
'The spec guarantees weight≥1 so small clients are NOT cut off. '.
"Actual: P4={$dealCounts[4]}. This is a bug."
);
@@ -262,21 +262,21 @@ it('splits leads weighted by remaining limit, small clients are not cut off', fu
$p0Share = $totalDeals > 0 ? $bigProjectDeals / $totalDeals : 0.0;
expect($p0Share)->toBeGreaterThan(0.45,
"FINDING: big-limit project P0 (limit=300) has a share of " .
round($p0Share * 100, 1) . "% which is below 45%. " .
"Expected substantially more than limit-30 projects given weights 300 vs 30. " .
"Limits: " . json_encode($limits) . ", Deals: " . json_encode($dealCounts)
'FINDING: big-limit project P0 (limit=300) has a share of '.
round($p0Share * 100, 1).'% which is below 45%. '.
'Expected substantially more than limit-30 projects given weights 300 vs 30. '.
'Limits: '.json_encode($limits).', Deals: '.json_encode($dealCounts)
);
// (d) Small projects each received exactly their limit (3) or fewer.
// They drop out once delivered_today == daily_limit, so max is 3.
expect($dealCounts[3])->toBeLessThanOrEqual(3,
"FINDING: P3 (limit=3) received {$dealCounts[3]} deals which exceeds its daily limit. " .
"The eligibility guard (delivered_today < daily_limit) should prevent this."
"FINDING: P3 (limit=3) received {$dealCounts[3]} deals which exceeds its daily limit. ".
'The eligibility guard (delivered_today < daily_limit) should prevent this.'
);
expect($dealCounts[4])->toBeLessThanOrEqual(3,
"FINDING: P4 (limit=3) received {$dealCounts[4]} deals which exceeds its daily limit. " .
"The eligibility guard (delivered_today < daily_limit) should prevent this."
"FINDING: P4 (limit=3) received {$dealCounts[4]} deals which exceeds its daily limit. ".
'The eligibility guard (delivered_today < daily_limit) should prevent this.'
);
})->group('imitation');
@@ -116,84 +116,84 @@ it('B1: lead matching client Xs exact region goes to X at step 1, Y fills at ste
// ── ARRANGE ─────────────────────────────────────────────────────────────────
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => BC_B_DOMAIN,
'unique_key' => BC_B_DOMAIN,
]);
// Client X: only Москва (exact match for subject=82).
$tenantX = Tenant::factory()->create([
'balance_rub' => '99999.00',
'balance_rub' => '99999.00',
'frozen_by_balance_at' => null,
]);
$projectX = Project::factory()->create([
'tenant_id' => $tenantX->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => BC_B_DOMAIN,
'daily_limit_target' => 10,
'tenant_id' => $tenantX->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => BC_B_DOMAIN,
'daily_limit_target' => 10,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [BC_MOSCOW],
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [BC_MOSCOW],
]);
linkProjectToSupplier($projectX, $supplier);
// Client Y: all-RF (regions='{}').
$tenantY = Tenant::factory()->create([
'balance_rub' => '99999.00',
'balance_rub' => '99999.00',
'frozen_by_balance_at' => null,
]);
$projectY = Project::factory()->create([
'tenant_id' => $tenantY->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => BC_B_DOMAIN,
'daily_limit_target' => 10,
'tenant_id' => $tenantY->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => BC_B_DOMAIN,
'daily_limit_target' => 10,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [], // all-RF
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [], // all-RF
]);
linkProjectToSupplier($projectY, $supplier);
$activeDate = SnapshotForge::activeDate();
createRoutingSnapshotFromProject(
project: $projectX,
date: $activeDate,
signalType: 'site',
signalIdentifier: BC_B_DOMAIN,
dailyLimit: 10,
regions: '{' . BC_MOSCOW . '}',
project: $projectX,
date: $activeDate,
signalType: 'site',
signalIdentifier: BC_B_DOMAIN,
dailyLimit: 10,
regions: '{'.BC_MOSCOW.'}',
);
createRoutingSnapshotFromProject(
project: $projectY,
date: $activeDate,
signalType: 'site',
signalIdentifier: BC_B_DOMAIN,
dailyLimit: 10,
regions: '{}',
project: $projectY,
date: $activeDate,
signalType: 'site',
signalIdentifier: BC_B_DOMAIN,
dailyLimit: 10,
regions: '{}',
);
// Lead resolves to Москва via FakeDaData (qc=0 → source=dadata, subject=82).
$fakeDaData = new FakeDaDataPhoneClient();
$fakeDaData = new FakeDaDataPhoneClient;
$fakeDaData->stub('79161000001', qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fakeDaData);
// ── ACT ─────────────────────────────────────────────────────────────────────
$injector = new LeadInjector();
$injector = new LeadInjector;
$lead = $injector->site(
domain: BC_B_DOMAIN,
phone: '79161000001',
tag: 'Москва',
domain: BC_B_DOMAIN,
phone: '79161000001',
tag: 'Москва',
platform: 'B1',
vid: 8_100_000_001,
vid: 8_100_000_001,
);
// ── ASSERT ──────────────────────────────────────────────────────────────────
@@ -210,13 +210,13 @@ it('B1: lead matching client Xs exact region goes to X at step 1, Y fills at ste
->where('tenant_id', $tenantY->id)
->count();
fwrite(STDOUT, PHP_EOL . '=== B1 DISTRIBUTION ===' . PHP_EOL);
fwrite(STDOUT, "Client X (Москва exact) deals: {$dealsX}" . PHP_EOL);
fwrite(STDOUT, "Client Y (all-RF) deals: {$dealsY}" . PHP_EOL);
fwrite(STDOUT, PHP_EOL.'=== B1 DISTRIBUTION ==='.PHP_EOL);
fwrite(STDOUT, "Client X (Москва exact) deals: {$dealsX}".PHP_EOL);
fwrite(STDOUT, "Client Y (all-RF) deals: {$dealsY}".PHP_EOL);
// Primary assertion: X gets the lead (routing_step=1 path exists).
expect($dealsX)->toBe(1,
"FINDING: Client X (regions=[82]) should receive the Москва lead at step 1. " .
'FINDING: Client X (regions=[82]) should receive the Москва lead at step 1. '.
"Got dealsX={$dealsX}. The exact-match phase (step 1) may not be working."
);
@@ -226,24 +226,24 @@ it('B1: lead matching client Xs exact region goes to X at step 1, Y fills at ste
->where('supplier_lead_id', $lead->id)
->first();
fwrite(STDOUT, "resolution_log routing_step: " . ($logRow?->routing_step ?? 'NULL') . PHP_EOL);
fwrite(STDOUT, "resolution_log region_source: " . ($logRow?->region_source ?? 'NULL') . PHP_EOL);
fwrite(STDOUT, "resolution_log subject_code_resolved: " . ($logRow?->subject_code_resolved ?? 'NULL') . PHP_EOL);
fwrite(STDOUT, '=== END B1 ===' . PHP_EOL . PHP_EOL);
fwrite(STDOUT, 'resolution_log routing_step: '.($logRow?->routing_step ?? 'NULL').PHP_EOL);
fwrite(STDOUT, 'resolution_log region_source: '.($logRow?->region_source ?? 'NULL').PHP_EOL);
fwrite(STDOUT, 'resolution_log subject_code_resolved: '.($logRow?->subject_code_resolved ?? 'NULL').PHP_EOL);
fwrite(STDOUT, '=== END B1 ==='.PHP_EOL.PHP_EOL);
expect($logRow)->not->toBeNull(
"FINDING: lead_region_resolution_log has no row for this lead. " .
"logRegionResolution() may have failed silently (fail-safe)."
'FINDING: lead_region_resolution_log has no row for this lead. '.
'logRegionResolution() may have failed silently (fail-safe).'
);
if ($logRow !== null) {
expect((int) $logRow->routing_step)->toBe(1,
"FINDING: lead_region_resolution_log.routing_step should be 1 (first project is X at step 1). " .
'FINDING: lead_region_resolution_log.routing_step should be 1 (first project is X at step 1). '.
"Got: {$logRow->routing_step}. The log records step of first project in selected collection."
);
expect((int) $logRow->subject_code_resolved)->toBe(BC_MOSCOW,
"FINDING: resolved subject_code should be 82 (Москва) from DaData qc=0. " .
'FINDING: resolved subject_code should be 82 (Москва) from DaData qc=0. '.
"Got: {$logRow->subject_code_resolved}."
);
}
@@ -256,13 +256,12 @@ it('B1: lead matching client Xs exact region goes to X at step 1, Y fills at ste
if ($dealX !== null) {
expect((int) $dealX->subject_code)->toBe(BC_MOSCOW,
"FINDING: deals.subject_code should be 82 (Москва) for step-1 deal. " .
'FINDING: deals.subject_code should be 82 (Москва) for step-1 deal. '.
"Got: {$dealX->subject_code}."
);
}
})->group('imitation');
/**
* Scenario B2: Lead with a subject nobody has exactly goes to all-RF client (step 2).
*
@@ -281,86 +280,86 @@ it('B2: lead with foreign subject goes to all-RF client at step 2 when nobody ha
// ── ARRANGE ─────────────────────────────────────────────────────────────────
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => BC_B_DOMAIN,
'unique_key' => BC_B_DOMAIN,
]);
// Client X: regions=[82] (Москва) — will NOT match code 50.
$tenantX = Tenant::factory()->create([
'balance_rub' => '99999.00',
'balance_rub' => '99999.00',
'frozen_by_balance_at' => null,
]);
$projectX = Project::factory()->create([
'tenant_id' => $tenantX->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => BC_B_DOMAIN,
'daily_limit_target' => 10,
'tenant_id' => $tenantX->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => BC_B_DOMAIN,
'daily_limit_target' => 10,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [BC_MOSCOW],
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [BC_MOSCOW],
]);
linkProjectToSupplier($projectX, $supplier);
// Client Y: all-RF — will match any subject at phase 2.
$tenantY = Tenant::factory()->create([
'balance_rub' => '99999.00',
'balance_rub' => '99999.00',
'frozen_by_balance_at' => null,
]);
$projectY = Project::factory()->create([
'tenant_id' => $tenantY->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => BC_B_DOMAIN,
'daily_limit_target' => 10,
'tenant_id' => $tenantY->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => BC_B_DOMAIN,
'daily_limit_target' => 10,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [], // all-RF
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [], // all-RF
]);
linkProjectToSupplier($projectY, $supplier);
$activeDate = SnapshotForge::activeDate();
createRoutingSnapshotFromProject(
project: $projectX,
date: $activeDate,
signalType: 'site',
signalIdentifier: BC_B_DOMAIN,
dailyLimit: 10,
regions: '{' . BC_MOSCOW . '}',
project: $projectX,
date: $activeDate,
signalType: 'site',
signalIdentifier: BC_B_DOMAIN,
dailyLimit: 10,
regions: '{'.BC_MOSCOW.'}',
);
createRoutingSnapshotFromProject(
project: $projectY,
date: $activeDate,
signalType: 'site',
signalIdentifier: BC_B_DOMAIN,
dailyLimit: 10,
regions: '{}',
project: $projectY,
date: $activeDate,
signalType: 'site',
signalIdentifier: BC_B_DOMAIN,
dailyLimit: 10,
regions: '{}',
);
// Lead resolves to Костромская область (code 50) — DaData qc=0, region name must
// be the exact string in RussianRegions::CODE_TO_NAME[50] = 'Костромская область'
// so that DaDataRegionMap::toSubjectCode() returns 50.
$fakeDaData = new FakeDaDataPhoneClient();
$fakeDaData = new FakeDaDataPhoneClient;
$fakeDaData->stub('79162000001', qc: 0, region: 'Костромская область', provider: 'Билайн');
app()->instance(DaDataPhoneClient::class, $fakeDaData);
// ── ACT ─────────────────────────────────────────────────────────────────────
$injector = new LeadInjector();
$injector = new LeadInjector;
$lead = $injector->site(
domain: BC_B_DOMAIN,
phone: '79162000001',
tag: null,
domain: BC_B_DOMAIN,
phone: '79162000001',
tag: null,
platform: 'B1',
vid: 8_100_000_002,
vid: 8_100_000_002,
);
// ── ASSERT ──────────────────────────────────────────────────────────────────
@@ -380,43 +379,42 @@ it('B2: lead with foreign subject goes to all-RF client at step 2 when nobody ha
->where('supplier_lead_id', $lead->id)
->first();
fwrite(STDOUT, PHP_EOL . '=== B2 DISTRIBUTION ===' . PHP_EOL);
fwrite(STDOUT, "Client X (Москва exact) deals: {$dealsX}" . PHP_EOL);
fwrite(STDOUT, "Client Y (all-RF) deals: {$dealsY}" . PHP_EOL);
fwrite(STDOUT, "resolution_log routing_step: " . ($logRow?->routing_step ?? 'NULL') . PHP_EOL);
fwrite(STDOUT, "resolution_log subject_code_resolved: " . ($logRow?->subject_code_resolved ?? 'NULL') . PHP_EOL);
fwrite(STDOUT, '=== END B2 ===' . PHP_EOL . PHP_EOL);
fwrite(STDOUT, PHP_EOL.'=== B2 DISTRIBUTION ==='.PHP_EOL);
fwrite(STDOUT, "Client X (Москва exact) deals: {$dealsX}".PHP_EOL);
fwrite(STDOUT, "Client Y (all-RF) deals: {$dealsY}".PHP_EOL);
fwrite(STDOUT, 'resolution_log routing_step: '.($logRow?->routing_step ?? 'NULL').PHP_EOL);
fwrite(STDOUT, 'resolution_log subject_code_resolved: '.($logRow?->subject_code_resolved ?? 'NULL').PHP_EOL);
fwrite(STDOUT, '=== END B2 ==='.PHP_EOL.PHP_EOL);
// X must NOT receive the lead (code 50 is NOT in X's regions=[82]).
expect($dealsX)->toBe(0,
"FINDING: Client X (regions=[82]) should NOT receive a lead with subject=50 (Костромская область). " .
'FINDING: Client X (regions=[82]) should NOT receive a lead with subject=50 (Костромская область). '.
"Got dealsX={$dealsX}. Phase-1 exact filter may be matching wrong subjects."
);
// Y must receive the lead (all-RF, step 2).
expect($dealsY)->toBe(1,
"FINDING: Client Y (all-RF regions='{}') should receive the lead at step 2. " .
"FINDING: Client Y (all-RF regions='{}') should receive the lead at step 2. ".
"Got dealsY={$dealsY}. Phase-2 all-RF filter may not be working."
);
expect($logRow)->not->toBeNull(
"FINDING: lead_region_resolution_log has no row. logRegionResolution() may have failed."
'FINDING: lead_region_resolution_log has no row. logRegionResolution() may have failed.'
);
if ($logRow !== null) {
expect((int) $logRow->routing_step)->toBe(2,
"FINDING: routing_step should be 2 (first project in selected is Y at step 2). " .
'FINDING: routing_step should be 2 (first project in selected is Y at step 2). '.
"Got: {$logRow->routing_step}."
);
expect((int) $logRow->subject_code_resolved)->toBe(BC_FOREIGN,
"FINDING: resolved subject_code should be 50 (Костромская область). " .
'FINDING: resolved subject_code should be 50 (Костромская область). '.
"Got: {$logRow->subject_code_resolved}."
);
}
})->group('imitation');
// ══════════════════════════════════════════════════════════════════════════════
// SCENARIO C — each client gets only its own region (phase-1 isolation)
// ══════════════════════════════════════════════════════════════════════════════
@@ -453,85 +451,85 @@ it('C1: lead with subject code 1 goes only to client A (regions=[1]), not to cli
// ── ARRANGE ─────────────────────────────────────────────────────────────────
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => BC_C_DOMAIN,
'unique_key' => BC_C_DOMAIN,
]);
// Client A: Республика Адыгея (code 1).
$tenantA = Tenant::factory()->create([
'balance_rub' => '99999.00',
'balance_rub' => '99999.00',
'frozen_by_balance_at' => null,
]);
$projectA = Project::factory()->create([
'tenant_id' => $tenantA->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => BC_C_DOMAIN,
'daily_limit_target' => 10,
'tenant_id' => $tenantA->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => BC_C_DOMAIN,
'daily_limit_target' => 10,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [BC_ADYGEA], // code 1
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [BC_ADYGEA], // code 1
]);
linkProjectToSupplier($projectA, $supplier);
// Client B: Санкт-Петербург (code 83).
$tenantB = Tenant::factory()->create([
'balance_rub' => '99999.00',
'balance_rub' => '99999.00',
'frozen_by_balance_at' => null,
]);
$projectB = Project::factory()->create([
'tenant_id' => $tenantB->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => BC_C_DOMAIN,
'daily_limit_target' => 10,
'tenant_id' => $tenantB->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => BC_C_DOMAIN,
'daily_limit_target' => 10,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [BC_SPB], // code 83
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [BC_SPB], // code 83
]);
linkProjectToSupplier($projectB, $supplier);
$activeDate = SnapshotForge::activeDate();
createRoutingSnapshotFromProject(
project: $projectA,
date: $activeDate,
signalType: 'site',
signalIdentifier: BC_C_DOMAIN,
dailyLimit: 10,
regions: '{' . BC_ADYGEA . '}',
project: $projectA,
date: $activeDate,
signalType: 'site',
signalIdentifier: BC_C_DOMAIN,
dailyLimit: 10,
regions: '{'.BC_ADYGEA.'}',
);
createRoutingSnapshotFromProject(
project: $projectB,
date: $activeDate,
signalType: 'site',
signalIdentifier: BC_C_DOMAIN,
dailyLimit: 10,
regions: '{' . BC_SPB . '}',
project: $projectB,
date: $activeDate,
signalType: 'site',
signalIdentifier: BC_C_DOMAIN,
dailyLimit: 10,
regions: '{'.BC_SPB.'}',
);
// FakeDaData: phone→Adygea (code 1).
// RussianRegions::CODE_TO_NAME[1] = 'Республика Адыгея'
$fakeDaData = new FakeDaDataPhoneClient();
$fakeDaData = new FakeDaDataPhoneClient;
$fakeDaData->stub('79163000001', qc: 0, region: 'Республика Адыгея', provider: 'МегаФон');
app()->instance(DaDataPhoneClient::class, $fakeDaData);
// ── ACT ─────────────────────────────────────────────────────────────────────
$injector = new LeadInjector();
$injector = new LeadInjector;
$leadAdygea = $injector->site(
domain: BC_C_DOMAIN,
phone: '79163000001',
tag: null,
domain: BC_C_DOMAIN,
phone: '79163000001',
tag: null,
platform: 'B1',
vid: 8_100_000_010,
vid: 8_100_000_010,
);
// ── ASSERT ──────────────────────────────────────────────────────────────────
@@ -551,27 +549,27 @@ it('C1: lead with subject code 1 goes only to client A (regions=[1]), not to cli
->where('supplier_lead_id', $leadAdygea->id)
->first();
fwrite(STDOUT, PHP_EOL . '=== C1 DISTRIBUTION (lead→Адыгея) ===' . PHP_EOL);
fwrite(STDOUT, "Client A (Адыгея code=1) deals: {$dealsA}" . PHP_EOL);
fwrite(STDOUT, "Client B (СПб code=83) deals: {$dealsB}" . PHP_EOL);
fwrite(STDOUT, "resolution_log routing_step: " . ($logRow?->routing_step ?? 'NULL') . PHP_EOL);
fwrite(STDOUT, "resolution_log subject_code_resolved: " . ($logRow?->subject_code_resolved ?? 'NULL') . PHP_EOL);
fwrite(STDOUT, '=== END C1 ===' . PHP_EOL . PHP_EOL);
fwrite(STDOUT, PHP_EOL.'=== C1 DISTRIBUTION (lead→Адыгея) ==='.PHP_EOL);
fwrite(STDOUT, "Client A (Адыгея code=1) deals: {$dealsA}".PHP_EOL);
fwrite(STDOUT, "Client B (СПб code=83) deals: {$dealsB}".PHP_EOL);
fwrite(STDOUT, 'resolution_log routing_step: '.($logRow?->routing_step ?? 'NULL').PHP_EOL);
fwrite(STDOUT, 'resolution_log subject_code_resolved: '.($logRow?->subject_code_resolved ?? 'NULL').PHP_EOL);
fwrite(STDOUT, '=== END C1 ==='.PHP_EOL.PHP_EOL);
// A must receive the lead.
expect($dealsA)->toBe(1,
"FINDING: Client A (regions=[1], Адыгея) should receive the lead with subject=1. " .
'FINDING: Client A (regions=[1], Адыгея) should receive the lead with subject=1. '.
"Got dealsA={$dealsA}. Phase-1 exact match may not be working for small subject codes."
);
// B must NOT receive the lead (step-1 only → combined=[A] is not empty → phase 3 skipped).
expect($dealsB)->toBe(0,
"FINDING: Client B (regions=[83], СПб) should NOT receive the Адыгея lead. " .
"Got dealsB={$dealsB}. " .
"If >0: the cascade reached phase 3 (fallback 'any') and gave the lead to B as well. " .
"This is because phase 1 picked A (1 candidate < cap=3) and phase 2 (all-RF) was empty, " .
"so combined=[A] which is NOT empty → phase 3 is skipped per LeadRouter logic. " .
"If B got the lead, phase 3 fired — investigate LeadRouter.combined.isNotEmpty() branch."
'FINDING: Client B (regions=[83], СПб) should NOT receive the Адыгея lead. '.
"Got dealsB={$dealsB}. ".
"If >0: the cascade reached phase 3 (fallback 'any') and gave the lead to B as well. ".
'This is because phase 1 picked A (1 candidate < cap=3) and phase 2 (all-RF) was empty, '.
'so combined=[A] which is NOT empty → phase 3 is skipped per LeadRouter logic. '.
'If B got the lead, phase 3 fired — investigate LeadRouter.combined.isNotEmpty() branch.'
);
if ($logRow !== null) {
@@ -581,7 +579,6 @@ it('C1: lead with subject code 1 goes only to client A (regions=[1]), not to cli
}
})->group('imitation');
/**
* Scenario C2: Lead with СПб subject goes only to client B, not to A.
*
@@ -591,83 +588,83 @@ it('C2: lead with subject code 83 goes only to client B (regions=[83]), not to c
// ── ARRANGE ─────────────────────────────────────────────────────────────────
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => BC_C_DOMAIN,
'unique_key' => BC_C_DOMAIN,
]);
$tenantA = Tenant::factory()->create([
'balance_rub' => '99999.00',
'balance_rub' => '99999.00',
'frozen_by_balance_at' => null,
]);
$projectA = Project::factory()->create([
'tenant_id' => $tenantA->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => BC_C_DOMAIN,
'daily_limit_target' => 10,
'tenant_id' => $tenantA->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => BC_C_DOMAIN,
'daily_limit_target' => 10,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [BC_ADYGEA],
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [BC_ADYGEA],
]);
linkProjectToSupplier($projectA, $supplier);
$tenantB = Tenant::factory()->create([
'balance_rub' => '99999.00',
'balance_rub' => '99999.00',
'frozen_by_balance_at' => null,
]);
$projectB = Project::factory()->create([
'tenant_id' => $tenantB->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => BC_C_DOMAIN,
'daily_limit_target' => 10,
'tenant_id' => $tenantB->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => BC_C_DOMAIN,
'daily_limit_target' => 10,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [BC_SPB],
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [BC_SPB],
]);
linkProjectToSupplier($projectB, $supplier);
$activeDate = SnapshotForge::activeDate();
createRoutingSnapshotFromProject(
project: $projectA,
date: $activeDate,
signalType: 'site',
signalIdentifier: BC_C_DOMAIN,
dailyLimit: 10,
regions: '{' . BC_ADYGEA . '}',
project: $projectA,
date: $activeDate,
signalType: 'site',
signalIdentifier: BC_C_DOMAIN,
dailyLimit: 10,
regions: '{'.BC_ADYGEA.'}',
);
createRoutingSnapshotFromProject(
project: $projectB,
date: $activeDate,
signalType: 'site',
signalIdentifier: BC_C_DOMAIN,
dailyLimit: 10,
regions: '{' . BC_SPB . '}',
project: $projectB,
date: $activeDate,
signalType: 'site',
signalIdentifier: BC_C_DOMAIN,
dailyLimit: 10,
regions: '{'.BC_SPB.'}',
);
// FakeDaData: phone→СПб (code 83).
// RussianRegions::CODE_TO_NAME[83] = 'Санкт-Петербург'
$fakeDaData = new FakeDaDataPhoneClient();
$fakeDaData = new FakeDaDataPhoneClient;
$fakeDaData->stub('79164000001', qc: 0, region: 'Санкт-Петербург', provider: 'Теле2');
app()->instance(DaDataPhoneClient::class, $fakeDaData);
// ── ACT ─────────────────────────────────────────────────────────────────────
$injector = new LeadInjector();
$injector = new LeadInjector;
$leadSpb = $injector->site(
domain: BC_C_DOMAIN,
phone: '79164000001',
tag: null,
domain: BC_C_DOMAIN,
phone: '79164000001',
tag: null,
platform: 'B1',
vid: 8_100_000_011,
vid: 8_100_000_011,
);
// ── ASSERT ──────────────────────────────────────────────────────────────────
@@ -687,22 +684,22 @@ it('C2: lead with subject code 83 goes only to client B (regions=[83]), not to c
->where('supplier_lead_id', $leadSpb->id)
->first();
fwrite(STDOUT, PHP_EOL . '=== C2 DISTRIBUTION (lead→СПб) ===' . PHP_EOL);
fwrite(STDOUT, "Client A (Адыгея code=1) deals: {$dealsA}" . PHP_EOL);
fwrite(STDOUT, "Client B (СПб code=83) deals: {$dealsB}" . PHP_EOL);
fwrite(STDOUT, "resolution_log routing_step: " . ($logRow?->routing_step ?? 'NULL') . PHP_EOL);
fwrite(STDOUT, "resolution_log subject_code_resolved: " . ($logRow?->subject_code_resolved ?? 'NULL') . PHP_EOL);
fwrite(STDOUT, '=== END C2 ===' . PHP_EOL . PHP_EOL);
fwrite(STDOUT, PHP_EOL.'=== C2 DISTRIBUTION (lead→СПб) ==='.PHP_EOL);
fwrite(STDOUT, "Client A (Адыгея code=1) deals: {$dealsA}".PHP_EOL);
fwrite(STDOUT, "Client B (СПб code=83) deals: {$dealsB}".PHP_EOL);
fwrite(STDOUT, 'resolution_log routing_step: '.($logRow?->routing_step ?? 'NULL').PHP_EOL);
fwrite(STDOUT, 'resolution_log subject_code_resolved: '.($logRow?->subject_code_resolved ?? 'NULL').PHP_EOL);
fwrite(STDOUT, '=== END C2 ==='.PHP_EOL.PHP_EOL);
// B must receive the lead (exact СПб match at step 1).
expect($dealsB)->toBe(1,
"FINDING: Client B (regions=[83], СПб) should receive the СПб lead at step 1. " .
'FINDING: Client B (regions=[83], СПб) should receive the СПб lead at step 1. '.
"Got dealsB={$dealsB}."
);
// A must NOT receive the lead.
expect($dealsA)->toBe(0,
"FINDING: Client A (regions=[1], Адыгея) should NOT receive the СПб lead. " .
'FINDING: Client A (regions=[1], Адыгея) should NOT receive the СПб lead. '.
"Got dealsA={$dealsA}. Phase-3 fallback may have fired — investigate."
);
@@ -712,7 +709,7 @@ it('C2: lead with subject code 83 goes only to client B (regions=[83]), not to c
);
expect((int) $logRow->subject_code_resolved)->toBe(BC_SPB,
"FINDING: resolved subject_code should be 83 (Санкт-Петербург). " .
'FINDING: resolved subject_code should be 83 (Санкт-Петербург). '.
"Got: {$logRow->subject_code_resolved}."
);
}
@@ -15,7 +15,6 @@ use Illuminate\Support\Facades\DB;
use Random\Engine\Mt19937;
use Random\Randomizer;
use Tests\Concerns\SharesSupplierPdo;
use Tests\Support\Imitation\ConditionLevers;
use Tests\Support\Imitation\FakeDaDataPhoneClient;
use Tests\Support\Imitation\LeadInjector;
use Tests\Support\Imitation\SnapshotForge;
@@ -69,7 +68,7 @@ uses(DatabaseTransactions::class, SharesSupplierPdo::class);
const D_MOSCOW_CODE = 82;
// ── SUPPLIER SIGNAL ───────────────────────────────────────────────────────────
const D_SUPPLIER_DOMAIN = 'scenario-d-delivery-days.ru';
const D_SUPPLIER_DOMAIN = 'scenario-d-delivery-days.ru';
const D_SUPPLIER_PLATFORM = 'B1';
// ── DETERMINISTIC SEED ────────────────────────────────────────────────────────
@@ -88,9 +87,9 @@ beforeEach(function (): void {
// DaData config — FakeDaDataPhoneClient bypasses HTTP entirely.
config([
'services.dadata.enabled' => true,
'services.dadata.api_key' => 'fake-key',
'services.dadata.secret' => 'fake-secret',
'services.dadata.enabled' => true,
'services.dadata.api_key' => 'fake-key',
'services.dadata.secret' => 'fake-secret',
'services.dadata.daily_cap_rub' => 1_000_000,
]);
@@ -108,9 +107,9 @@ it('only delivers to the active-today client; the excluded-day client gets zero
//
// We MUST compute the bit from the active-snapshot date (not wall-clock today),
// because after 21:00 MSK the active date flips to tomorrow.
$activeDate = SnapshotForge::activeDate(); // 'YYYY-MM-DD'
$activeDate = SnapshotForge::activeDate(); // 'YYYY-MM-DD'
$activeDateObj = Carbon::parse($activeDate, 'Europe/Moscow');
$todayBit = 1 << ($activeDateObj->isoWeekday() - 1); // e.g. Wednesday=4
$todayBit = 1 << ($activeDateObj->isoWeekday() - 1); // e.g. Wednesday=4
// ACTIVE mask: full week (includes every day, including today).
$activeMask = 127; // 0b1111111
@@ -120,56 +119,56 @@ it('only delivers to the active-today client; the excluded-day client gets zero
$inactiveMask = 127 & ~$todayBit; // clears the bit for the active date's weekday
// Sanity: active mask passes the filter, inactive mask does not.
expect(($activeMask & $todayBit) !== 0)->toBeTrue();
expect(($activeMask & $todayBit) !== 0)->toBeTrue();
expect(($inactiveMask & $todayBit))->toBe(0);
// ── ARRANGE: SHARED SUPPLIER PROJECT ─────────────────────────────────────
$supplier = SupplierProject::factory()->create([
'platform' => D_SUPPLIER_PLATFORM,
'platform' => D_SUPPLIER_PLATFORM,
'signal_type' => 'site',
'unique_key' => D_SUPPLIER_DOMAIN,
'unique_key' => D_SUPPLIER_DOMAIN,
]);
// ── ARRANGE: ACTIVE TENANT / PROJECT ─────────────────────────────────────
$tenantActive = Tenant::factory()->create([
'balance_rub' => '9999.00',
'balance_rub' => '9999.00',
'frozen_by_balance_at' => null,
]);
$projectActive = Project::factory()->create([
'tenant_id' => $tenantActive->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => D_SUPPLIER_DOMAIN,
'daily_limit_target' => 10,
'tenant_id' => $tenantActive->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => D_SUPPLIER_DOMAIN,
'daily_limit_target' => 10,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => $activeMask, // all days — included today
'preflight_blocked_at' => null,
'regions' => [D_MOSCOW_CODE],
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => $activeMask, // all days — included today
'preflight_blocked_at' => null,
'regions' => [D_MOSCOW_CODE],
]);
linkProjectToSupplier($projectActive, $supplier);
// ── ARRANGE: INACTIVE TENANT / PROJECT ───────────────────────────────────
$tenantInactive = Tenant::factory()->create([
'balance_rub' => '9999.00',
'balance_rub' => '9999.00',
'frozen_by_balance_at' => null,
]);
$projectInactive = Project::factory()->create([
'tenant_id' => $tenantInactive->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => D_SUPPLIER_DOMAIN,
'daily_limit_target' => 10,
'tenant_id' => $tenantInactive->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => D_SUPPLIER_DOMAIN,
'daily_limit_target' => 10,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => $inactiveMask, // today's bit cleared — excluded from snapshot
'preflight_blocked_at' => null,
'regions' => [D_MOSCOW_CODE],
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => $inactiveMask, // today's bit cleared — excluded from snapshot
'preflight_blocked_at' => null,
'regions' => [D_MOSCOW_CODE],
]);
linkProjectToSupplier($projectInactive, $supplier);
@@ -194,29 +193,29 @@ it('only delivers to the active-today client; the excluded-day client gets zero
->exists();
expect($snapshotActiveExists)->toBeTrue(
"FINDING: projectActive (mask={$activeMask}, bit={$todayBit}) " .
"was expected in the snapshot for {$activeDate} but is absent. " .
"SnapshotRebuildCommand may have a bug in delivery_days_mask filtering."
"FINDING: projectActive (mask={$activeMask}, bit={$todayBit}) ".
"was expected in the snapshot for {$activeDate} but is absent. ".
'SnapshotRebuildCommand may have a bug in delivery_days_mask filtering.'
);
expect($snapshotInactiveExists)->toBeFalse(
"FINDING: projectInactive (mask={$inactiveMask}, bit={$todayBit}) " .
"was expected to be ABSENT from the snapshot for {$activeDate} but was INSERTED. " .
"This means the delivery_days_mask filter is not working — the inactive project " .
"will receive leads it should not receive."
"FINDING: projectInactive (mask={$inactiveMask}, bit={$todayBit}) ".
"was expected to be ABSENT from the snapshot for {$activeDate} but was INSERTED. ".
'This means the delivery_days_mask filter is not working — the inactive project '.
'will receive leads it should not receive.'
);
// ── ARRANGE: FAKE DADATA CLIENT ──────────────────────────────────────────
$fake = (new FakeDaDataPhoneClient())->stub(D_PHONE_1, qc: 0, region: 'Москва', provider: 'МТС');
$fake = (new FakeDaDataPhoneClient)->stub(D_PHONE_1, qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fake);
// ── ACT: INJECT ONE LEAD ─────────────────────────────────────────────────
(new LeadInjector())->site(
domain: D_SUPPLIER_DOMAIN,
phone: D_PHONE_1,
tag: 'Москва',
(new LeadInjector)->site(
domain: D_SUPPLIER_DOMAIN,
phone: D_PHONE_1,
tag: 'Москва',
platform: D_SUPPLIER_PLATFORM,
vid: 88_000_000_001,
vid: 88_000_000_001,
);
// ── ASSERT: DEALS DISTRIBUTION ───────────────────────────────────────────
@@ -234,7 +233,7 @@ it('only delivers to the active-today client; the excluded-day client gets zero
->count();
// Diagnostic output.
fwrite(STDOUT, PHP_EOL . '=== SCENARIO D: DELIVERY DAYS FILTER REPORT ===' . PHP_EOL);
fwrite(STDOUT, PHP_EOL.'=== SCENARIO D: DELIVERY DAYS FILTER REPORT ==='.PHP_EOL);
fwrite(STDOUT, sprintf('Active date: %s (isoWeekday=%d)%s',
$activeDate, $activeDateObj->isoWeekday(), PHP_EOL));
fwrite(STDOUT, sprintf('Today bit: %d (0b%s)%s',
@@ -247,18 +246,18 @@ it('only delivers to the active-today client; the excluded-day client gets zero
$snapshotInactiveExists ? 'PRESENT (BUG!)' : 'ABSENT (correct)', PHP_EOL));
fwrite(STDOUT, sprintf('Active client deals: %d (expected: 1)%s', $activeDeals, PHP_EOL));
fwrite(STDOUT, sprintf('Inactive client deals: %d (expected: 0)%s', $inactiveDeals, PHP_EOL));
fwrite(STDOUT, '=== END SCENARIO D ===' . PHP_EOL . PHP_EOL);
fwrite(STDOUT, '=== END SCENARIO D ==='.PHP_EOL.PHP_EOL);
expect($activeDeals)->toBe(1,
"FINDING: Active client (mask={$activeMask}, project_id={$projectActive->id}) " .
"expected 1 deal but got {$activeDeals}. " .
"Check LeadRouter eligibility query or LedgerService::chargeForDelivery."
"FINDING: Active client (mask={$activeMask}, project_id={$projectActive->id}) ".
"expected 1 deal but got {$activeDeals}. ".
'Check LeadRouter eligibility query or LedgerService::chargeForDelivery.'
);
expect($inactiveDeals)->toBe(0,
"FINDING: Inactive client (mask={$inactiveMask}, today_bit={$todayBit}, " .
"project_id={$projectInactive->id}) expected 0 deals but got {$inactiveDeals}. " .
"The delivery_days_mask filter in SnapshotRebuildCommand is NOT excluding this project. " .
"FINDING: Inactive client (mask={$inactiveMask}, today_bit={$todayBit}, ".
"project_id={$projectInactive->id}) expected 0 deals but got {$inactiveDeals}. ".
'The delivery_days_mask filter in SnapshotRebuildCommand is NOT excluding this project. '.
"This is a correctness bug: clients with today's bit cleared MUST be absent from the snapshot."
);
@@ -19,6 +19,7 @@ use Tests\Concerns\SharesSupplierPdo;
use Tests\Support\Imitation\ConditionLevers;
use Tests\Support\Imitation\FakeDaDataPhoneClient;
use Tests\Support\Imitation\LeadInjector;
use Tests\Support\Imitation\SnapshotForge;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
@@ -78,9 +79,9 @@ beforeEach(function (): void {
// DaData config — required by LeadRegionResolver even when FakeDaDataPhoneClient is used.
config([
'services.dadata.enabled' => true,
'services.dadata.api_key' => 'fake-key',
'services.dadata.secret' => 'fake-secret',
'services.dadata.enabled' => true,
'services.dadata.api_key' => 'fake-key',
'services.dadata.secret' => 'fake-secret',
'services.dadata.daily_cap_rub' => 1_000_000,
]);
@@ -99,49 +100,49 @@ it('E1: project is paused and mail sent when balance below tier price; lead goes
// One shared supplier project (B1 site signal).
$supplier = SupplierProject::factory()->create([
'platform' => EF_PLATFORM,
'platform' => EF_PLATFORM,
'signal_type' => 'site',
'unique_key' => EF_DOMAIN,
'unique_key' => EF_DOMAIN,
]);
// Client1: balance BELOW tier-1 price (500 RUB). Even 1 kopeck short triggers pause.
// We set balance to 0 which is definitely below 500 RUB threshold.
$tenant1 = Tenant::factory()->create([
'balance_rub' => '0.00',
'balance_rub' => '0.00',
'frozen_by_balance_at' => null,
]);
$project1 = Project::factory()->create([
'tenant_id' => $tenant1->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => EF_DOMAIN,
'daily_limit_target' => EF_HEALTHY_LIMIT,
'tenant_id' => $tenant1->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => EF_DOMAIN,
'daily_limit_target' => EF_HEALTHY_LIMIT,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [EF_MOSCOW_CODE],
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [EF_MOSCOW_CODE],
]);
linkProjectToSupplier($project1, $supplier);
// Client2: healthy balance — should receive the lead.
$tenant2 = Tenant::factory()->create([
'balance_rub' => '9999.00',
'balance_rub' => '9999.00',
'frozen_by_balance_at' => null,
]);
$project2 = Project::factory()->create([
'tenant_id' => $tenant2->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => EF_DOMAIN,
'daily_limit_target' => EF_HEALTHY_LIMIT,
'tenant_id' => $tenant2->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => EF_DOMAIN,
'daily_limit_target' => EF_HEALTHY_LIMIT,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [EF_MOSCOW_CODE],
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [EF_MOSCOW_CODE],
]);
linkProjectToSupplier($project2, $supplier);
@@ -158,7 +159,7 @@ it('E1: project is paused and mail sent when balance below tier price; lead goes
// Tier 1 = 500 RUB. Set to 499.99 — positive but insufficient.
ConditionLevers::setBalance($tenant1, '499.99');
$activeDate = \Tests\Support\Imitation\SnapshotForge::activeDate();
$activeDate = SnapshotForge::activeDate();
// Build snapshots for both clients so both pass the snapshot filter.
createRoutingSnapshotFromProject(
@@ -167,7 +168,7 @@ it('E1: project is paused and mail sent when balance below tier price; lead goes
signalType: 'site',
signalIdentifier: EF_DOMAIN,
dailyLimit: EF_HEALTHY_LIMIT,
regions: '{' . EF_MOSCOW_CODE . '}',
regions: '{'.EF_MOSCOW_CODE.'}',
);
createRoutingSnapshotFromProject(
project: $project2,
@@ -175,24 +176,24 @@ it('E1: project is paused and mail sent when balance below tier price; lead goes
signalType: 'site',
signalIdentifier: EF_DOMAIN,
dailyLimit: EF_HEALTHY_LIMIT,
regions: '{' . EF_MOSCOW_CODE . '}',
regions: '{'.EF_MOSCOW_CODE.'}',
);
// FakeDaData: phone → Москва (qc=0, subject_code=82).
$phone = '79161234001';
$fakeDaData = new FakeDaDataPhoneClient();
$fakeDaData = new FakeDaDataPhoneClient;
$fakeDaData->stub($phone, qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fakeDaData);
// ── ACT ──────────────────────────────────────────────────────────────────
$injector = new LeadInjector();
$injector = new LeadInjector;
$lead = $injector->site(
domain: EF_DOMAIN,
phone: $phone,
tag: 'Москва',
domain: EF_DOMAIN,
phone: $phone,
tag: 'Москва',
platform: EF_PLATFORM,
vid: 1_100_000_001,
vid: 1_100_000_001,
);
// ── ASSERT ───────────────────────────────────────────────────────────────
@@ -200,9 +201,9 @@ it('E1: project is paused and mail sent when balance below tier price; lead goes
// Client1's project must be paused (is_active=false) after the balance failure.
$project1->refresh();
expect($project1->is_active)->toBeFalse(
'FINDING E1: project1 was NOT paused after InsufficientBalance. ' .
'Expected RouteSupplierLeadJob::handleInsufficientBalance to set is_active=false. ' .
'Actual is_active=' . ($project1->is_active ? 'true' : 'false')
'FINDING E1: project1 was NOT paused after InsufficientBalance. '.
'Expected RouteSupplierLeadJob::handleInsufficientBalance to set is_active=false. '.
'Actual is_active='.($project1->is_active ? 'true' : 'false')
);
// ZeroBalancePausedMail must have been sent for tenant1 (rate-limit: first call always fires).
@@ -217,8 +218,8 @@ it('E1: project is paused and mail sent when balance below tier price; lead goes
->count();
expect($tenant2Deals)->toBe(1,
'FINDING E1: Client2 (healthy) did NOT receive the lead. ' .
"Expected 1 deal for tenant2 (id={$tenant2->id}), got {$tenant2Deals}. " .
'FINDING E1: Client2 (healthy) did NOT receive the lead. '.
"Expected 1 deal for tenant2 (id={$tenant2->id}), got {$tenant2Deals}. ".
'The auto-pause flow should skip Client1 and continue routing to Client2.'
);
@@ -229,8 +230,8 @@ it('E1: project is paused and mail sent when balance below tier price; lead goes
->count();
expect($tenant1Deals)->toBe(0,
'FINDING E1: Client1 (insufficient balance) has a deal when it should have 0. ' .
"Tenant1 id={$tenant1->id} has {$tenant1Deals} deals. " .
'FINDING E1: Client1 (insufficient balance) has a deal when it should have 0. '.
"Tenant1 id={$tenant1->id} has {$tenant1Deals} deals. ".
'InsufficientBalance should roll back the transaction, preventing deal creation.'
);
@@ -248,30 +249,30 @@ it('E2: frozen tenant is excluded from eligibility; healthy client gets the lead
// ── ARRANGE ──────────────────────────────────────────────────────────────
$supplier = SupplierProject::factory()->create([
'platform' => EF_PLATFORM,
'platform' => EF_PLATFORM,
'signal_type' => 'site',
'unique_key' => EF_DOMAIN,
'unique_key' => EF_DOMAIN,
]);
// Client1: frozen via ConditionLevers::freeze().
// After freeze(), frozen_by_balance_at IS NOT NULL → excluded by LeadRouter SQL
// WHERE tenants.frozen_by_balance_at IS NULL.
$tenant1 = Tenant::factory()->create([
'balance_rub' => '9999.00', // balance is fine — frozen is the blocker
'balance_rub' => '9999.00', // balance is fine — frozen is the blocker
'frozen_by_balance_at' => null,
]);
$project1 = Project::factory()->create([
'tenant_id' => $tenant1->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => EF_DOMAIN,
'daily_limit_target' => EF_HEALTHY_LIMIT,
'tenant_id' => $tenant1->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => EF_DOMAIN,
'daily_limit_target' => EF_HEALTHY_LIMIT,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [EF_MOSCOW_CODE],
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [EF_MOSCOW_CODE],
]);
linkProjectToSupplier($project1, $supplier);
@@ -282,25 +283,25 @@ it('E2: frozen tenant is excluded from eligibility; healthy client gets the lead
// Client2: healthy.
$tenant2 = Tenant::factory()->create([
'balance_rub' => '9999.00',
'balance_rub' => '9999.00',
'frozen_by_balance_at' => null,
]);
$project2 = Project::factory()->create([
'tenant_id' => $tenant2->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => EF_DOMAIN,
'daily_limit_target' => EF_HEALTHY_LIMIT,
'tenant_id' => $tenant2->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => EF_DOMAIN,
'daily_limit_target' => EF_HEALTHY_LIMIT,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [EF_MOSCOW_CODE],
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [EF_MOSCOW_CODE],
]);
linkProjectToSupplier($project2, $supplier);
$activeDate = \Tests\Support\Imitation\SnapshotForge::activeDate();
$activeDate = SnapshotForge::activeDate();
// Snapshots for both clients. The snapshot itself may include Client1 (snapshot was
// built from static project data), but LeadRouter's SQL live-checks frozen_by_balance_at.
@@ -310,7 +311,7 @@ it('E2: frozen tenant is excluded from eligibility; healthy client gets the lead
signalType: 'site',
signalIdentifier: EF_DOMAIN,
dailyLimit: EF_HEALTHY_LIMIT,
regions: '{' . EF_MOSCOW_CODE . '}',
regions: '{'.EF_MOSCOW_CODE.'}',
);
createRoutingSnapshotFromProject(
project: $project2,
@@ -318,23 +319,23 @@ it('E2: frozen tenant is excluded from eligibility; healthy client gets the lead
signalType: 'site',
signalIdentifier: EF_DOMAIN,
dailyLimit: EF_HEALTHY_LIMIT,
regions: '{' . EF_MOSCOW_CODE . '}',
regions: '{'.EF_MOSCOW_CODE.'}',
);
$phone = '79161234002';
$fakeDaData = new FakeDaDataPhoneClient();
$fakeDaData = new FakeDaDataPhoneClient;
$fakeDaData->stub($phone, qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fakeDaData);
// ── ACT ──────────────────────────────────────────────────────────────────
$injector = new LeadInjector();
$injector = new LeadInjector;
$lead = $injector->site(
domain: EF_DOMAIN,
phone: $phone,
tag: 'Москва',
domain: EF_DOMAIN,
phone: $phone,
tag: 'Москва',
platform: EF_PLATFORM,
vid: 1_100_000_002,
vid: 1_100_000_002,
);
// ── ASSERT ───────────────────────────────────────────────────────────────
@@ -346,9 +347,9 @@ it('E2: frozen tenant is excluded from eligibility; healthy client gets the lead
->count();
expect($tenant1Deals)->toBe(0,
'FINDING E2: Frozen client (tenant1) received a deal. ' .
"Expected 0 deals for tenant1 (id={$tenant1->id}), got {$tenant1Deals}. " .
'LeadRouter SQL WHERE tenants.frozen_by_balance_at IS NULL should exclude this tenant. ' .
'FINDING E2: Frozen client (tenant1) received a deal. '.
"Expected 0 deals for tenant1 (id={$tenant1->id}), got {$tenant1Deals}. ".
'LeadRouter SQL WHERE tenants.frozen_by_balance_at IS NULL should exclude this tenant. '.
'This is a serious billing/freeze bug.'
);
@@ -359,15 +360,15 @@ it('E2: frozen tenant is excluded from eligibility; healthy client gets the lead
->count();
expect($tenant2Deals)->toBe(1,
'FINDING E2: Healthy Client2 did NOT receive the lead. ' .
"Expected 1 deal for tenant2 (id={$tenant2->id}), got {$tenant2Deals}. " .
'FINDING E2: Healthy Client2 did NOT receive the lead. '.
"Expected 1 deal for tenant2 (id={$tenant2->id}), got {$tenant2Deals}. ".
'With frozen Client1 excluded, Client2 should be the sole eligible recipient.'
);
// Verify tenant1 IS still frozen (no accidental unfreeze by any path).
$tenant1->refresh();
expect($tenant1->frozen_by_balance_at)->not->toBeNull(
'FINDING E2: tenant1.frozen_by_balance_at was cleared during routing. ' .
'FINDING E2: tenant1.frozen_by_balance_at was cleared during routing. '.
'Freeze state must be preserved across the routing cycle.'
);
@@ -385,54 +386,54 @@ it('F: client at daily limit is excluded; lead goes to client with remaining cap
// ── ARRANGE ──────────────────────────────────────────────────────────────
$supplier = SupplierProject::factory()->create([
'platform' => EF_PLATFORM,
'platform' => EF_PLATFORM,
'signal_type' => 'site',
'unique_key' => EF_DOMAIN,
'unique_key' => EF_DOMAIN,
]);
$dailyLimit = 5; // small limit so fillToLimit is clear
// Client1: delivered_today EQUAL to daily_limit → excluded (delivered_today < daily_limit is false).
$tenant1 = Tenant::factory()->create([
'balance_rub' => '9999.00',
'balance_rub' => '9999.00',
'frozen_by_balance_at' => null,
]);
$project1 = Project::factory()->create([
'tenant_id' => $tenant1->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => EF_DOMAIN,
'daily_limit_target' => $dailyLimit,
'tenant_id' => $tenant1->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => EF_DOMAIN,
'daily_limit_target' => $dailyLimit,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [EF_MOSCOW_CODE],
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [EF_MOSCOW_CODE],
]);
linkProjectToSupplier($project1, $supplier);
// Client2: healthy with headroom.
$tenant2 = Tenant::factory()->create([
'balance_rub' => '9999.00',
'balance_rub' => '9999.00',
'frozen_by_balance_at' => null,
]);
$project2 = Project::factory()->create([
'tenant_id' => $tenant2->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => EF_DOMAIN,
'daily_limit_target' => EF_HEALTHY_LIMIT,
'tenant_id' => $tenant2->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => EF_DOMAIN,
'daily_limit_target' => EF_HEALTHY_LIMIT,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [EF_MOSCOW_CODE],
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [EF_MOSCOW_CODE],
]);
linkProjectToSupplier($project2, $supplier);
$activeDate = \Tests\Support\Imitation\SnapshotForge::activeDate();
$activeDate = SnapshotForge::activeDate();
// Build snapshots BEFORE setting delivered_today to limit,
// so both clients appear in the snapshot.
@@ -442,7 +443,7 @@ it('F: client at daily limit is excluded; lead goes to client with remaining cap
signalType: 'site',
signalIdentifier: EF_DOMAIN,
dailyLimit: $dailyLimit,
regions: '{' . EF_MOSCOW_CODE . '}',
regions: '{'.EF_MOSCOW_CODE.'}',
);
createRoutingSnapshotFromProject(
project: $project2,
@@ -450,7 +451,7 @@ it('F: client at daily limit is excluded; lead goes to client with remaining cap
signalType: 'site',
signalIdentifier: EF_DOMAIN,
dailyLimit: EF_HEALTHY_LIMIT,
regions: '{' . EF_MOSCOW_CODE . '}',
regions: '{'.EF_MOSCOW_CODE.'}',
);
// NOW set Client1 delivered_today = limit (5) so it fails the eligibility check:
@@ -459,19 +460,19 @@ it('F: client at daily limit is excluded; lead goes to client with remaining cap
ConditionLevers::fillToLimit($project1);
$phone = '79161234003';
$fakeDaData = new FakeDaDataPhoneClient();
$fakeDaData = new FakeDaDataPhoneClient;
$fakeDaData->stub($phone, qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fakeDaData);
// ── ACT ──────────────────────────────────────────────────────────────────
$injector = new LeadInjector();
$injector = new LeadInjector;
$lead = $injector->site(
domain: EF_DOMAIN,
phone: $phone,
tag: 'Москва',
domain: EF_DOMAIN,
phone: $phone,
tag: 'Москва',
platform: EF_PLATFORM,
vid: 1_100_000_003,
vid: 1_100_000_003,
);
// ── ASSERT ───────────────────────────────────────────────────────────────
@@ -483,9 +484,9 @@ it('F: client at daily limit is excluded; lead goes to client with remaining cap
->count();
expect($tenant1Deals)->toBe(0,
'FINDING F: Client1 (at daily limit) received a deal. ' .
"Expected 0 deals for tenant1 (id={$tenant1->id}), got {$tenant1Deals}. " .
"LeadRouter SQL: projects.delivered_today < snap.daily_limit excludes at-limit projects. " .
'FINDING F: Client1 (at daily limit) received a deal. '.
"Expected 0 deals for tenant1 (id={$tenant1->id}), got {$tenant1Deals}. ".
'LeadRouter SQL: projects.delivered_today < snap.daily_limit excludes at-limit projects. '.
"project1.daily_limit_target={$dailyLimit}, delivered_today should be {$dailyLimit} after fillToLimit."
);
@@ -496,25 +497,25 @@ it('F: client at daily limit is excluded; lead goes to client with remaining cap
->count();
expect($tenant2Deals)->toBe(1,
'FINDING F: Client2 (with headroom) did NOT receive the lead. ' .
"Expected 1 deal for tenant2 (id={$tenant2->id}), got {$tenant2Deals}. " .
'FINDING F: Client2 (with headroom) did NOT receive the lead. '.
"Expected 1 deal for tenant2 (id={$tenant2->id}), got {$tenant2Deals}. ".
'With Client1 at limit and excluded, Client2 should be the sole eligible recipient.'
);
// Verify project1 delivered_today is still at limit (nothing was delivered to it).
$project1->refresh();
expect((int) $project1->delivered_today)->toBe($dailyLimit,
'FINDING F: project1.delivered_today changed during routing. ' .
"Expected {$dailyLimit} (untouched), got {$project1->delivered_today}. " .
'FINDING F: project1.delivered_today changed during routing. '.
"Expected {$dailyLimit} (untouched), got {$project1->delivered_today}. ".
'A lead was delivered to a project that exceeded its limit.'
);
// project1 is_active should still be true — limit exhaustion alone does NOT trigger
// auto-pause (only InsufficientBalance does). This is a deliberate design check.
expect($project1->is_active)->toBeTrue(
'FINDING F: project1.is_active was set to false due to limit exhaustion. ' .
'DESIGN NOTE: daily-limit exhaustion alone must NOT trigger auto-pause. ' .
'Auto-pause (is_active=false) is only triggered by InsufficientBalance (billing failure). ' .
'FINDING F: project1.is_active was set to false due to limit exhaustion. '.
'DESIGN NOTE: daily-limit exhaustion alone must NOT trigger auto-pause. '.
'Auto-pause (is_active=false) is only triggered by InsufficientBalance (billing failure). '.
'If this fails: auto-pause is being triggered by the wrong condition — report as bug.'
);
@@ -2,7 +2,6 @@
declare(strict_types=1);
use App\Models\Deal;
use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
@@ -51,7 +50,6 @@ uses(DatabaseTransactions::class, SharesSupplierPdo::class);
* - NO exception (job не упал);
* - Непроданный лид виден в supplier_leads.
*/
const G3_MOSCOW_CODE = 82;
const G3_SUPPLIER_DOMAIN = 'scenario-g3-orphan.ru';
const G3_SUPPLIER_PLATFORM = 'B1';
@@ -70,7 +68,7 @@ beforeEach(function (): void {
'services.dadata.daily_cap_rub' => 1_000_000,
]);
$fakeDaData = new FakeDaDataPhoneClient();
$fakeDaData = new FakeDaDataPhoneClient;
$fakeDaData->stub(G3_LEAD_PHONE, qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fakeDaData);
});
@@ -80,9 +78,9 @@ it('orphan lead: no deals created, processed_at set, no exception, no money move
// One shared SupplierProject (B1 site signal).
$supplier = SupplierProject::factory()->create([
'platform' => G3_SUPPLIER_PLATFORM,
'platform' => G3_SUPPLIER_PLATFORM,
'signal_type' => 'site',
'unique_key' => G3_SUPPLIER_DOMAIN,
'unique_key' => G3_SUPPLIER_DOMAIN,
]);
$activeDate = SnapshotForge::activeDate();
@@ -90,21 +88,21 @@ it('orphan lead: no deals created, processed_at set, no exception, no money move
// ── Client P1: limit exhausted — fillToLimit makes delivered_today = daily_limit_target.
// queryCandidates: projects.delivered_today < snap.daily_limit → FALSE → excluded from all phases.
$tenantP1 = Tenant::factory()->create([
'balance_rub' => '999.00',
'balance_rub' => '999.00',
'frozen_by_balance_at' => null,
]);
$projectP1 = Project::factory()->create([
'tenant_id' => $tenantP1->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => G3_SUPPLIER_DOMAIN,
'daily_limit_target' => G3_DAILY_LIMIT,
'tenant_id' => $tenantP1->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => G3_SUPPLIER_DOMAIN,
'daily_limit_target' => G3_DAILY_LIMIT,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [G3_MOSCOW_CODE],
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [G3_MOSCOW_CODE],
]);
linkProjectToSupplier($projectP1, $supplier);
createRoutingSnapshotFromProject(
@@ -113,7 +111,7 @@ it('orphan lead: no deals created, processed_at set, no exception, no money move
signalType: 'site',
signalIdentifier: G3_SUPPLIER_DOMAIN,
dailyLimit: G3_DAILY_LIMIT,
regions: '{' . G3_MOSCOW_CODE . '}',
regions: '{'.G3_MOSCOW_CODE.'}',
);
// Exhaust the limit: delivered_today = G3_DAILY_LIMIT → SQL condition false in all phases.
ConditionLevers::fillToLimit($projectP1);
@@ -121,21 +119,21 @@ it('orphan lead: no deals created, processed_at set, no exception, no money move
// ── Client P2: tenant frozen (frozen_by_balance_at IS NOT NULL).
// queryCandidates: WHERE tenants.frozen_by_balance_at IS NULL → FALSE → excluded from all phases.
$tenantP2 = Tenant::factory()->create([
'balance_rub' => '999.00',
'balance_rub' => '999.00',
'frozen_by_balance_at' => null,
]);
$projectP2 = Project::factory()->create([
'tenant_id' => $tenantP2->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => G3_SUPPLIER_DOMAIN,
'daily_limit_target' => G3_DAILY_LIMIT,
'tenant_id' => $tenantP2->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => G3_SUPPLIER_DOMAIN,
'daily_limit_target' => G3_DAILY_LIMIT,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [G3_MOSCOW_CODE],
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [G3_MOSCOW_CODE],
]);
linkProjectToSupplier($projectP2, $supplier);
createRoutingSnapshotFromProject(
@@ -144,7 +142,7 @@ it('orphan lead: no deals created, processed_at set, no exception, no money move
signalType: 'site',
signalIdentifier: G3_SUPPLIER_DOMAIN,
dailyLimit: G3_DAILY_LIMIT,
regions: '{' . G3_MOSCOW_CODE . '}',
regions: '{'.G3_MOSCOW_CODE.'}',
);
// Freeze the tenant: frozen_by_balance_at IS NOT NULL → excluded from all phases.
ConditionLevers::freeze($tenantP2);
@@ -152,21 +150,21 @@ it('orphan lead: no deals created, processed_at set, no exception, no money move
// ── Client P3: zero balance (balance_rub = 0).
// queryCandidates: WHERE tenants.balance_rub > 0 → FALSE → excluded from all phases.
$tenantP3 = Tenant::factory()->create([
'balance_rub' => '999.00',
'balance_rub' => '999.00',
'frozen_by_balance_at' => null,
]);
$projectP3 = Project::factory()->create([
'tenant_id' => $tenantP3->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => G3_SUPPLIER_DOMAIN,
'daily_limit_target' => G3_DAILY_LIMIT,
'tenant_id' => $tenantP3->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => G3_SUPPLIER_DOMAIN,
'daily_limit_target' => G3_DAILY_LIMIT,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [G3_MOSCOW_CODE],
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [G3_MOSCOW_CODE],
]);
linkProjectToSupplier($projectP3, $supplier);
createRoutingSnapshotFromProject(
@@ -175,14 +173,14 @@ it('orphan lead: no deals created, processed_at set, no exception, no money move
signalType: 'site',
signalIdentifier: G3_SUPPLIER_DOMAIN,
dailyLimit: G3_DAILY_LIMIT,
regions: '{' . G3_MOSCOW_CODE . '}',
regions: '{'.G3_MOSCOW_CODE.'}',
);
// Drain balance: balance_rub = 0 → balance_rub > 0 condition fails in all phases.
ConditionLevers::drainBalance($tenantP3);
// Record counts BEFORE injection to detect any pre-existing rows.
$dealsCountBefore = DB::connection('pgsql_supplier')->table('deals')->count();
$chargesCountBefore = DB::connection('pgsql_supplier')->table('lead_charges')->count();
$dealsCountBefore = DB::connection('pgsql_supplier')->table('deals')->count();
$chargesCountBefore = DB::connection('pgsql_supplier')->table('lead_charges')->count();
$balanceTxCountBefore = DB::connection('pgsql_supplier')->table('balance_transactions')->count();
// ── ACT — inject one lead and run the job synchronously ─────────────────────
@@ -191,15 +189,15 @@ it('orphan lead: no deals created, processed_at set, no exception, no money move
$injectedLead = null;
try {
$injector = new LeadInjector();
$injector = new LeadInjector;
$injectedLead = $injector->site(
domain: G3_SUPPLIER_DOMAIN,
phone: G3_LEAD_PHONE,
tag: 'Москва',
domain: G3_SUPPLIER_DOMAIN,
phone: G3_LEAD_PHONE,
tag: 'Москва',
platform: G3_SUPPLIER_PLATFORM,
vid: 8_888_000_001,
vid: 8_888_000_001,
);
} catch (\Throwable $e) {
} catch (Throwable $e) {
$thrownException = $e;
}
@@ -207,8 +205,8 @@ it('orphan lead: no deals created, processed_at set, no exception, no money move
// 1. No exception bubbled — the job must complete cleanly.
expect($thrownException)->toBeNull(
'FINDING: RouteSupplierLeadJob threw an exception for an orphan lead. ' .
'Expected: no exception. Got: ' . ($thrownException?->getMessage() ?? 'none')
'FINDING: RouteSupplierLeadJob threw an exception for an orphan lead. '.
'Expected: no exception. Got: '.($thrownException?->getMessage() ?? 'none')
);
// 2. The SupplierLead was created and is accessible.
@@ -220,43 +218,43 @@ it('orphan lead: no deals created, processed_at set, no exception, no money move
/** @var SupplierLead $freshLead */
$freshLead = SupplierLead::find($injectedLead->id);
expect($freshLead)->not->toBeNull(
'FINDING: SupplierLead id=' . $injectedLead->id . ' not found in DB after injection.'
'FINDING: SupplierLead id='.$injectedLead->id.' not found in DB after injection.'
);
// 3. processed_at IS set — job stamped the lead as "processed" even though nobody received it.
// WHERE: supplier_leads table, column processed_at — this is WHERE the orphan lead "rests".
expect($freshLead->processed_at)->not->toBeNull(
'FINDING: SupplierLead.processed_at is NULL after routing with no eligible clients. ' .
'Expected: RouteSupplierLeadJob always sets processed_at=now() at step 6, ' .
'even when deals_created_count=0. The orphan lead should rest in supplier_leads ' .
'FINDING: SupplierLead.processed_at is NULL after routing with no eligible clients. '.
'Expected: RouteSupplierLeadJob always sets processed_at=now() at step 6, '.
'even when deals_created_count=0. The orphan lead should rest in supplier_leads '.
'with processed_at set (idempotency guard).'
);
// 4. deals_created_count = 0 — no deals were created.
expect((int) $freshLead->deals_created_count)->toBe(0,
'FINDING: SupplierLead.deals_created_count expected 0 but got ' .
$freshLead->deals_created_count . '. ' .
'FINDING: SupplierLead.deals_created_count expected 0 but got '.
$freshLead->deals_created_count.'. '.
'No eligible project was found — no deal should have been created.'
);
// 5. No deals created across all three tenants.
$newDealsCount = DB::connection('pgsql_supplier')->table('deals')->count() - $dealsCountBefore;
expect($newDealsCount)->toBe(0,
'FINDING: ' . $newDealsCount . ' deal(s) were created despite all clients being ineligible. ' .
'FINDING: '.$newDealsCount.' deal(s) were created despite all clients being ineligible. '.
'P1(limit-exhausted), P2(frozen), P3(zero-balance) should all fail LeadRouter SQL filter.'
);
// 6. No lead_charges created — no money moved.
$newChargesCount = DB::connection('pgsql_supplier')->table('lead_charges')->count() - $chargesCountBefore;
expect($newChargesCount)->toBe(0,
'FINDING: ' . $newChargesCount . ' lead_charge row(s) created despite no eligible clients. ' .
'FINDING: '.$newChargesCount.' lead_charge row(s) created despite no eligible clients. '.
'LedgerService::chargeForDelivery should not have been called.'
);
// 7. No balance_transactions created — balances untouched.
$newBalanceTxCount = DB::connection('pgsql_supplier')->table('balance_transactions')->count() - $balanceTxCountBefore;
expect($newBalanceTxCount)->toBe(0,
'FINDING: ' . $newBalanceTxCount . ' balance_transaction(s) created despite no eligible clients.'
'FINDING: '.$newBalanceTxCount.' balance_transaction(s) created despite no eligible clients.'
);
// 8. The orphan lead is visible/recorded — WHERE it rests.
@@ -267,25 +265,25 @@ it('orphan lead: no deals created, processed_at set, no exception, no money move
->where('deals_created_count', 0)
->count();
expect($orphanCount)->toBe(1,
'FINDING: Orphan lead not found in supplier_leads with processed_at IS NOT NULL and ' .
'deals_created_count=0. The unsold lead should rest in supplier_leads, identifiable by ' .
'FINDING: Orphan lead not found in supplier_leads with processed_at IS NOT NULL and '.
'deals_created_count=0. The unsold lead should rest in supplier_leads, identifiable by '.
'processed_at IS NOT NULL + deals_created_count = 0 (no error column set).'
);
// ── REPORT ──────────────────────────────────────────────────────────────────
fwrite(STDOUT, PHP_EOL . '=== SCENARIO G3 ORPHAN LEAD REPORT ===' . PHP_EOL);
fwrite(STDOUT, 'SupplierLead id: ' . $freshLead->id . PHP_EOL);
fwrite(STDOUT, 'processed_at: ' . ($freshLead->processed_at?->toIso8601String() ?? 'NULL') . PHP_EOL);
fwrite(STDOUT, 'deals_created_count: ' . $freshLead->deals_created_count . PHP_EOL);
fwrite(STDOUT, 'error: ' . ($freshLead->error ?? 'NULL (no error)') . PHP_EOL);
fwrite(STDOUT, 'deals created (new): ' . $newDealsCount . PHP_EOL);
fwrite(STDOUT, 'lead_charges (new): ' . $newChargesCount . PHP_EOL);
fwrite(STDOUT, 'balance_transactions(new):' . $newBalanceTxCount . PHP_EOL);
fwrite(STDOUT, PHP_EOL . 'WHERE the orphan lead rests:' . PHP_EOL);
fwrite(STDOUT, ' Table: supplier_leads' . PHP_EOL);
fwrite(STDOUT, ' Filter: processed_at IS NOT NULL AND deals_created_count = 0' . PHP_EOL);
fwrite(STDOUT, ' Note: error column is NULL (clean completion, not a failure).' . PHP_EOL);
fwrite(STDOUT, ' Note: NO entry in failed_webhook_jobs (job::failed() not called).' . PHP_EOL);
fwrite(STDOUT, '=== END G3 REPORT ===' . PHP_EOL . PHP_EOL);
fwrite(STDOUT, PHP_EOL.'=== SCENARIO G3 ORPHAN LEAD REPORT ==='.PHP_EOL);
fwrite(STDOUT, 'SupplierLead id: '.$freshLead->id.PHP_EOL);
fwrite(STDOUT, 'processed_at: '.($freshLead->processed_at?->toIso8601String() ?? 'NULL').PHP_EOL);
fwrite(STDOUT, 'deals_created_count: '.$freshLead->deals_created_count.PHP_EOL);
fwrite(STDOUT, 'error: '.($freshLead->error ?? 'NULL (no error)').PHP_EOL);
fwrite(STDOUT, 'deals created (new): '.$newDealsCount.PHP_EOL);
fwrite(STDOUT, 'lead_charges (new): '.$newChargesCount.PHP_EOL);
fwrite(STDOUT, 'balance_transactions(new):'.$newBalanceTxCount.PHP_EOL);
fwrite(STDOUT, PHP_EOL.'WHERE the orphan lead rests:'.PHP_EOL);
fwrite(STDOUT, ' Table: supplier_leads'.PHP_EOL);
fwrite(STDOUT, ' Filter: processed_at IS NOT NULL AND deals_created_count = 0'.PHP_EOL);
fwrite(STDOUT, ' Note: error column is NULL (clean completion, not a failure).'.PHP_EOL);
fwrite(STDOUT, ' Note: NO entry in failed_webhook_jobs (job::failed() not called).'.PHP_EOL);
fwrite(STDOUT, '=== END G3 REPORT ==='.PHP_EOL.PHP_EOL);
})->group('imitation');
@@ -46,12 +46,14 @@ declare(strict_types=1);
* { "status": "accepted", "supplier_lead_id": <int> }
*/
use App\Jobs\RouteSupplierLeadJob;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\SystemSetting;
use App\Services\DaData\DaDataPhoneClient;
use App\Services\LeadRegionResolver;
use App\Support\RussianRegions;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\DB;
@@ -82,7 +84,7 @@ beforeEach(function (): void {
// The main project's .env has a real key; phpunit.xml in this worktree has no APP_KEY.
// This fix is scoped to this file's tests only (config() changes are per-request).
if (strlen(base64_decode(str_replace('base64:', '', config('app.key'))) ?: '') !== 32) {
config(['app.key' => 'base64:' . base64_encode(str_repeat('a', 32))]);
config(['app.key' => 'base64:'.base64_encode(str_repeat('a', 32))]);
app('encrypter')->__construct(
str_repeat('a', 32),
config('app.cipher', 'AES-256-CBC')
@@ -102,8 +104,8 @@ beforeEach(function (): void {
// Seed pricing tiers (required by RouteSupplierLeadJob/LedgerService path).
try {
(new \Database\Seeders\PricingTierSeeder())->run();
} catch (\Throwable) {
(new PricingTierSeeder)->run();
} catch (Throwable) {
// Already seeded or not needed for this specific test.
}
});
@@ -117,8 +119,8 @@ function makeG5Lead(string $phone, string $tag = ''): SupplierLead
return SupplierLead::factory()->create([
'supplier_project_id' => $sp->id,
'phone' => $phone,
'raw_payload' => ['tag' => $tag],
'phone' => $phone,
'raw_payload' => ['tag' => $tag],
]);
}
@@ -128,25 +130,25 @@ function makeG5Lead(string $phone, string $tag = ''): SupplierLead
function insertG5PhoneRange(int $defCode, int $from, int $to, int $subjectCode): void
{
$importId = DB::table('phone_ranges_imports')->insertGetId([
'imported_at' => now(),
'source_url' => 'test://rossvyaz-g5g6',
'rows_inserted' => 1,
'rows_updated' => 0,
'imported_at' => now(),
'source_url' => 'test://rossvyaz-g5g6',
'rows_inserted' => 1,
'rows_updated' => 0,
'checksum_sha256' => hash('sha256', "g5g6-{$defCode}-{$from}-{$to}-{$subjectCode}"),
'status' => 'completed',
'completed_at' => now(),
'status' => 'completed',
'completed_at' => now(),
]);
DB::table('phone_ranges')->insert([
'def_code' => $defCode,
'from_num' => $from,
'to_num' => $to,
'operator' => 'test-operator',
'region' => RussianRegions::CODE_TO_NAME[$subjectCode] ?? 'test-region',
'def_code' => $defCode,
'from_num' => $from,
'to_num' => $to,
'operator' => 'test-operator',
'region' => RussianRegions::CODE_TO_NAME[$subjectCode] ?? 'test-region',
'region_normalized' => null,
'subject_code' => $subjectCode,
'imported_at' => now(),
'import_id' => $importId,
'subject_code' => $subjectCode,
'imported_at' => now(),
'import_id' => $importId,
]);
}
@@ -162,11 +164,11 @@ it('G5a: qc=2 + empty tag → source=unknown (FINDING: plan says tag, actual is
// We assert REAL behaviour.
config(['services.dadata.enabled' => true]);
$fake = (new FakeDaDataPhoneClient())->stub('79990000201', qc: 2, region: null, provider: null);
$fake = (new FakeDaDataPhoneClient)->stub('79990000201', qc: 2, region: null, provider: null);
app()->instance(DaDataPhoneClient::class, $fake);
$lead = makeG5Lead('79990000201', tag: '');
$res = app(LeadRegionResolver::class)->resolve($lead);
$res = app(LeadRegionResolver::class)->resolve($lead);
// REAL behaviour: empty tag → tagCode=null → source='unknown', NOT 'tag'
expect($res->source)->toBe('unknown')
@@ -180,12 +182,12 @@ it('G5a: qc=2 + valid tag → source=tag (Россвязь still skipped)', func
// Россвязь is never consulted for qc=2 (the code path jumps to tagFallback).
config(['services.dadata.enabled' => true]);
$fake = (new FakeDaDataPhoneClient())->stub('79990000202', qc: 2, region: null, provider: null);
$fake = (new FakeDaDataPhoneClient)->stub('79990000202', qc: 2, region: null, provider: null);
app()->instance(DaDataPhoneClient::class, $fake);
$moscowCode = RussianRegions::nameToCode()['Москва'];
$lead = makeG5Lead('79990000202', tag: 'Москва');
$res = app(LeadRegionResolver::class)->resolve($lead);
$res = app(LeadRegionResolver::class)->resolve($lead);
expect($res->source)->toBe('tag')
->and($res->subjectCode)->toBe($moscowCode)
@@ -197,11 +199,11 @@ it('G5a: qc=7 + empty tag → source=unknown (same as qc=2, Россвязь ski
// qc=7 (иностранец) behaves identically to qc=2: goes straight to tagFallback().
config(['services.dadata.enabled' => true]);
$fake = (new FakeDaDataPhoneClient())->stub('79990000203', qc: 7, region: null, provider: null);
$fake = (new FakeDaDataPhoneClient)->stub('79990000203', qc: 7, region: null, provider: null);
app()->instance(DaDataPhoneClient::class, $fake);
$lead = makeG5Lead('79990000203', tag: '');
$res = app(LeadRegionResolver::class)->resolve($lead);
$res = app(LeadRegionResolver::class)->resolve($lead);
expect($res->source)->toBe('unknown')
->and($res->subjectCode)->toBeNull()
@@ -224,11 +226,11 @@ it('G5b: DaData throws DaDataException + seeded phone range → source=rossvyaz,
$tyumenCode = RussianRegions::nameToCode()['Тюменская область']; // ordinal 77
insertG5PhoneRange(defCode: 988, from: 5550000, to: 5559999, subjectCode: $tyumenCode);
$fake = (new FakeDaDataPhoneClient())->stubThrows('79885550211');
$fake = (new FakeDaDataPhoneClient)->stubThrows('79885550211');
app()->instance(DaDataPhoneClient::class, $fake);
$lead = makeG5Lead('79885550211', tag: '');
$res = app(LeadRegionResolver::class)->resolve($lead);
$res = app(LeadRegionResolver::class)->resolve($lead);
expect($res->source)->toBe('rossvyaz')
->and($res->rossvyazMatched)->toBeTrue()
@@ -245,11 +247,11 @@ it('G5b: qc=1 (не уточнён) + seeded phone range → source=rossvyaz, ro
$voronezCode = RussianRegions::nameToCode()['Воронежская область']; // ordinal 42
insertG5PhoneRange(defCode: 988, from: 6660000, to: 6669999, subjectCode: $voronezCode);
$fake = (new FakeDaDataPhoneClient())->stub('79886660311', qc: 1, region: null, provider: null);
$fake = (new FakeDaDataPhoneClient)->stub('79886660311', qc: 1, region: null, provider: null);
app()->instance(DaDataPhoneClient::class, $fake);
$lead = makeG5Lead('79886660311', tag: '');
$res = app(LeadRegionResolver::class)->resolve($lead);
$res = app(LeadRegionResolver::class)->resolve($lead);
expect($res->source)->toBe('rossvyaz')
->and($res->rossvyazMatched)->toBeTrue()
@@ -268,11 +270,11 @@ it('G5c: qc=2 + no phone range seeded + empty tag → source=unknown', function
config(['services.dadata.enabled' => true]);
// No phone_ranges seeded for this phone.
$fake = (new FakeDaDataPhoneClient())->stub('79990000304', qc: 2, region: null, provider: null);
$fake = (new FakeDaDataPhoneClient)->stub('79990000304', qc: 2, region: null, provider: null);
app()->instance(DaDataPhoneClient::class, $fake);
$lead = makeG5Lead('79990000304', tag: '');
$res = app(LeadRegionResolver::class)->resolve($lead);
$res = app(LeadRegionResolver::class)->resolve($lead);
expect($res->source)->toBe('unknown')
->and($res->subjectCode)->toBeNull()
@@ -285,11 +287,11 @@ it('G5c: DaData throws + no phone range seeded + empty tag → source=unknown',
config(['services.dadata.enabled' => true]);
// No phone_ranges seeded for this phone.
$fake = (new FakeDaDataPhoneClient())->stubThrows('79990000305');
$fake = (new FakeDaDataPhoneClient)->stubThrows('79990000305');
app()->instance(DaDataPhoneClient::class, $fake);
$lead = makeG5Lead('79990000305', tag: '');
$res = app(LeadRegionResolver::class)->resolve($lead);
$res = app(LeadRegionResolver::class)->resolve($lead);
expect($res->source)->toBe('unknown')
->and($res->subjectCode)->toBeNull()
@@ -317,16 +319,16 @@ it('G6: duplicate vid via HTTP → first 202 accepted, second 200 already_proces
$vid = 987654321; // Deterministic vid, outside auto-generated ranges from LeadInjector
$payload = [
'vid' => $vid,
'vid' => $vid,
'project' => 'B1_g6test.example.com',
'phone' => '79991112233',
'time' => time(),
'tag' => 'Москва',
'phone' => '79991112233',
'time' => time(),
'tag' => 'Москва',
];
// First request → should be accepted (202).
$first = $this->postJson(
'/api/webhook/supplier/' . G5G6_SECRET,
'/api/webhook/supplier/'.G5G6_SECRET,
$payload
);
@@ -340,7 +342,7 @@ it('G6: duplicate vid via HTTP → first 202 accepted, second 200 already_proces
// Second request — same vid, same payload → dedup path (200).
$second = $this->postJson(
'/api/webhook/supplier/' . G5G6_SECRET,
'/api/webhook/supplier/'.G5G6_SECRET,
$payload
);
@@ -349,7 +351,7 @@ it('G6: duplicate vid via HTTP → first 202 accepted, second 200 already_proces
// Exact response shape from controller lines 96-99:
// { "status": "already_processed", "supplier_lead_id": <existing id> }
$second->assertJson([
'status' => 'already_processed',
'status' => 'already_processed',
'supplier_lead_id' => $firstLeadId,
]);
@@ -358,7 +360,7 @@ it('G6: duplicate vid via HTTP → first 202 accepted, second 200 already_proces
// RouteSupplierLeadJob dispatched exactly once (for the first request).
// The second request returns early before any dispatch.
Bus::assertDispatchedTimes(\App\Jobs\RouteSupplierLeadJob::class, 1);
Bus::assertDispatchedTimes(RouteSupplierLeadJob::class, 1);
})->group('imitation');
it('G6: second request returns the SAME supplier_lead_id as the first', function (): void {
@@ -371,14 +373,14 @@ it('G6: second request returns the SAME supplier_lead_id as the first', function
$vid = 987654322; // Different deterministic vid from G6 test above
$payload = [
'vid' => $vid,
'vid' => $vid,
'project' => 'B1_g6test.example.com',
'phone' => '79991112244',
'time' => time(),
'phone' => '79991112244',
'time' => time(),
];
$first = $this->postJson('/api/webhook/supplier/' . G5G6_SECRET, $payload);
$second = $this->postJson('/api/webhook/supplier/' . G5G6_SECRET, $payload);
$first = $this->postJson('/api/webhook/supplier/'.G5G6_SECRET, $payload);
$second = $this->postJson('/api/webhook/supplier/'.G5G6_SECRET, $payload);
$first->assertStatus(202);
$second->assertStatus(200);
@@ -80,7 +80,7 @@ uses(DatabaseTransactions::class, SharesSupplierPdo::class);
// ── SUBJECT CODE CONSTANTS (порядковые, НЕ ГИБДД) ────────────────────────────
/** RussianRegions::CODE_TO_NAME[29] = 'Красноярский край' — real lead region */
const X1_LEAD_REGION = 29;
const X1_LEAD_REGION = 29;
/** RussianRegions::CODE_TO_NAME[37] = 'Белгородская область' — client's subscribed region */
const X1_CLIENT_REGION = 37;
/** RussianRegions::CODE_TO_NAME[82] = 'Москва' */
@@ -103,9 +103,9 @@ beforeEach(function (): void {
// DaData config defaults — individual tests override as needed.
config([
'services.dadata.enabled' => true,
'services.dadata.api_key' => 'fake-key',
'services.dadata.secret' => 'fake-secret',
'services.dadata.enabled' => true,
'services.dadata.api_key' => 'fake-key',
'services.dadata.secret' => 'fake-secret',
'services.dadata.daily_cap_rub' => 1_000_000,
]);
@@ -141,40 +141,40 @@ it('X1: step-3 fallback substitutes subject_code to client region, preserves rea
// One supplier (B1 site).
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => X1_DOMAIN,
'unique_key' => X1_DOMAIN,
]);
// One client: subscribed to R_client=37 (Белгородская обл.) ONLY.
// No all-RF client → phases 1+2 both empty → phase 3 fires.
$tenant = Tenant::factory()->create([
'balance_rub' => '99999.00',
'balance_rub' => '99999.00',
'frozen_by_balance_at' => null,
]);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => X1_DOMAIN,
'daily_limit_target' => 10,
'tenant_id' => $tenant->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => X1_DOMAIN,
'daily_limit_target' => 10,
'effective_daily_limit_today' => null,
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [X1_CLIENT_REGION], // {37}
'delivered_today' => 0,
'delivered_in_month' => 0,
'delivery_days_mask' => 127,
'preflight_blocked_at' => null,
'regions' => [X1_CLIENT_REGION], // {37}
]);
linkProjectToSupplier($project, $supplier);
$activeDate = SnapshotForge::activeDate();
createRoutingSnapshotFromProject(
project: $project,
date: $activeDate,
signalType: 'site',
project: $project,
date: $activeDate,
signalType: 'site',
signalIdentifier: X1_DOMAIN,
dailyLimit: 10,
regions: '{' . X1_CLIENT_REGION . '}', // '{37}'
dailyLimit: 10,
regions: '{'.X1_CLIENT_REGION.'}', // '{37}'
);
// Lead resolves to R_lead=29 (Красноярский край) via DaData qc=0.
@@ -183,19 +183,19 @@ it('X1: step-3 fallback substitutes subject_code to client region, preserves rea
$leadPhone = '79292900001';
$leadRegionName = RussianRegions::CODE_TO_NAME[X1_LEAD_REGION]; // 'Красноярский край'
$fakeDaData = new FakeDaDataPhoneClient();
$fakeDaData = new FakeDaDataPhoneClient;
$fakeDaData->stub($leadPhone, qc: 0, region: $leadRegionName, provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fakeDaData);
// ── ACT ───────────────────────────────────────────────────────────────────
$injector = new LeadInjector();
$injector = new LeadInjector;
$lead = $injector->site(
domain: X1_DOMAIN,
phone: $leadPhone,
tag: null,
domain: X1_DOMAIN,
phone: $leadPhone,
tag: null,
platform: 'B1',
vid: 9_120_001_001,
vid: 9_120_001_001,
);
// ── ASSERT ────────────────────────────────────────────────────────────────
@@ -212,51 +212,51 @@ it('X1: step-3 fallback substitutes subject_code to client region, preserves rea
->first();
// Diagnostic output.
fwrite(STDOUT, PHP_EOL . '=== X1 SUBSTITUTION ===' . PHP_EOL);
fwrite(STDOUT, "Lead phone: {$leadPhone}" . PHP_EOL);
fwrite(STDOUT, "R_lead (real resolved): " . X1_LEAD_REGION . " ({$leadRegionName})" . PHP_EOL);
fwrite(STDOUT, "R_client (client region): " . X1_CLIENT_REGION . " (" . RussianRegions::CODE_TO_NAME[X1_CLIENT_REGION] . ")" . PHP_EOL);
fwrite(STDOUT, "deal found: " . ($deal !== null ? 'YES' : 'NO') . PHP_EOL);
fwrite(STDOUT, PHP_EOL.'=== X1 SUBSTITUTION ==='.PHP_EOL);
fwrite(STDOUT, "Lead phone: {$leadPhone}".PHP_EOL);
fwrite(STDOUT, 'R_lead (real resolved): '.X1_LEAD_REGION." ({$leadRegionName})".PHP_EOL);
fwrite(STDOUT, 'R_client (client region): '.X1_CLIENT_REGION.' ('.RussianRegions::CODE_TO_NAME[X1_CLIENT_REGION].')'.PHP_EOL);
fwrite(STDOUT, 'deal found: '.($deal !== null ? 'YES' : 'NO').PHP_EOL);
if ($deal !== null) {
fwrite(STDOUT, "deals.subject_code: {$deal->subject_code}" . PHP_EOL);
fwrite(STDOUT, "deals.city: {$deal->city}" . PHP_EOL);
fwrite(STDOUT, "deals.region_substituted: {$deal->region_substituted}" . PHP_EOL);
fwrite(STDOUT, "deals.subject_code: {$deal->subject_code}".PHP_EOL);
fwrite(STDOUT, "deals.city: {$deal->city}".PHP_EOL);
fwrite(STDOUT, "deals.region_substituted: {$deal->region_substituted}".PHP_EOL);
}
if ($logRow !== null) {
fwrite(STDOUT, "log.routing_step: {$logRow->routing_step}" . PHP_EOL);
fwrite(STDOUT, "log.subject_code_resolved: {$logRow->subject_code_resolved}" . PHP_EOL);
fwrite(STDOUT, "log.actual_subject_code: {$logRow->actual_subject_code}" . PHP_EOL);
fwrite(STDOUT, "log.substituted_subject_code: {$logRow->substituted_subject_code}" . PHP_EOL);
fwrite(STDOUT, "log.region_source: {$logRow->region_source}" . PHP_EOL);
fwrite(STDOUT, "log.routing_step: {$logRow->routing_step}".PHP_EOL);
fwrite(STDOUT, "log.subject_code_resolved: {$logRow->subject_code_resolved}".PHP_EOL);
fwrite(STDOUT, "log.actual_subject_code: {$logRow->actual_subject_code}".PHP_EOL);
fwrite(STDOUT, "log.substituted_subject_code: {$logRow->substituted_subject_code}".PHP_EOL);
fwrite(STDOUT, "log.region_source: {$logRow->region_source}".PHP_EOL);
} else {
fwrite(STDOUT, "log row: NOT FOUND" . PHP_EOL);
fwrite(STDOUT, 'log row: NOT FOUND'.PHP_EOL);
}
fwrite(STDOUT, '=== END X1 ===' . PHP_EOL . PHP_EOL);
fwrite(STDOUT, '=== END X1 ==='.PHP_EOL.PHP_EOL);
// A deal must have been created.
expect($deal)->not->toBeNull(
'FINDING: No deal was created for the tenant. ' .
'The phase-3 fallback (any region) may not be reaching this client, ' .
'FINDING: No deal was created for the tenant. '.
'The phase-3 fallback (any region) may not be reaching this client, '.
'or the snapshot is missing.'
);
if ($deal !== null) {
// deals.subject_code must be R_client (substituted to client's region on step 3).
expect((int) $deal->subject_code)->toBe(X1_CLIENT_REGION,
'FINDING: deals.subject_code should be ' . X1_CLIENT_REGION . ' (R_client, Белгородская обл.) ' .
'because routing_step=3 substitutes subject_code to the first code in snapshot.regions. ' .
'Got: ' . $deal->subject_code . '. ' .
'If got R_lead=' . X1_LEAD_REGION . ': substitution is not firing (routingStep capture or pickSubstituteRegion failed). ' .
'FINDING: deals.subject_code should be '.X1_CLIENT_REGION.' (R_client, Белгородская обл.) '.
'because routing_step=3 substitutes subject_code to the first code in snapshot.regions. '.
'Got: '.$deal->subject_code.'. '.
'If got R_lead='.X1_LEAD_REGION.': substitution is not firing (routingStep capture or pickSubstituteRegion failed). '.
'If got null: snapshot.regions may not be picked up correctly.'
);
// deals.city must be the name of R_lead (real resolved region), NOT R_client.
// Code §3.10 comment: «Город» = человекочитаемое имя НАСТОЯЩЕГО региона лида.
expect($deal->city)->toBe($leadRegionName,
'FINDING: deals.city should be "' . $leadRegionName . '" (name of R_lead=' . X1_LEAD_REGION . ', real lead region). ' .
'Got: "' . $deal->city . '". ' .
'city is ALWAYS set from $resolution->subjectCode name, NOT from $dealSubjectCode. ' .
'If city = "' . RussianRegions::CODE_TO_NAME[X1_CLIENT_REGION] . '": ' .
'FINDING: deals.city should be "'.$leadRegionName.'" (name of R_lead='.X1_LEAD_REGION.', real lead region). '.
'Got: "'.$deal->city.'". '.
'city is ALWAYS set from $resolution->subjectCode name, NOT from $dealSubjectCode. '.
'If city = "'.RussianRegions::CODE_TO_NAME[X1_CLIENT_REGION].'": '.
'prod code erroneously uses $dealSubjectCode for city.'
);
@@ -269,62 +269,61 @@ it('X1: step-3 fallback substitutes subject_code to client region, preserves rea
}
expect($regionSubstituted)->toBeTrue(
'FINDING: deals.region_substituted should be TRUE on routing_step=3. ' .
'Got raw value: "' . $deal->region_substituted . '". ' .
'FINDING: deals.region_substituted should be TRUE on routing_step=3. '.
'Got raw value: "'.$deal->region_substituted.'". '.
'Check RouteSupplierLeadJob line: \'region_substituted\' => $routingStep === 3.'
);
}
// lead_region_resolution_log must have a row.
expect($logRow)->not->toBeNull(
'FINDING: lead_region_resolution_log has no row for this lead. ' .
'logRegionResolution() may have failed silently (fail-safe wrapper suppresses exceptions). ' .
'FINDING: lead_region_resolution_log has no row for this lead. '.
'logRegionResolution() may have failed silently (fail-safe wrapper suppresses exceptions). '.
'Check for partition missing (received_at date not matching any partition).'
);
if ($logRow !== null) {
// routing_step = 3.
expect((int) $logRow->routing_step)->toBe(3,
'FINDING: log.routing_step should be 3 (phase-3 fallback). ' .
'Got: ' . $logRow->routing_step . '. ' .
'If 1: exact match fired (snapshot.regions may be wrong). ' .
'FINDING: log.routing_step should be 3 (phase-3 fallback). '.
'Got: '.$logRow->routing_step.'. '.
'If 1: exact match fired (snapshot.regions may be wrong). '.
'If 2: all-RF match fired (client has regions=\'{}\' or snapshot is wrong).'
);
// subject_code_resolved = R_lead (the actual resolved code, not substituted).
expect((int) $logRow->subject_code_resolved)->toBe(X1_LEAD_REGION,
'FINDING: log.subject_code_resolved should be ' . X1_LEAD_REGION . ' (R_lead, real resolved). ' .
'Got: ' . $logRow->subject_code_resolved . '.'
'FINDING: log.subject_code_resolved should be '.X1_LEAD_REGION.' (R_lead, real resolved). '.
'Got: '.$logRow->subject_code_resolved.'.'
);
// actual_subject_code = R_lead.
// RegionResolution.actualSubjectCode = subjectCode at construction time (real resolved).
expect((int) $logRow->actual_subject_code)->toBe(X1_LEAD_REGION,
'FINDING: log.actual_subject_code should be ' . X1_LEAD_REGION . ' (R_lead, real lead region). ' .
'Got: ' . $logRow->actual_subject_code . '. ' .
'FINDING: log.actual_subject_code should be '.X1_LEAD_REGION.' (R_lead, real lead region). '.
'Got: '.$logRow->actual_subject_code.'. '.
'actualSubjectCode is set equal to subjectCode in RegionResolution::make().'
);
// substituted_subject_code = R_client (the first code from snapshot.regions).
// pickSubstituteRegion('{37}') → 37 = X1_CLIENT_REGION.
expect($logRow->substituted_subject_code)->not->toBeNull(
'FINDING: log.substituted_subject_code should be ' . X1_CLIENT_REGION . ' (R_client) on step 3. ' .
'Got null. ' .
'logRegionResolution() computes substituted only when routingStep===3 AND $first!==null. ' .
'FINDING: log.substituted_subject_code should be '.X1_CLIENT_REGION.' (R_client) on step 3. '.
'Got null. '.
'logRegionResolution() computes substituted only when routingStep===3 AND $first!==null. '.
'Check that $first->snapshot_regions attribute is present (set by LeadRouter SQL SELECT).'
);
if ($logRow->substituted_subject_code !== null) {
expect((int) $logRow->substituted_subject_code)->toBe(X1_CLIENT_REGION,
'FINDING: log.substituted_subject_code should be ' . X1_CLIENT_REGION . ' (R_client=Белгородская обл.). ' .
'Got: ' . $logRow->substituted_subject_code . '. ' .
'FINDING: log.substituted_subject_code should be '.X1_CLIENT_REGION.' (R_client=Белгородская обл.). '.
'Got: '.$logRow->substituted_subject_code.'. '.
'pickSubstituteRegion() parses PG INT[]-literal \'{37}\' → [37] → first=37.'
);
}
}
})->group('imitation');
// ══════════════════════════════════════════════════════════════════════════════
// SCENARIO X3 — region_source breakdown (dadata / rossvyaz / tag / unknown)
// ══════════════════════════════════════════════════════════════════════════════
@@ -368,9 +367,9 @@ it('X3: leads with dadata/rossvyaz/tag/unknown sources produce correct region_so
// This means RouteSupplierLeadJob still runs LeadRegionResolver, updates
// supplier_leads.region_source, then calls logRegionResolution (with empty selected).
$supplier = SupplierProject::factory()->create([
'platform' => 'B1',
'platform' => 'B1',
'signal_type' => 'site',
'unique_key' => X3_DOMAIN,
'unique_key' => X3_DOMAIN,
]);
// ── Lead 1: source = 'dadata' ──────────────────────────────────────────────
@@ -378,13 +377,13 @@ it('X3: leads with dadata/rossvyaz/tag/unknown sources produce correct region_so
$phone1 = '79310000001';
$region1Name = RussianRegions::CODE_TO_NAME[X1_MOSCOW]; // 'Москва'
$fake1 = new FakeDaDataPhoneClient();
$fake1 = new FakeDaDataPhoneClient;
$fake1->stub($phone1, qc: 0, region: $region1Name, provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fake1);
config(['services.dadata.enabled' => true]);
$injector = new LeadInjector();
$injector = new LeadInjector;
$lead1 = $injector->site(domain: X3_DOMAIN, phone: $phone1, tag: null, platform: 'B1', vid: 9_130_003_001);
// ── Lead 2: source = 'rossvyaz' ────────────────────────────────────────────
@@ -394,27 +393,27 @@ it('X3: leads with dadata/rossvyaz/tag/unknown sources produce correct region_so
$phone2 = '79310000002';
// DEF=931, subscriber=0000002. seed range from=0 to=9999999 covering it.
$importId2 = DB::table('phone_ranges_imports')->insertGetId([
'imported_at' => now(),
'source_url' => 'test://x3-rossvyaz',
'rows_inserted' => 1,
'rows_updated' => 0,
'imported_at' => now(),
'source_url' => 'test://x3-rossvyaz',
'rows_inserted' => 1,
'rows_updated' => 0,
'checksum_sha256' => hash('sha256', 'x3-rossvyaz-931'),
'status' => 'completed',
'completed_at' => now(),
'status' => 'completed',
'completed_at' => now(),
]);
DB::table('phone_ranges')->insert([
'def_code' => 931,
'from_num' => 0,
'to_num' => 9999999,
'operator' => 'test-op-x3',
'region' => RussianRegions::CODE_TO_NAME[X1_MOSCOW],
'def_code' => 931,
'from_num' => 0,
'to_num' => 9999999,
'operator' => 'test-op-x3',
'region' => RussianRegions::CODE_TO_NAME[X1_MOSCOW],
'region_normalized' => null,
'subject_code' => X1_MOSCOW,
'imported_at' => now(),
'import_id' => $importId2,
'subject_code' => X1_MOSCOW,
'imported_at' => now(),
'import_id' => $importId2,
]);
$fake2 = new FakeDaDataPhoneClient();
$fake2 = new FakeDaDataPhoneClient;
$fake2->stubThrows($phone2); // DaData throws → cascade falls to Россвязь
app()->instance(DaDataPhoneClient::class, $fake2);
config(['services.dadata.enabled' => true]);
@@ -455,59 +454,59 @@ it('X3: leads with dadata/rossvyaz/tag/unknown sources produce correct region_so
$source3 = $rows[$lead3->id]->region_source ?? 'MISSING';
$source4 = $rows[$lead4->id]->region_source ?? 'MISSING';
fwrite(STDOUT, PHP_EOL . '=== X3 SOURCE BREAKDOWN ===' . PHP_EOL);
fwrite(STDOUT, "Lead1 (dadata expected) region_source: {$source1} | resolved: " . ($rows[$lead1->id]->resolved_subject_code ?? 'null') . PHP_EOL);
fwrite(STDOUT, "Lead2 (rossvyaz expected) region_source: {$source2} | resolved: " . ($rows[$lead2->id]->resolved_subject_code ?? 'null') . PHP_EOL);
fwrite(STDOUT, "Lead3 (tag expected) region_source: {$source3} | resolved: " . ($rows[$lead3->id]->resolved_subject_code ?? 'null') . PHP_EOL);
fwrite(STDOUT, "Lead4 (unknown expected) region_source: {$source4} | resolved: " . ($rows[$lead4->id]->resolved_subject_code ?? 'null') . PHP_EOL);
fwrite(STDOUT, PHP_EOL.'=== X3 SOURCE BREAKDOWN ==='.PHP_EOL);
fwrite(STDOUT, "Lead1 (dadata expected) region_source: {$source1} | resolved: ".($rows[$lead1->id]->resolved_subject_code ?? 'null').PHP_EOL);
fwrite(STDOUT, "Lead2 (rossvyaz expected) region_source: {$source2} | resolved: ".($rows[$lead2->id]->resolved_subject_code ?? 'null').PHP_EOL);
fwrite(STDOUT, "Lead3 (tag expected) region_source: {$source3} | resolved: ".($rows[$lead3->id]->resolved_subject_code ?? 'null').PHP_EOL);
fwrite(STDOUT, "Lead4 (unknown expected) region_source: {$source4} | resolved: ".($rows[$lead4->id]->resolved_subject_code ?? 'null').PHP_EOL);
// Aggregate counts from supplier_leads.
$actualSources = array_map(fn ($id) => $rows[$id]->region_source ?? 'MISSING', $leadIds);
$counts = array_count_values($actualSources);
fwrite(STDOUT, 'Source counts: ' . json_encode($counts, JSON_UNESCAPED_UNICODE) . PHP_EOL);
fwrite(STDOUT, '=== END X3 ===' . PHP_EOL . PHP_EOL);
fwrite(STDOUT, 'Source counts: '.json_encode($counts, JSON_UNESCAPED_UNICODE).PHP_EOL);
fwrite(STDOUT, '=== END X3 ==='.PHP_EOL.PHP_EOL);
// ── ASSERT ────────────────────────────────────────────────────────────────
// Lead 1: expect 'dadata'.
expect($source1)->toBe('dadata',
"FINDING: Lead1 (phone={$phone1}, qc=0, valid region from DaData) should be 'dadata'. " .
"Got: '{$source1}'. " .
"If 'rossvyaz': DaData response was not mapped (DaDataRegionMap may not find '{$region1Name}'). " .
"FINDING: Lead1 (phone={$phone1}, qc=0, valid region from DaData) should be 'dadata'. ".
"Got: '{$source1}'. ".
"If 'rossvyaz': DaData response was not mapped (DaDataRegionMap may not find '{$region1Name}'). ".
"If 'unknown': DaData is disabled or threw despite stub."
);
// Lead 2: expect 'rossvyaz'.
expect($source2)->toBe('rossvyaz',
"FINDING: Lead2 (phone={$phone2}, DaData throws, phone_ranges seeded DEF=931→Москва) should be 'rossvyaz'. " .
"Got: '{$source2}'. " .
"If 'unknown': Россвязь lookup didn't match (check DEF extraction: phone 7{DEF}{7-digit} = 7|931|0000002 → DEF=931). " .
"FINDING: Lead2 (phone={$phone2}, DaData throws, phone_ranges seeded DEF=931→Москва) should be 'rossvyaz'. ".
"Got: '{$source2}'. ".
"If 'unknown': Россвязь lookup didn't match (check DEF extraction: phone 7{DEF}{7-digit} = 7|931|0000002 → DEF=931). ".
"If 'dadata': FakeDaDataPhoneClient stubThrows didn't fire (stub registration issue)."
);
// Lead 3: expect 'tag'.
expect($source3)->toBe('tag',
"FINDING: Lead3 (phone={$phone3}, DaData disabled, tag='Москва') should be 'tag'. " .
"Got: '{$source3}'. " .
"KNOWN FINDING (G5 test suite): with DaData disabled, LeadRegionResolver falls through " .
"Россвязь first. If no phone_ranges row for DEF=931 with this phone, then tagFallback() " .
"is called. tagFallback() returns 'tag' only when tagCode!=null (valid tag→region mapping). " .
"'Москва' should map to code 82 via RegionTagResolver. " .
"FINDING: Lead3 (phone={$phone3}, DaData disabled, tag='Москва') should be 'tag'. ".
"Got: '{$source3}'. ".
'KNOWN FINDING (G5 test suite): with DaData disabled, LeadRegionResolver falls through '.
'Россвязь first. If no phone_ranges row for DEF=931 with this phone, then tagFallback() '.
"is called. tagFallback() returns 'tag' only when tagCode!=null (valid tag→region mapping). ".
"'Москва' should map to code 82 via RegionTagResolver. ".
"If 'unknown': tag 'Москва' did not map to a region code in RegionTagResolver."
);
// Lead 4: expect 'unknown'.
expect($source4)->toBe('unknown',
"FINDING: Lead4 (phone={$phone4}, DaData disabled, no phone_range for DEF=988, empty tag) should be 'unknown'. " .
"Got: '{$source4}'. " .
"If 'rossvyaz': there is an unexpected phone_ranges row covering DEF=988. " .
"FINDING: Lead4 (phone={$phone4}, DaData disabled, no phone_range for DEF=988, empty tag) should be 'unknown'. ".
"Got: '{$source4}'. ".
"If 'rossvyaz': there is an unexpected phone_ranges row covering DEF=988. ".
"If 'tag': empty/null tag somehow resolved to a code (check RegionTagResolver null-tag handling)."
);
// Aggregate assertion: 4 leads → exactly these 4 sources.
$expectedCounts = ['dadata' => 1, 'rossvyaz' => 1, 'tag' => 1, 'unknown' => 1];
expect($counts)->toEqual($expectedCounts,
'FINDING: The aggregate source counts do not match expected {dadata:1, rossvyaz:1, tag:1, unknown:1}. ' .
'Got: ' . json_encode($counts) . '. See individual source assertions above for details.'
'FINDING: The aggregate source counts do not match expected {dadata:1, rossvyaz:1, tag:1, unknown:1}. '.
'Got: '.json_encode($counts).'. See individual source assertions above for details.'
);
})->group('imitation');
+13 -8
View File
@@ -52,7 +52,7 @@ beforeEach(function (): void {
$repoRoot = dirname(base_path());
// Step 1: Load the base schema.sql (creates most tables, triggers, RLS, etc.)
$schemaPath = $repoRoot . DIRECTORY_SEPARATOR . 'db' . DIRECTORY_SEPARATOR . 'schema.sql';
$schemaPath = $repoRoot.DIRECTORY_SEPARATOR.'db'.DIRECTORY_SEPARATOR.'schema.sql';
if (! is_readable($schemaPath)) {
throw new RuntimeException("schema.sql not found: {$schemaPath}");
}
@@ -67,7 +67,8 @@ beforeEach(function (): void {
ON DELETE CASCADE
DEFERRABLE INITIALLY DEFERRED
SQL);
} catch (Throwable) { /* already exists — idempotent */ }
} catch (Throwable) { /* already exists — idempotent */
}
// Step 3: Create tables that are in PARTITIONED_TABLES but NOT in schema.sql
// (they were added via delta migrations). partitions:create-months needs them.
@@ -98,7 +99,8 @@ beforeEach(function (): void {
'CREATE INDEX project_routing_snapshots_tenant_date_idx
ON project_routing_snapshots (tenant_id, snapshot_date)'
);
} catch (Throwable) { /* idempotent */ }
} catch (Throwable) { /* idempotent */
}
try {
DB::statement('ALTER TABLE project_routing_snapshots ENABLE ROW LEVEL SECURITY');
@@ -107,7 +109,8 @@ beforeEach(function (): void {
ON project_routing_snapshots
USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint)"
);
} catch (Throwable) { /* idempotent */ }
} catch (Throwable) { /* idempotent */
}
// 3b. phone_ranges_imports + phone_ranges + lead_region_resolution_log
// (delta migration 2026_05_31_100000)
@@ -174,7 +177,8 @@ beforeEach(function (): void {
try {
DB::statement('ALTER TABLE lead_region_resolution_log ENABLE ROW LEVEL SECURITY');
} catch (Throwable) { /* idempotent */ }
} catch (Throwable) { /* idempotent */
}
// Step 4: Create month partitions — with shared PDO, pgsql_supplier sees
// all the tables we just created within the same transaction.
@@ -192,12 +196,13 @@ beforeEach(function (): void {
['migration' => $migration],
['batch' => 1],
);
} catch (Throwable) { /* ok if migrations table doesn't exist */ }
} catch (Throwable) { /* ok if migrations table doesn't exist */
}
}
}
// Seed pricing tiers (required by LedgerService::chargeForDelivery).
(new PricingTierSeeder())->run();
(new PricingTierSeeder)->run();
// Allow cross-tenant reads during seeding.
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
@@ -216,7 +221,7 @@ beforeEach(function (): void {
* All project names are prefixed `IMIT-single-`.
*/
it('seeds the single-project matrix', function (): void {
(new ImitationClientsSeeder())->run();
(new ImitationClientsSeeder)->run();
expect(Project::where('name', 'like', 'IMIT-single-%')->count())->toBe(36);
})->group('imitation');
@@ -25,19 +25,19 @@ beforeEach(function (): void {
it('rebuild() creates a project_routing_snapshots row for the active date', function (): void {
// Arrange: tenant with positive balance (required by snapshot:rebuild eligibility)
$tenant = Tenant::factory()->create([
'balance_rub' => '500.00',
'balance_rub' => '500.00',
'frozen_by_balance_at' => null,
]);
// Arrange: active project with call signal (required by snapshot:rebuild
// INSERT: signal_type NOT NULL CHECK IN ('call','site','sms'))
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'signal_type' => 'call',
'signal_identifier' => '79161234567',
'daily_limit_target' => 10,
'delivery_days_mask' => 127, // all 7 days
'tenant_id' => $tenant->id,
'is_active' => true,
'signal_type' => 'call',
'signal_identifier' => '79161234567',
'daily_limit_target' => 10,
'delivery_days_mask' => 127, // all 7 days
'preflight_blocked_at' => null,
]);
@@ -41,6 +41,7 @@ use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\SystemSetting;
use App\Models\Tenant;
use App\Models\User;
use App\Services\DaData\DaDataPhoneClient;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
@@ -80,9 +81,9 @@ beforeEach(function (): void {
// Disable DaData by default; individual tests enable as needed.
config([
'services.dadata.enabled' => false,
'services.dadata.api_key' => 'fake-key',
'services.dadata.secret' => 'fake-secret',
'services.dadata.enabled' => false,
'services.dadata.api_key' => 'fake-key',
'services.dadata.secret' => 'fake-secret',
'services.dadata.daily_cap_rub' => 1_000_000,
]);
});
@@ -99,13 +100,13 @@ beforeEach(function (): void {
function tmi_fixAppKey(): void
{
if (strlen(base64_decode(str_replace('base64:', '', config('app.key'))) ?: '') !== 32) {
config(['app.key' => 'base64:' . base64_encode(str_repeat('a', 32))]);
config(['app.key' => 'base64:'.base64_encode(str_repeat('a', 32))]);
try {
app('encrypter')->__construct(
str_repeat('a', 32),
config('app.cipher', 'AES-256-CBC')
);
} catch (\Throwable) {
} catch (Throwable) {
// Encrypter may already be initialized; ignore re-init errors.
}
}
@@ -146,7 +147,7 @@ function tmi_clearIpAllowlist(): void
it('G1: project linked to two supplier sources receives leads from each', function (): void {
config(['services.dadata.enabled' => true]);
$seeder = new ImitationClientsSeeder();
$seeder = new ImitationClientsSeeder;
$g1 = $seeder->seedG1('IMIT-G1-topo');
/** @var Tenant $tenant */
@@ -159,17 +160,17 @@ it('G1: project linked to two supplier sources receives leads from each', functi
// Set ample balance.
DB::table('tenants')->where('id', $tenant->id)->update([
'balance_rub' => '9999.00',
'balance_rub' => '9999.00',
'frozen_by_balance_at' => null,
'delivered_in_month' => 0,
'delivered_in_month' => 0,
]);
// Set project active, all-RF regions (empty = all), all days.
DB::table('projects')->where('id', $project->id)->update([
'is_active' => true,
'regions' => '{}',
'is_active' => true,
'regions' => '{}',
'delivery_days_mask' => 127,
'delivered_today' => 0,
'delivered_today' => 0,
]);
$activeDate = SnapshotForge::activeDate();
@@ -178,28 +179,28 @@ it('G1: project linked to two supplier sources receives leads from each', functi
// (project_supplier_links), not signal_identifier in snapshot. The snapshot just
// needs to exist with the correct project_id and date.
createRoutingSnapshotFromProject(
project: $project,
date: $activeDate,
signalType: 'site',
project: $project,
date: $activeDate,
signalType: 'site',
signalIdentifier: $project->signal_identifier ?? 'g1-proj.test',
dailyLimit: 50,
regions: '{}',
dailyLimit: 50,
regions: '{}',
);
// Lead via B1 source. Inject using the supplier's unique_key so resolveOrStub
// finds the SAME supplier_project that the pivot links to.
$phoneB1 = '79161111001';
$fake = new FakeDaDataPhoneClient();
$fake = new FakeDaDataPhoneClient;
$fake->stub($phoneB1, qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fake);
$injector = new LeadInjector();
$injector = new LeadInjector;
$leadB1 = $injector->site(
domain: $supplierB1->unique_key ?? 'g1-b1.test',
phone: $phoneB1,
tag: 'Москва',
domain: $supplierB1->unique_key ?? 'g1-b1.test',
phone: $phoneB1,
tag: 'Москва',
platform: $supplierB1->platform,
vid: 9_100_000_001,
vid: 9_100_000_001,
);
$dealsB1 = DB::connection('pgsql_supplier')
@@ -208,7 +209,7 @@ it('G1: project linked to two supplier sources receives leads from each', functi
->count();
expect($dealsB1)->toBeGreaterThan(0,
'FINDING: G1 — project linked to B1 supplier did not receive any deal from B1 lead. ' .
'FINDING: G1 — project linked to B1 supplier did not receive any deal from B1 lead. '.
"supplier_project_id={$supplierB1->id}, project_id={$project->id}"
);
})->group('imitation');
@@ -222,7 +223,7 @@ it('G1: project linked to two supplier sources receives leads from each', functi
it('G2: two clients on same supplier each receive at least one lead', function (): void {
config(['services.dadata.enabled' => true]);
$seeder = new ImitationClientsSeeder();
$seeder = new ImitationClientsSeeder;
$g2 = $seeder->seedG2(
['daily_limit_target' => 20, 'regions' => [TOPO_MOSCOW]],
['daily_limit_target' => 20, 'regions' => [TOPO_MOSCOW]],
@@ -239,23 +240,23 @@ it('G2: two clients on same supplier each receive at least one lead', function (
foreach ([$projects[0], $projects[1]] as $idx => $project) {
DB::table('tenants')->where('id', $tenants[$idx]->id)->update([
'balance_rub' => '9999.00',
'balance_rub' => '9999.00',
'frozen_by_balance_at' => null,
'delivered_in_month' => 0,
'delivered_in_month' => 0,
]);
DB::table('projects')->where('id', $project->id)->update([
'is_active' => true,
'regions' => '{' . TOPO_MOSCOW . '}',
'is_active' => true,
'regions' => '{'.TOPO_MOSCOW.'}',
'delivery_days_mask' => 127,
'delivered_today' => 0,
'delivered_today' => 0,
]);
createRoutingSnapshotFromProject(
project: $project,
date: $activeDate,
signalType: 'site',
project: $project,
date: $activeDate,
signalType: 'site',
signalIdentifier: $project->signal_identifier,
dailyLimit: 20,
regions: '{' . TOPO_MOSCOW . '}',
dailyLimit: 20,
regions: '{'.TOPO_MOSCOW.'}',
);
}
@@ -265,22 +266,22 @@ it('G2: two clients on same supplier each receive at least one lead', function (
// and RouteSupplierLeadJob::resolveOrStub() looks up supplier_projects by (platform, unique_key).
$supplierDomain = $supplier->unique_key ?? 'g2-src.test';
$fake = new FakeDaDataPhoneClient();
$fake = new FakeDaDataPhoneClient;
for ($i = 1; $i <= 10; $i++) {
$phone = '79162' . str_pad((string) $i, 6, '0', STR_PAD_LEFT);
$phone = '79162'.str_pad((string) $i, 6, '0', STR_PAD_LEFT);
$fake->stub($phone, qc: 0, region: 'Москва', provider: 'МТС');
}
app()->instance(DaDataPhoneClient::class, $fake);
$injector = new LeadInjector();
$injector = new LeadInjector;
for ($i = 1; $i <= 10; $i++) {
$phone = '79162' . str_pad((string) $i, 6, '0', STR_PAD_LEFT);
$phone = '79162'.str_pad((string) $i, 6, '0', STR_PAD_LEFT);
$injector->site(
domain: $supplierDomain,
phone: $phone,
tag: 'Москва',
domain: $supplierDomain,
phone: $phone,
tag: 'Москва',
platform: $supplier->platform,
vid: 9_200_000_000 + $i,
vid: 9_200_000_000 + $i,
);
}
@@ -304,13 +305,13 @@ it('G2: two clients on same supplier each receive at least one lead', function (
// both should receive deals. This is probabilistic but seed-based.
// If one is 0, it indicates routing bias — report as FINDING.
expect($deals0)->toBeGreaterThan(0,
"FINDING: G2 — client 0 received 0 deals out of {$totalDeals} total. " .
"Both clients have equal weight. Possible routing bias."
"FINDING: G2 — client 0 received 0 deals out of {$totalDeals} total. ".
'Both clients have equal weight. Possible routing bias.'
);
expect($deals1)->toBeGreaterThan(0,
"FINDING: G2 — client 1 received 0 deals out of {$totalDeals} total. " .
"Both clients have equal weight. Possible routing bias."
"FINDING: G2 — client 1 received 0 deals out of {$totalDeals} total. ".
'Both clients have equal weight. Possible routing bias.'
);
})->group('imitation');
@@ -323,7 +324,7 @@ it('G2: two clients on same supplier each receive at least one lead', function (
it('G4: two projects on same supplier with different regions each receive region-matching leads', function (): void {
config(['services.dadata.enabled' => true]);
$seeder = new ImitationClientsSeeder();
$seeder = new ImitationClientsSeeder;
$g4 = $seeder->seedG4(TOPO_MOSCOW, TOPO_SPB);
/** @var SupplierProject $supplier */
@@ -336,14 +337,14 @@ it('G4: two projects on same supplier with different regions each receive region
$projectB = $g4['projectB']; // regions=[83] СПб
DB::table('tenants')->where('id', $tenant->id)->update([
'balance_rub' => '9999.00',
'balance_rub' => '9999.00',
'frozen_by_balance_at' => null,
'delivered_in_month' => 0,
'delivered_in_month' => 0,
]);
foreach ([$projectA, $projectB] as $project) {
DB::table('projects')->where('id', $project->id)->update([
'is_active' => true,
'is_active' => true,
'delivery_days_mask' => 127,
'delivered_today' => 0,
]);
@@ -352,38 +353,38 @@ it('G4: two projects on same supplier with different regions each receive region
$activeDate = SnapshotForge::activeDate();
createRoutingSnapshotFromProject(
project: $projectA,
date: $activeDate,
signalType: 'site',
project: $projectA,
date: $activeDate,
signalType: 'site',
signalIdentifier: $projectA->signal_identifier,
dailyLimit: 50,
regions: '{' . TOPO_MOSCOW . '}',
dailyLimit: 50,
regions: '{'.TOPO_MOSCOW.'}',
);
createRoutingSnapshotFromProject(
project: $projectB,
date: $activeDate,
signalType: 'site',
project: $projectB,
date: $activeDate,
signalType: 'site',
signalIdentifier: $projectB->signal_identifier,
dailyLimit: 50,
regions: '{' . TOPO_SPB . '}',
dailyLimit: 50,
regions: '{'.TOPO_SPB.'}',
);
// Use the supplier's unique_key as the domain — resolveOrStub looks up by (platform, unique_key).
$supplierKey = $supplier->unique_key ?? 'g4-src.test';
$injector = new LeadInjector();
$injector = new LeadInjector;
// Lead A: Москва phone → resolved to code 82 → should go to projectA only.
$phoneMoscow = '79163000001';
$fakeMoscow = (new FakeDaDataPhoneClient())->stub($phoneMoscow, qc: 0, region: 'Москва', provider: 'МТС');
$fakeMoscow = (new FakeDaDataPhoneClient)->stub($phoneMoscow, qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fakeMoscow);
$injector->site(
domain: $supplierKey,
phone: $phoneMoscow,
tag: 'Москва',
domain: $supplierKey,
phone: $phoneMoscow,
tag: 'Москва',
platform: $supplier->platform,
vid: 9_400_000_001,
vid: 9_400_000_001,
);
$dealsAfterMoscowLead_A = DB::connection('pgsql_supplier')
@@ -399,26 +400,26 @@ it('G4: two projects on same supplier with different regions each receive region
->count();
expect($dealsAfterMoscowLead_A)->toBeGreaterThan(0,
'FINDING: G4 — Москва lead (subject_code=82) did not reach projectA (regions=[82]). ' .
'FINDING: G4 — Москва lead (subject_code=82) did not reach projectA (regions=[82]). '.
"projectA_id={$projectA->id}, projectB_id={$projectB->id}"
);
expect($dealsAfterMoscowLead_B)->toBe(0,
"FINDING: G4 — Москва lead (subject_code=82) leaked into projectB (regions=[83]). " .
'FINDING: G4 — Москва lead (subject_code=82) leaked into projectB (regions=[83]). '.
"Expected 0 deals for projectB, got {$dealsAfterMoscowLead_B}."
);
// Lead B: СПб phone → resolved to code 83 → should go to projectB only.
$phoneSpb = '79163000002';
$fakeSpb = (new FakeDaDataPhoneClient())->stub($phoneSpb, qc: 0, region: 'Санкт-Петербург', provider: 'МегаФон');
$fakeSpb = (new FakeDaDataPhoneClient)->stub($phoneSpb, qc: 0, region: 'Санкт-Петербург', provider: 'МегаФон');
app()->instance(DaDataPhoneClient::class, $fakeSpb);
$injector->site(
domain: $supplierKey,
phone: $phoneSpb,
tag: 'Санкт-Петербург',
domain: $supplierKey,
phone: $phoneSpb,
tag: 'Санкт-Петербург',
platform: $supplier->platform,
vid: 9_400_000_002,
vid: 9_400_000_002,
);
$dealsAfterSpbLead_A = DB::connection('pgsql_supplier')
@@ -435,12 +436,12 @@ it('G4: two projects on same supplier with different regions each receive region
// projectA should still have only the first lead (unchanged).
expect($dealsAfterSpbLead_A)->toBe($dealsAfterMoscowLead_A,
"FINDING: G4 — СПб lead (subject_code=83) leaked into projectA (regions=[82]). " .
'FINDING: G4 — СПб lead (subject_code=83) leaked into projectA (regions=[82]). '.
"Expected {$dealsAfterMoscowLead_A} deals for projectA, got {$dealsAfterSpbLead_A}."
);
expect($dealsAfterSpbLead_B)->toBeGreaterThan(0,
'FINDING: G4 — СПб lead (subject_code=83) did not reach projectB (regions=[83]). ' .
'FINDING: G4 — СПб lead (subject_code=83) did not reach projectB (regions=[83]). '.
"projectA_id={$projectA->id}, projectB_id={$projectB->id}"
);
})->group('imitation');
@@ -471,27 +472,27 @@ it('money: lead_charges, balance_transactions, supplier_lead_costs are correct a
$expectedAmountRub = '500.00'; // 50000 / 100
$tenant = Tenant::factory()->create([
'balance_rub' => $initialBalance,
'balance_rub' => $initialBalance,
'frozen_by_balance_at' => null,
'delivered_in_month' => 0,
'delivered_in_month' => 0,
]);
$user = \App\Models\User::factory()->create(['tenant_id' => $tenant->id]);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$supplier = SupplierProject::factory()->create([
'platform' => 'B2',
'platform' => 'B2',
'signal_type' => 'site',
]);
$phone = '79164000001';
// PostgresIntArray cast requires PHP array, not '{...}' string literal.
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => $supplier->unique_key ?? 'money-test-b2.test',
'regions' => [], // empty = all-RF; cast converts to '{}'
'tenant_id' => $tenant->id,
'is_active' => true,
'signal_type' => 'site',
'signal_identifier' => $supplier->unique_key ?? 'money-test-b2.test',
'regions' => [], // empty = all-RF; cast converts to '{}'
'delivery_days_mask' => 127,
'delivered_today' => 0,
'delivered_today' => 0,
'delivered_in_month' => 0,
]);
@@ -499,24 +500,24 @@ it('money: lead_charges, balance_transactions, supplier_lead_costs are correct a
$activeDate = SnapshotForge::activeDate();
createRoutingSnapshotFromProject(
project: $project,
date: $activeDate,
signalType: 'site',
project: $project,
date: $activeDate,
signalType: 'site',
signalIdentifier: $project->signal_identifier,
dailyLimit: 50,
regions: '{}',
dailyLimit: 50,
regions: '{}',
);
$fakeDaData = (new FakeDaDataPhoneClient())->stub($phone, qc: 0, region: 'Москва', provider: 'МТС');
$fakeDaData = (new FakeDaDataPhoneClient)->stub($phone, qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fakeDaData);
$injector = new LeadInjector();
$injector = new LeadInjector;
$injector->site(
domain: ltrim($project->signal_identifier, '/'),
phone: $phone,
tag: 'Москва',
domain: ltrim($project->signal_identifier, '/'),
phone: $phone,
tag: 'Москва',
platform: $supplier->platform,
vid: 9_500_000_001,
vid: 9_500_000_001,
);
// ── Reload tenant to see updated balance ────────────────────────────────
@@ -539,7 +540,7 @@ it('money: lead_charges, balance_transactions, supplier_lead_costs are correct a
expect($charge)->not->toBeNull('FINDING: lead_charges row missing after delivery.');
expect((int) $charge->price_per_lead_kopecks)->toBe($expectedPriceKopecks,
"FINDING: lead_charges.price_per_lead_kopecks = {$charge->price_per_lead_kopecks}, " .
"FINDING: lead_charges.price_per_lead_kopecks = {$charge->price_per_lead_kopecks}, ".
"expected {$expectedPriceKopecks} (tier 1, delivered_in_month was 0 → count+1=1)."
);
expect($charge->charge_source)->toBe('rub',
@@ -566,7 +567,7 @@ it('money: lead_charges, balance_transactions, supplier_lead_costs are correct a
// The absolute value must equal the tier price.
$absAmount = ltrim($amountRub, '-');
expect($absAmount)->toBe($expectedAmountRub,
"FINDING: balance_transactions.amount_rub absolute value = '{$absAmount}', " .
"FINDING: balance_transactions.amount_rub absolute value = '{$absAmount}', ".
"expected '{$expectedAmountRub}' (kopecks={$expectedPriceKopecks} → rub=500.00)."
);
@@ -577,16 +578,16 @@ it('money: lead_charges, balance_transactions, supplier_lead_costs are correct a
$actualNewBalance = (string) $tenantAfter->balance_rub;
// Normalize: bcmath may return '500.00'; DB may store '500.00' as well.
expect($actualNewBalance)->toBe($expectedNewBalance,
"FINDING: KOPECK LOSS detected. " .
"Initial balance: {$initialBalance}, price: {$expectedAmountRub}. " .
"Expected new balance: {$expectedNewBalance}, actual: {$actualNewBalance}. " .
"This indicates floating-point or bcmath precision error."
'FINDING: KOPECK LOSS detected. '.
"Initial balance: {$initialBalance}, price: {$expectedAmountRub}. ".
"Expected new balance: {$expectedNewBalance}, actual: {$actualNewBalance}. ".
'This indicates floating-point or bcmath precision error.'
);
// balance_rub_after in balance_transactions must match actual tenant balance.
$btBalanceAfter = (string) $bt->balance_rub_after;
expect($btBalanceAfter)->toBe($expectedNewBalance,
"FINDING: balance_transactions.balance_rub_after = '{$btBalanceAfter}', " .
"FINDING: balance_transactions.balance_rub_after = '{$btBalanceAfter}', ".
"expected '{$expectedNewBalance}'. Ledger audit trail inconsistency."
);
@@ -596,14 +597,14 @@ it('money: lead_charges, balance_transactions, supplier_lead_costs are correct a
->first();
expect($slc)->not->toBeNull(
'FINDING: supplier_lead_costs row missing after delivery. ' .
'FINDING: supplier_lead_costs row missing after delivery. '.
'LedgerService should insert it when supplier resolved via platform B2.'
);
// ── 5. delivered_in_month incremented ───────────────────────────────────
expect((int) $tenantAfter->delivered_in_month)->toBe(1,
"FINDING: tenants.delivered_in_month = {$tenantAfter->delivered_in_month}, " .
"expected 1 after first lead delivery (started at 0)."
"FINDING: tenants.delivered_in_month = {$tenantAfter->delivered_in_month}, ".
'expected 1 after first lead delivery (started at 0).'
);
})->group('imitation');
@@ -618,14 +619,14 @@ it('money: tier is resolved by delivered_in_month+1 boundary', function (): void
// delivered_in_month=100 → count+1=101 → tier 2 (price=45000 kopecks = 450.00 rub).
$expectedPriceKopecks = 45000;
$expectedAmountRub = '450.00';
$expectedAmountRub = '450.00';
$tenant = Tenant::factory()->create([
'balance_rub' => '9999.00',
'balance_rub' => '9999.00',
'frozen_by_balance_at' => null,
'delivered_in_month' => 100, // one past tier-1 boundary (100 leads used tier 1)
'delivered_in_month' => 100, // one past tier-1 boundary (100 leads used tier 1)
]);
\App\Models\User::factory()->create(['tenant_id' => $tenant->id]);
User::factory()->create(['tenant_id' => $tenant->id]);
$supplier = SupplierProject::factory()->create(['platform' => 'B2', 'signal_type' => 'site']);
$supplierKey2 = $supplier->unique_key; // used for injection domain
@@ -635,11 +636,11 @@ it('money: tier is resolved by delivered_in_month+1 boundary', function (): void
$project = Project::factory()
->asSiteSignal($supplierKey2)
->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'regions' => [], // empty = all-RF; PostgresIntArray cast expects PHP array
'tenant_id' => $tenant->id,
'is_active' => true,
'regions' => [], // empty = all-RF; PostgresIntArray cast expects PHP array
'delivery_days_mask' => 127,
'delivered_today' => 0,
'delivered_today' => 0,
'delivered_in_month' => 100,
]);
@@ -647,25 +648,25 @@ it('money: tier is resolved by delivered_in_month+1 boundary', function (): void
$activeDate = SnapshotForge::activeDate();
createRoutingSnapshotFromProject(
project: $project,
date: $activeDate,
signalType: 'site',
project: $project,
date: $activeDate,
signalType: 'site',
signalIdentifier: $supplierKey2,
dailyLimit: 50,
regions: '{}',
dailyLimit: 50,
regions: '{}',
);
$phone = '79165000002';
$fake = (new FakeDaDataPhoneClient())->stub($phone, qc: 0, region: 'Москва', provider: 'МТС');
$fake = (new FakeDaDataPhoneClient)->stub($phone, qc: 0, region: 'Москва', provider: 'МТС');
app()->instance(DaDataPhoneClient::class, $fake);
$injector = new LeadInjector();
$injector = new LeadInjector;
$injector->site(
domain: $supplierKey2,
phone: $phone,
tag: 'Москва',
domain: $supplierKey2,
phone: $phone,
tag: 'Москва',
platform: $supplier->platform,
vid: 9_500_100_001,
vid: 9_500_100_001,
);
$deal = DB::connection('pgsql_supplier')
@@ -683,7 +684,7 @@ it('money: tier is resolved by delivered_in_month+1 boundary', function (): void
expect($charge)->not->toBeNull('FINDING: lead_charges row missing for tier boundary test.');
expect((int) $charge->price_per_lead_kopecks)->toBe($expectedPriceKopecks,
"FINDING: Tier boundary wrong. delivered_in_month=100 → count+1=101 → should be tier 2 " .
'FINDING: Tier boundary wrong. delivered_in_month=100 → count+1=101 → should be tier 2 '.
"(price=45000 kopecks). Got: {$charge->price_per_lead_kopecks}."
);
expect($charge->charge_source)->toBe('rub');
@@ -704,15 +705,15 @@ it('intake: bad secret returns 404', function (): void {
tmi_clearIpAllowlist();
$response = $this->postJson('/api/webhook/supplier/THIS-IS-THE-WRONG-SECRET-XXXXX', [
'vid' => 999_001,
'vid' => 999_001,
'project' => 'B2_some-domain.test',
'phone' => '79161234567',
'time' => now()->timestamp,
'tag' => 'Москва',
'phone' => '79161234567',
'time' => now()->timestamp,
'tag' => 'Москва',
]);
expect($response->status())->toBe(404,
'FINDING: Bad webhook secret did not return 404. ' .
'FINDING: Bad webhook secret did not return 404. '.
"Got HTTP {$response->status()}."
);
})->group('imitation');
@@ -728,17 +729,17 @@ it('intake: invalid phone (wrong prefix) returns 422', function (): void {
tmi_setIntakeSecret(TOPO_SECRET);
tmi_clearIpAllowlist();
$response = $this->postJson('/api/webhook/supplier/' . TOPO_SECRET, [
'vid' => 999_002,
$response = $this->postJson('/api/webhook/supplier/'.TOPO_SECRET, [
'vid' => 999_002,
'project' => 'B2_some-domain.test',
'phone' => '89161234567', // starts with 8, fails /^7\d{10}$/
'time' => now()->timestamp,
'tag' => 'Москва',
'phone' => '89161234567', // starts with 8, fails /^7\d{10}$/
'time' => now()->timestamp,
'tag' => 'Москва',
]);
expect($response->status())->toBe(422,
'FINDING: Phone starting with 8 (invalid) did not return 422. ' .
"Got HTTP {$response->status()}. Response: " . $response->content()
'FINDING: Phone starting with 8 (invalid) did not return 422. '.
"Got HTTP {$response->status()}. Response: ".$response->content()
);
})->group('imitation');
@@ -750,16 +751,16 @@ it('intake: too-short phone returns 422', function (): void {
tmi_setIntakeSecret(TOPO_SECRET);
tmi_clearIpAllowlist();
$response = $this->postJson('/api/webhook/supplier/' . TOPO_SECRET, [
'vid' => 999_003,
$response = $this->postJson('/api/webhook/supplier/'.TOPO_SECRET, [
'vid' => 999_003,
'project' => 'B2_some-domain.test',
'phone' => '7916123', // too short — only 7 digits after 7
'time' => now()->timestamp,
'tag' => 'Москва',
'phone' => '7916123', // too short — only 7 digits after 7
'time' => now()->timestamp,
'tag' => 'Москва',
]);
expect($response->status())->toBe(422,
'FINDING: Short phone did not return 422. ' .
'FINDING: Short phone did not return 422. '.
"Got HTTP {$response->status()}."
);
})->group('imitation');
@@ -776,18 +777,18 @@ it('intake: timestamp 48h in the past (beyond -24h window) returns 422', functio
$oldTime = now()->subHours(48)->getTimestamp(); // 48h ago — outside ±24h window
$response = $this->postJson('/api/webhook/supplier/' . TOPO_SECRET, [
'vid' => 999_004,
$response = $this->postJson('/api/webhook/supplier/'.TOPO_SECRET, [
'vid' => 999_004,
'project' => 'B2_some-domain.test',
'phone' => '79161234568',
'time' => $oldTime,
'tag' => 'Москва',
'phone' => '79161234568',
'time' => $oldTime,
'tag' => 'Москва',
]);
expect($response->status())->toBe(422,
'FINDING: Timestamp 48h in the past did not return 422. ' .
"Got HTTP {$response->status()}. " .
"Controller requires time within ±24h (min=now-24h, max=now+24h)."
'FINDING: Timestamp 48h in the past did not return 422. '.
"Got HTTP {$response->status()}. ".
'Controller requires time within ±24h (min=now-24h, max=now+24h).'
);
})->group('imitation');
@@ -801,16 +802,16 @@ it('intake: timestamp 48h in the future (beyond +24h window) returns 422', funct
$futureTime = now()->addHours(48)->getTimestamp(); // 48h in future
$response = $this->postJson('/api/webhook/supplier/' . TOPO_SECRET, [
'vid' => 999_005,
$response = $this->postJson('/api/webhook/supplier/'.TOPO_SECRET, [
'vid' => 999_005,
'project' => 'B2_some-domain.test',
'phone' => '79161234569',
'time' => $futureTime,
'tag' => 'Москва',
'phone' => '79161234569',
'time' => $futureTime,
'tag' => 'Москва',
]);
expect($response->status())->toBe(422,
'FINDING: Timestamp 48h in the future did not return 422. ' .
'FINDING: Timestamp 48h in the future did not return 422. '.
"Got HTTP {$response->status()}."
);
})->group('imitation');
@@ -843,18 +844,18 @@ it('intake: rate-limit 600/min per IP — 601st request returns 429', function (
}
// Now the 601st HTTP request should see tooManyAttempts = true.
$response = $this->postJson('/api/webhook/supplier/' . TOPO_SECRET, [
'vid' => 999_006,
$response = $this->postJson('/api/webhook/supplier/'.TOPO_SECRET, [
'vid' => 999_006,
'project' => 'B2_some-domain.test',
'phone' => '79161234570',
'time' => now()->timestamp,
'tag' => 'Москва',
'phone' => '79161234570',
'time' => now()->timestamp,
'tag' => 'Москва',
]);
expect($response->status())->toBe(429,
'FINDING: 601st request (after 600 hits) did not return 429 (rate limited). ' .
"Got HTTP {$response->status()}. " .
"Controller has RATE_LIMIT_PER_MINUTE=600. Rate-limit enforcement may be broken."
'FINDING: 601st request (after 600 hits) did not return 429 (rate limited). '.
"Got HTTP {$response->status()}. ".
'Controller has RATE_LIMIT_PER_MINUTE=600. Rate-limit enforcement may be broken.'
);
// Clean up rate limiter state to avoid cross-test pollution.
@@ -12,7 +12,6 @@ declare(strict_types=1);
* Этот тест фиксирует консолидацию: приложение само эти заголовки больше НЕ ставит.
* Бьём по публичному / (welcome SPA) auth и БД не нужны.
*/
test('приложение НЕ ставит Content-Security-Policy-Report-Only (источник CSP — enforcing nginx)', function () {
$this->get('/')->assertHeaderMissing('Content-Security-Policy-Report-Only');
});
@@ -15,6 +15,7 @@ use App\Services\LeadRouter;
use App\Services\NotificationService;
use App\Services\RegionTagResolver;
use App\Services\SupplierProjects\SupplierProjectResolver;
use Carbon\Carbon;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
@@ -71,7 +72,7 @@ beforeEach(function (): void {
linkProjectToSupplier($this->project, $this->sp);
createRoutingSnapshotFromProject($this->project, null, 'site', 'race-csv.ru', 100);
createRoutingSnapshotFromProject($this->project, \Carbon\Carbon::tomorrow('Europe/Moscow')->toDateString(), 'site', 'race-csv.ru', 100);
createRoutingSnapshotFromProject($this->project, Carbon::tomorrow('Europe/Moscow')->toDateString(), 'site', 'race-csv.ru', 100);
});
/**
@@ -118,7 +118,7 @@ final class ConditionLevers
public static function setRegions(Project $project, array $codes): void
{
// PostgreSQL int[] литерал: '{82,83}' или '{}'.
$pgArray = '{' . implode(',', array_map('intval', $codes)) . '}';
$pgArray = '{'.implode(',', array_map('intval', $codes)).'}';
DB::table('projects')
->where('id', $project->id)
@@ -26,7 +26,7 @@ use App\Services\DaData\DaDataPhoneResponse;
class FakeDaDataPhoneClient extends DaDataPhoneClient
{
/**
* @var array<string, DaDataPhoneResponse|null> phone => response (null = throw DaDataException)
* @var array<string, DaDataPhoneResponse|null> phone => response (null = throw DaDataException)
*/
private array $stubs = [];
@@ -56,10 +56,10 @@ class FakeDaDataPhoneClient extends DaDataPhoneClient
city: null,
timezone: null,
raw: [
'qc' => $qc,
'qc' => $qc,
'provider' => $provider,
'region' => $region,
'phone' => $phone,
'region' => $region,
'phone' => $phone,
],
);
@@ -80,7 +80,7 @@ class FakeDaDataPhoneClient extends DaDataPhoneClient
/**
* Возвращает заранее зарегистрированный ответ или бросает DaDataException.
*
* @throws DaDataException Если стаб не зарегистрирован или зарегистрирован как throw.
* @throws DaDataException Если стаб не зарегистрирован или зарегистрирован как throw.
*/
public function cleanPhone(string $phone): DaDataPhoneResponse
{
@@ -52,11 +52,11 @@ final class ImitationClientsSeeder
private function seedSingleProjectMatrix(): void
{
$signals = ['site', 'call'];
$signals = ['site', 'call'];
// regions: empty = all-RF; [82] = Москва; [82, 83] = Москва + СПб
$regions = [[], [82], [82, 83]];
$regions = [[], [82], [82, 83]];
$dayMasks = [127, 31];
$limits = [3, 30, 300];
$limits = [3, 30, 300];
$i = 0;
foreach ($signals as $signal) {
@@ -65,11 +65,11 @@ final class ImitationClientsSeeder
foreach ($limits as $limit) {
$i++;
$this->makeSingleProjectCell(
index: $i,
signal: $signal,
regions: $regionSet,
index: $i,
signal: $signal,
regions: $regionSet,
daysMask: $daysMask,
limit: $limit,
limit: $limit,
);
}
}
@@ -96,7 +96,7 @@ final class ImitationClientsSeeder
$uniqueSuffix = Str::random(6);
$signalIdentifier = $signal === 'site'
? "imit-{$index}-{$uniqueSuffix}.test"
: '7' . str_pad((string) (9000000000 + $index), 10, '0', STR_PAD_LEFT) . $uniqueSuffix;
: '7'.str_pad((string) (9000000000 + $index), 10, '0', STR_PAD_LEFT).$uniqueSuffix;
// Pass regions as a PHP int[] — the PostgresIntArray Eloquent cast
// converts it to the PostgreSQL literal '{82,83}' or '{}' in set().
@@ -104,27 +104,27 @@ final class ImitationClientsSeeder
? Project::factory()
->asSiteSignal($signalIdentifier)
->create([
'name' => "IMIT-single-{$index}",
'tenant_id' => $tenant->id,
'regions' => $regions,
'name' => "IMIT-single-{$index}",
'tenant_id' => $tenant->id,
'regions' => $regions,
'delivery_days_mask' => $daysMask,
'daily_limit_target' => $limit,
])
: Project::factory()
->asCallSignal($signalIdentifier)
->create([
'name' => "IMIT-single-{$index}",
'tenant_id' => $tenant->id,
'regions' => $regions,
'name' => "IMIT-single-{$index}",
'tenant_id' => $tenant->id,
'regions' => $regions,
'delivery_days_mask' => $daysMask,
'daily_limit_target' => $limit,
]);
DB::table('project_supplier_links')->insert([
'project_id' => $project->id,
'project_id' => $project->id,
'supplier_project_id' => $this->sharedSupplier->id,
'platform' => $this->sharedSupplier->platform,
'subject_code' => null,
'platform' => $this->sharedSupplier->platform,
'subject_code' => null,
]);
}
@@ -139,7 +139,7 @@ final class ImitationClientsSeeder
private function makeSharedSupplierProject(): SupplierProject
{
return SupplierProject::factory()->create([
'platform' => 'B2',
'platform' => 'B2',
'signal_type' => 'site',
]);
}
@@ -163,9 +163,9 @@ final class ImitationClientsSeeder
User::factory()->create(['tenant_id' => $tenant->id]);
$project = Project::factory()
->asSiteSignal("g1-{$namePrefix}-" . Str::random(6) . '.test')
->asSiteSignal("g1-{$namePrefix}-".Str::random(6).'.test')
->create([
'name' => "{$namePrefix}-project",
'name' => "{$namePrefix}-project",
'tenant_id' => $tenant->id,
]);
@@ -174,10 +174,10 @@ final class ImitationClientsSeeder
foreach ([$supplier1, $supplier2] as $supplier) {
DB::table('project_supplier_links')->insert([
'project_id' => $project->id,
'project_id' => $project->id,
'supplier_project_id' => $supplier->id,
'platform' => $supplier->platform,
'subject_code' => null,
'platform' => $supplier->platform,
'subject_code' => null,
]);
}
@@ -199,7 +199,7 @@ final class ImitationClientsSeeder
$supplier = SupplierProject::factory()->create(['platform' => 'B2', 'signal_type' => 'site']);
$projects = [];
$tenants = [];
$tenants = [];
foreach ([$overrides1, $overrides2] as $idx => $overrides) {
$tenant = Tenant::factory()->create();
@@ -207,18 +207,18 @@ final class ImitationClientsSeeder
$tenants[] = $tenant;
$project = Project::factory()
->asSiteSignal("g2-client-{$idx}-" . Str::random(6) . '.test')
->asSiteSignal("g2-client-{$idx}-".Str::random(6).'.test')
->create(array_merge([
'name' => "IMIT-G2-client-{$idx}",
'name' => "IMIT-G2-client-{$idx}",
'tenant_id' => $tenant->id,
], $overrides));
$projects[] = $project;
DB::table('project_supplier_links')->insert([
'project_id' => $project->id,
'project_id' => $project->id,
'supplier_project_id' => $supplier->id,
'platform' => $supplier->platform,
'subject_code' => null,
'platform' => $supplier->platform,
'subject_code' => null,
]);
}
@@ -249,31 +249,31 @@ final class ImitationClientsSeeder
$projectA = Project::factory()
->asSiteSignal("g4-region-{$regionA}-{$uniqueA}.test")
->create([
'name' => "IMIT-G4-region-{$regionA}",
'name' => "IMIT-G4-region-{$regionA}",
'tenant_id' => $tenant->id,
'regions' => [$regionA], // PHP int[] — PostgresIntArray cast handles conversion
'regions' => [$regionA], // PHP int[] — PostgresIntArray cast handles conversion
]);
$projectB = Project::factory()
->asSiteSignal("g4-region-{$regionB}-{$uniqueB}.test")
->create([
'name' => "IMIT-G4-region-{$regionB}",
'name' => "IMIT-G4-region-{$regionB}",
'tenant_id' => $tenant->id,
'regions' => [$regionB], // PHP int[] — PostgresIntArray cast handles conversion
'regions' => [$regionB], // PHP int[] — PostgresIntArray cast handles conversion
]);
foreach ([$projectA, $projectB] as $project) {
DB::table('project_supplier_links')->insert([
'project_id' => $project->id,
'project_id' => $project->id,
'supplier_project_id' => $supplier->id,
'platform' => $supplier->platform,
'subject_code' => null,
'platform' => $supplier->platform,
'subject_code' => null,
]);
}
return [
'supplier' => $supplier,
'tenant' => $tenant,
'tenant' => $tenant,
'projectA' => $projectA,
'projectB' => $projectB,
];
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Support\Imitation;
use App\Support\RussianRegions;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
@@ -77,11 +78,11 @@ abstract class ImitationTestCase extends TestCase
* Only call this when your specific test scenario exercises the Россвязь
* branch of LeadRegionResolver (e.g. DaData degradation tests).
*
* @param string $defCode DEF-code prefix (e.g. '999').
* @param string $from Lower bound of number range (e.g. '0000000').
* @param string $to Upper bound of number range (e.g. '0099999').
* @param int $subjectCode Subject code (1..89, порядковый, НЕ ГИБДД).
* Use App\Support\RussianRegions::nameToCode() for lookup.
* @param string $defCode DEF-code prefix (e.g. '999').
* @param string $from Lower bound of number range (e.g. '0000000').
* @param string $to Upper bound of number range (e.g. '0099999').
* @param int $subjectCode Subject code (1..89, порядковый, НЕ ГИБДД).
* Use App\Support\RussianRegions::nameToCode() for lookup.
*/
protected function seedPhoneRange(
int $defCode,
@@ -94,25 +95,25 @@ abstract class ImitationTestCase extends TestCase
// used non-existent columns (range_from/range_to/region_name) and omitted
// import_id, so every Россвязь-branch test that called it failed at runtime.
$importId = DB::table('phone_ranges_imports')->insertGetId([
'imported_at' => now(),
'source_url' => 'test://rossvyaz',
'rows_inserted' => 1,
'rows_updated' => 0,
'imported_at' => now(),
'source_url' => 'test://rossvyaz',
'rows_inserted' => 1,
'rows_updated' => 0,
'checksum_sha256' => str_repeat('0', 64),
'status' => 'completed',
'completed_at' => now(),
'status' => 'completed',
'completed_at' => now(),
]);
DB::table('phone_ranges')->insert([
'def_code' => $defCode,
'from_num' => $from,
'to_num' => $to,
'operator' => 'test-operator',
'region' => \App\Support\RussianRegions::CODE_TO_NAME[$subjectCode] ?? 'test-region',
'def_code' => $defCode,
'from_num' => $from,
'to_num' => $to,
'operator' => 'test-operator',
'region' => RussianRegions::CODE_TO_NAME[$subjectCode] ?? 'test-region',
'region_normalized' => null,
'subject_code' => $subjectCode,
'imported_at' => now(),
'import_id' => $importId,
'subject_code' => $subjectCode,
'imported_at' => now(),
'import_id' => $importId,
]);
}
}
+12 -12
View File
@@ -27,12 +27,12 @@ final class LeadInjector
/**
* Инъектировать заявку с сигналом «сайт».
*
* @param string $domain Домен сигнала (например, 'vashinvestor.ru').
* Составляет identifier в project-поле: "{$platform}_{$domain}".
* @param string $phone Телефон в формате 7XXXXXXXXXX.
* @param string|null $tag Тег региона (например, 'Москва'); может быть null.
* @param string $platform Префикс платформы: 'B1', 'B2', 'B3' или иное ( DIRECT).
* @param int|null $vid Внешний ID заявки поставщика. Если null генерируется уникальный.
* @param string $domain Домен сигнала (например, 'vashinvestor.ru').
* Составляет identifier в project-поле: "{$platform}_{$domain}".
* @param string $phone Телефон в формате 7XXXXXXXXXX.
* @param string|null $tag Тег региона (например, 'Москва'); может быть null.
* @param string $platform Префикс платформы: 'B1', 'B2', 'B3' или иное ( DIRECT).
* @param int|null $vid Внешний ID заявки поставщика. Если null генерируется уникальный.
*/
public function site(
string $domain,
@@ -49,12 +49,12 @@ final class LeadInjector
/**
* Инъектировать заявку с сигналом «звонок».
*
* @param string $number Номер телефона для call-сигнала (7XXXXXXXXXX).
* Составляет identifier в project-поле: "{$platform}_{$number}".
* @param string $phone Телефон звонящего в формате 7XXXXXXXXXX.
* @param string|null $tag Тег региона; может быть null.
* @param string $platform Префикс платформы: 'B1', 'B2', 'B3' или иное ( DIRECT).
* @param int|null $vid Внешний ID заявки поставщика. Если null генерируется уникальный.
* @param string $number Номер телефона для call-сигнала (7XXXXXXXXXX).
* Составляет identifier в project-поле: "{$platform}_{$number}".
* @param string $phone Телефон звонящего в формате 7XXXXXXXXXX.
* @param string|null $tag Тег региона; может быть null.
* @param string $platform Префикс платформы: 'B1', 'B2', 'B3' или иное ( DIRECT).
* @param int|null $vid Внешний ID заявки поставщика. Если null генерируется уникальный.
*/
public function call(
string $number,
+24
View File
@@ -4,6 +4,30 @@
**Файл схемы:** `schema.sql` (текущая версия — v8.41, консолидированная — разворачивает БД с нуля).
## v8.42 (2026-06-18) — G1/SP1 самозапись клиента: код подтверждения почты в email_verifications
Backend самозаписи клиента (находка go-live G1, под-проект SP1). В таблицу
`email_verifications` добавлены поля под 6-значный код подтверждения почты —
самозапись создаёт тенанта в статусе `pending_email_confirm` и подтверждает
почту кодом (механика зеркалит `impersonation_tokens`).
Спека: `docs/superpowers/specs/2026-06-18-g1-sp1-self-registration-email-spec-v3.md`.
План: `docs/superpowers/plans/2026-06-18-g1-sp1-self-registration-email-plan-v7.md`.
Миграция: `app/database/migrations/2026_06_18_120000_add_code_fields_to_email_verifications.php`.
**Добавлено:**
- **`email_verifications.code_hash`** — `VARCHAR(255)`, bcrypt-хеш 6-значного кода
(plain в БД не хранится). `token` остаётся внутренним UUID строки.
- **`email_verifications.failed_attempts`** — `SMALLINT NOT NULL DEFAULT 0`, лимит 5
неверных вводов (как `impersonation_tokens.failed_attempts`). TTL строки — 15 минут
(`expires_at`). Счётчики таблиц/индексов/RLS/функций/триггеров в шапке схемы — без изменений.
- **GRANT SELECT, INSERT, UPDATE** на `email_verifications` для 4 ролей (самозапись пишет
через BYPASSRLS — нет tenant-GUC на публичном роуте).
- **`tenants.status` расширен `VARCHAR(20)``VARCHAR(30)`** — латентный дефект схемы: CHECK
допускал `'pending_email_confirm'` (21 символ), но колонка была 20 → значение не влезало.
Самозапись (G1/SP1) первой реально ставит этот статус (`ALTER COLUMN status TYPE VARCHAR(30)`).
## v8.41 (2026-06-17) — F-P1 / 152-ФЗ retention: partial index deals(deleted_at)
Частичный индекс для ретеншена ПДн удалённых лидов. Команда
+6 -3
View File
@@ -1,6 +1,7 @@
-- =============================================================================
-- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра»)
-- Версия: v8.41 (17.06.2026 — F-P1 / 152-ФЗ ретеншен: partial index deals(deleted_at) WHERE deleted_at IS NOT NULL для команды pd:scrub-soft-deleted-deals (анонимизация ПДн soft-deleted сделок по retention-сроку из system_settings). Миграция 2026_06_17_120000, спека docs/superpowers/specs/2026-06-17-fp1-deal-pii-retention-spec.md)
-- Версия: v8.42 (18.06.2026 — G1/SP1 самозапись клиента: email_verifications +code_hash +failed_attempts (6-значный код подтверждения почты, bcrypt, TTL 15м, 5 попыток) + tenants.status расширен VARCHAR(20)→(30) (латентный дефект: CHECK допускал 21-символьное 'pending_email_confirm', колонка была 20). Счётчики таблиц/индексов/RLS/функций/триггеров без изменений. Миграция 2026_06_18_120000, спека docs/superpowers/specs/2026-06-18-g1-sp1-self-registration-email-spec-v3.md)
-- Базовая версия: v8.41 (17.06.2026 — F-P1 / 152-ФЗ ретеншен: partial index deals(deleted_at) WHERE deleted_at IS NOT NULL для команды pd:scrub-soft-deleted-deals (анонимизация ПДн soft-deleted сделок по retention-сроку из system_settings). Миграция 2026_06_17_120000, спека docs/superpowers/specs/2026-06-17-fp1-deal-pii-retention-spec.md)
-- Базовая версия: v8.40 (31.05.2026 — lead region resolution Session 1: phone_ranges_imports + phone_ranges (реестр Россвязи, SaaS-level без RLS, idx_phone_ranges_lookup), lead_region_resolution_log (PARTITION BY RANGE (received_at), composite PK (id, received_at), аудит резолва региона на лид), supplier_leads +4 колонки (resolved_subject_code/region_source/dadata_qc/phone_operator), deals +2 колонки (phone_operator/region_substituted). MonthlyPartitionManager +entry, retention 12m. Миграция 2026_05_31_100000, план docs/superpowers/plans/2026-05-29-lead-region-resolution.md. DDL — в дельта-миграции, не в теле (как v8.39))
-- Базовая версия: v8.39 (27.05.2026 — project_routing_snapshots: новая партиционированная таблица снимков маршрутизации (PARTITION BY RANGE (snapshot_date)), composite PK (snapshot_date, project_id), FK tenant_id→tenants, RLS tenant isolation, MonthlyPartitionManager +entry, retention 3m. Slepok routing Этап 2)
-- Базовая версия: v8.38 (26.05.2026 — projects.paused_at TIMESTAMPTZ + projects_paused_at_idx: anchor для SupplierSnapshotGuard. Защита от убытка при удалении/смене источника проекта, пока поставщик может прислать лиды по уже сделанному слепку — docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md)
@@ -616,7 +617,9 @@ CREATE TABLE email_verifications (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL, -- FK добавлен после CREATE TABLE users
email VARCHAR(255) NOT NULL,
token VARCHAR(255) UNIQUE NOT NULL,
token VARCHAR(255) UNIQUE NOT NULL, -- внутр. uuid строки; секрет — code_hash
code_hash VARCHAR(255), -- v8.42: bcrypt 6-значного кода самозаписи (G1/SP1)
failed_attempts SMALLINT NOT NULL DEFAULT 0, -- v8.42: лимит 5 неверных вводов
expires_at TIMESTAMPTZ NOT NULL,
verified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
@@ -635,7 +638,7 @@ CREATE TABLE tenants (
subdomain VARCHAR(63) UNIQUE NOT NULL, -- "client1"
organization_name VARCHAR(255) NOT NULL,
contact_email VARCHAR(255) NOT NULL,
status VARCHAR(20) DEFAULT 'active'
status VARCHAR(30) DEFAULT 'active' -- v8.42: 20→30, 'pending_email_confirm' = 21 симв.
CHECK (status IN ('active','suspended','pending_email_confirm','deleted')),
-- webhook_token / webhook_token_rotated_at удалены в v8.35 (legacy direct webhook removal)
timezone VARCHAR(50) DEFAULT 'Europe/Moscow',
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,121 @@
# G1 / SP1 — Самозапись клиента с подтверждением почты (backend)
**Дата:** 18.06.2026. Часть находки **G1** (самозапись клиента), под-проект **SP1** (backend, проверяемо Pest).
**Связанные:** SP2 (реквизиты + гейт первого проекта), SP3 (фронт). Здесь — только SP1.
## Цель
Превратить регистрацию из «прицепить нового пользователя к первому существующему тенанту» в настоящую
самозапись: посетитель вводит почту, пароль и проходит капчу → создаётся **новый** тенант в состоянии
ожидания подтверждения почты и его владелец-пользователь → на почту уходит 6-значный код → ввод верного
кода активирует тенанта и пользователя, начисляет стартовый баланс 300 ₽ и выполняет вход.
Реквизиты юр.лица/ИП/физлица, тяга по ИНН и блокировка создания первого проекта до заполнения реквизитов —
**вне** SP1 (это SP2). Фронтенд — вне SP1 (SP3). Реальный провайдер капчи — вне SP1 (подключается позже).
## Контракт register {#D1}
`POST /api/auth/register` (публичный, throttled). Тело: `email`, `password`, `accept_offer`, `accept_pdn`,
`captcha_token`.
- Валидация: `email` — корректный, ≤255; `password` — текущие правила проекта (`HasPasswordRules`);
`accept_offer`/`accept_pdn``accepted`; `captcha_token` — обязателен, строка.
- Капча проверяется через шов `CaptchaVerifier` (см. {#D5}); провал → `422` с ошибкой по полю `captcha_token`.
- Уникальность email: если активный пользователь с таким email уже существует → `422` «Аккаунт с таким
email уже существует». Если существует **неподтверждённый** (тенант в состоянии ожидания подтверждения) →
это путь повторной отправки кода (resend), а не создание дубля: код перегенерируется, тенант/пользователь
не дублируются.
- Успех (новый email): создаётся тенант в состоянии ожидания подтверждения почты с уникальным `subdomain`
(см. {#D6}), `organization_name` = временный плейсхолдер на основе email (уточняется в SP2), `balance_rub`
= 0, пробный режим; создаётся владелец-пользователь, неактивный (`is_active=false`); генерируется код и
пишется строка подтверждения почты (см. {#D4}); код отправляется письмом.
- Вход на этом шаге **не выполняется**. Ответ `201`: состояние «ожидает подтверждения», email, срок
годности кода. На окружениях `local`/`testing` ответ дополнительно содержит plain-код для тестов (как это
уже делает init impersonation-флоу), на prod — нет.
## Контракт confirm-email {#D2}
`POST /api/auth/confirm-email` (публичный, throttled). Тело: `email`, `code`.
- Поиск последней непогашенной строки подтверждения по email через BYPASSRLS-подключение (на публичном
роуте нет tenant-контекста — см. {#D7}).
- Если строки нет / срок истёк / превышены попытки / код не совпал → `422`; при несовпадении —
`failed_attempts++`, на 5-й неудаче строка инвалидируется (требуется resend).
- Успех: помечается `verified_at`; владелец-пользователь становится активным; тенант переходит в активное
состояние; начисляется `balance_rub = 300.00`; выполняется `Auth::login` + регенерация сессии; пишется
лог успешной регистрации. Ответ `200`: пользователь + `requires_2fa: false`.
## Контракт resend-code {#D3}
`POST /api/auth/resend-code` (публичный, throttled + cooldown). Тело: `email`.
- Anti-enumeration: ответ унифицирован независимо от существования аккаунта (как forgot-password флоу).
- Для существующего неподтверждённого аккаунта — старый код инвалидируется, генерируется новый, уходит
письмом. Для активного/несуществующего — ничего не делается, ответ тот же.
- Cooldown между отправками защищает от спама писем.
## Изменения схемы email_verifications {#D4}
Таблица `email_verifications` уже существует (id, user_id, email, token UNIQUE, expires_at, verified_at,
created_at). Расширяется под код-флоу:
- `+ code_hash VARCHAR(255)` — bcrypt 6-значного кода (plain-код в БД не хранится).
- `+ failed_attempts SMALLINT NOT NULL DEFAULT 0` — счётчик неверных вводов; лимит 5 (как у
impersonation-токена).
- `token` сохраняется как внутренний уникальный идентификатор строки (UUID), пользовательский секрет — это
код в `code_hash`. TTL строки — 15 минут (`expires_at`).
- Состояние тенанта при ожидании подтверждения — уже предусмотренное схемой значение статуса
`pending_email_confirm`; новых колонок в `tenants` не требуется.
Правка `db/schema.sql` сопровождается записью в `db/CHANGELOG_schema.md` и Laravel-миграцией.
## Капча: шов CaptchaVerifier {#D5}
Капча в SP1 — это серверный шов, чтобы поток оставался проверяемым Pest без внешних ключей.
- Интерфейс `CaptchaVerifier` с методом проверки токена (возвращает bool/исход).
- Драйвер по умолчанию для dev/testing — нулевой (`NullCaptchaVerifier`), конфигурируемый на пропуск/провал
через конфиг, чтобы тест мог проверить обе ветки.
- Конфиг `services.captcha` (driver + место под ключи). Реальный драйвер Yandex SmartCaptcha — задокументи-
рованный TODO, подключается позже (SP3/ops), интерфейс под него спроектирован сразу.
- Контроллер/сервис регистрации зовёт только интерфейс, не зная провайдера.
## Генерация subdomain {#D6}
`tenants.subdomain``UNIQUE NOT NULL`, ≤63. При самозаписи известен только email.
- База: локальная часть email, приведённая к `[a-z0-9]` (прочее отбрасывается/заменяется).
- Уникальность: при коллизии добавляется короткий суффикс (счётчик/случайный), пока значение свободно.
- Fallback при пустой базе — `client` + случайный суффикс. Длина усечена до 63.
## Edge-cases и безопасность {#D7}
- Публичные роуты register/confirm/resend не проходят middleware с tenant-контекстом → запись/чтение
`email_verifications` (RLS-таблица) идёт через BYPASSRLS-подключение `pgsql_supplier`, как уже устроено в
impersonation-контроллере.
- Код 6-значный (`100000..999999`), хранится только в виде хеша; сравнение через `Hash::check`.
- Брошенные неподтверждённые тенанты — уборка вынесена в опциональный follow-up (команда
`auth:prune-unconfirmed`), не входит в ядро SP1.
- Стартовый баланс 300 ₽ начисляется **в момент подтверждения** (активации), не при создании заявки —
неподтверждённый аккаунт денег не получает.
- Throttle на всех трёх роутах + cooldown на resend — против перебора кода и спама писем.
## Критерии приёмки (Pest) {#D8}
- register с новым email и валидным captcha_token → тенант в ожидании подтверждения, владелец неактивен,
строка кода создана; на testing виден plain-код; вход не выполнен.
- register с невалидным captcha_token → `422`, тенант/пользователь не создаются.
- confirm верным кодом → тенант активен, пользователь активен, `balance_rub = 300.00`, выполнен вход.
- confirm неверным кодом → `422`, `failed_attempts` увеличился; на 5-й неудаче строка инвалидирована.
- confirm протухшим кодом → `422`; resend выдаёт новый рабочий код.
- повторный register на неподтверждённый email → resend без второго тенанта.
- register на активный email → `422`.
- resend на несуществующий/активный email → унифицированный ответ (anti-enumeration).
```verified-context-json
[
{"id":"D2","kind":"EXTRACTED","ref":"app/app/Http/Controllers/Api/AuthController.php","anchor":"Tenant::first()"},
{"id":"D4","kind":"EXTRACTED","ref":"db/schema.sql","anchor":"pending_email_confirm"},
{"id":"D1","kind":"EXTRACTED","ref":"app/app/Http/Controllers/Api/ImpersonationController.php","anchor":"random_int(100_000, 999_999)"}
]
```