feat: G1/SP1 самозапись клиента с подтверждением почты 6-значным кодом
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 цифр.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)"}
|
||||
]
|
||||
```
|
||||
Reference in New Issue
Block a user