Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a85cf3d32b | |||
| 50b789b69f | |||
| c84f8c4373 | |||
| 705f35623c | |||
| 888f737c88 | |||
| 17ff7f8f04 | |||
| 251bf83aac | |||
| 0e31783036 | |||
| b888eb440a | |||
| 89c217a34f | |||
| 64bbe4f7c2 | |||
| 5745917efe | |||
| 564d984f2a | |||
| fdff36c553 | |||
| 3711a92958 |
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Support\SupplierIdentifier;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Однократный back-fill: для каждого site-проекта с identifier-субдоменом —
|
||||
* добавить линки к supplier_projects на корневом домене (если те уже существуют).
|
||||
*
|
||||
* Идемпотентна (insertOrIgnore эквивалент: явная проверка наличия + DEFAULT NOW
|
||||
* на created_at в схеме).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-22-root-domain-auto-link-design.md §4.3
|
||||
*/
|
||||
class BackfillRootSupplierLinksCommand extends Command
|
||||
{
|
||||
protected $signature = 'supplier:backfill-root-links {--dry-run : показать что бы добавилось, не писать в БД}';
|
||||
|
||||
protected $description = 'Back-fill линков project_supplier_links к корневому домену для проектов-субдоменов';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$scanned = 0;
|
||||
$added = 0;
|
||||
$skippedAlreadyRoot = 0;
|
||||
$skippedNoRootSp = 0;
|
||||
|
||||
$projects = Project::on('pgsql_supplier')
|
||||
->where('signal_type', 'site')
|
||||
->whereExists(function ($q): void {
|
||||
$q->select(DB::raw(1))
|
||||
->from('project_supplier_links')
|
||||
->whereColumn('project_supplier_links.project_id', 'projects.id');
|
||||
})
|
||||
->get(['id', 'signal_identifier']);
|
||||
|
||||
foreach ($projects as $project) {
|
||||
$scanned++;
|
||||
$root = SupplierIdentifier::extractRootDomain((string) $project->signal_identifier);
|
||||
if ($root === null) {
|
||||
$skippedAlreadyRoot++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$rootSps = SupplierProject::on('pgsql_supplier')
|
||||
->where('unique_key', $root)
|
||||
->where('signal_type', 'site')
|
||||
->get();
|
||||
|
||||
if ($rootSps->isEmpty()) {
|
||||
$skippedNoRootSp++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($rootSps as $rootSp) {
|
||||
$alreadyExists = DB::connection('pgsql_supplier')
|
||||
->table('project_supplier_links')
|
||||
->where('project_id', $project->id)
|
||||
->where('supplier_project_id', $rootSp->id)
|
||||
->exists();
|
||||
|
||||
if ($alreadyExists) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
DB::connection('pgsql_supplier')->table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $rootSp->id,
|
||||
'platform' => $rootSp->platform,
|
||||
'subject_code' => null,
|
||||
]);
|
||||
}
|
||||
$added++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'%sScanned: %d / Added: %d / Skipped (already root): %d / Skipped (no root sp): %d',
|
||||
$dryRun ? '[DRY-RUN] ' : '',
|
||||
$scanned,
|
||||
$added,
|
||||
$skippedAlreadyRoot,
|
||||
$skippedNoRootSp,
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,16 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\LoginRequest;
|
||||
use App\Http\Requests\Auth\RegisterRequest;
|
||||
use App\Http\Requests\Auth\RegisterStartRequest;
|
||||
use App\Http\Requests\Auth\RegisterVerifyRequest;
|
||||
use App\Mail\RegisterEmailVerificationCode;
|
||||
use App\Mail\SuspiciousLoginNotification;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\NotificationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
@@ -56,6 +59,21 @@ class AuthController extends Controller
|
||||
/** Лимит неудач входа с одного IP за час (ТЗ §22.4.4 п.2). */
|
||||
private const IP_LOCKOUT_THRESHOLD = 10;
|
||||
|
||||
/** Лимит отправок кода регистрации на email|ip за час. */
|
||||
private const REGISTER_MAX_SENDS = 5;
|
||||
|
||||
/** Окно лимита отправок (сек). */
|
||||
private const REGISTER_SEND_DECAY = 3600;
|
||||
|
||||
/** Срок жизни кода подтверждения (мин). */
|
||||
private const CODE_TTL_MINUTES = 15;
|
||||
|
||||
/** Лимит неверных вводов кода до сброса pending. */
|
||||
private const CODE_MAX_ATTEMPTS = 5;
|
||||
|
||||
/** Cooldown между повторными отправками кода (сек). */
|
||||
private const RESEND_COOLDOWN_SECONDS = 60;
|
||||
|
||||
public function login(LoginRequest $request): JsonResponse
|
||||
{
|
||||
$credentials = $request->only(['email', 'password']);
|
||||
@@ -128,10 +146,84 @@ class AuthController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function register(RegisterRequest $request): JsonResponse
|
||||
/**
|
||||
* Шаг 1 регистрации: валидирует форму, генерирует 6-значный код,
|
||||
* кладёт pending-данные в session, шлёт код письмом. Аккаунт НЕ создаётся.
|
||||
*/
|
||||
public function registerStart(RegisterStartRequest $request): JsonResponse
|
||||
{
|
||||
// На MVP — attach нового user'а к первому tenant'у (для UI-разводки).
|
||||
// Production: wizard с tenant_name + ИНН + создание Tenant + первый user owner-роли.
|
||||
$email = mb_strtolower($request->string('email')->toString());
|
||||
$key = 'auth:register:'.$email.'|'.($request->ip() ?? 'unknown');
|
||||
|
||||
if (RateLimiter::tooManyAttempts($key, self::REGISTER_MAX_SENDS)) {
|
||||
return $this->lockoutResponse($key);
|
||||
}
|
||||
RateLimiter::hit($key, self::REGISTER_SEND_DECAY);
|
||||
|
||||
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
|
||||
|
||||
$request->session()->put('registration.pending', [
|
||||
'email' => $email,
|
||||
'password_hash' => Hash::make($request->string('password')->toString()),
|
||||
'phone' => $request->string('phone')->toString(),
|
||||
'code_hash' => hash('sha256', $code),
|
||||
'expires_at' => now()->addMinutes(self::CODE_TTL_MINUTES)->toIso8601String(),
|
||||
'attempts' => 0,
|
||||
'last_sent_at' => now()->toIso8601String(),
|
||||
]);
|
||||
|
||||
Mail::to($email)->send(new RegisterEmailVerificationCode($code));
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Код подтверждения отправлен на указанный email.',
|
||||
'email' => $email,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Шаг 2 регистрации: проверяет код из session-pending; при успехе создаёт
|
||||
* аккаунт с email_verified_at=now() и логинит пользователя.
|
||||
*
|
||||
* Все ошибки кода отдаём под ключом 'code' (422) — единая точка показа на фронте.
|
||||
*/
|
||||
public function registerVerify(RegisterVerifyRequest $request): JsonResponse
|
||||
{
|
||||
$pending = $request->session()->get('registration.pending');
|
||||
|
||||
if (! is_array($pending)) {
|
||||
return $this->codeError('Регистрация не начата или истекла. Начните заново.');
|
||||
}
|
||||
|
||||
if (now()->greaterThan(Carbon::parse($pending['expires_at']))) {
|
||||
$request->session()->forget('registration.pending');
|
||||
|
||||
return $this->codeError('Срок действия кода истёк. Запросите новый код.');
|
||||
}
|
||||
|
||||
if ($pending['attempts'] >= self::CODE_MAX_ATTEMPTS) {
|
||||
$request->session()->forget('registration.pending');
|
||||
|
||||
return $this->codeError('Слишком много попыток. Начните регистрацию заново.');
|
||||
}
|
||||
|
||||
$input = $request->string('code')->toString();
|
||||
if (! hash_equals($pending['code_hash'], hash('sha256', $input))) {
|
||||
$pending['attempts']++;
|
||||
$request->session()->put('registration.pending', $pending);
|
||||
|
||||
return $this->codeError('Неверный код.');
|
||||
}
|
||||
|
||||
// Код верен. Перепроверяем уникальность email (гонка между start и verify).
|
||||
if (User::where('email', $pending['email'])->exists()) {
|
||||
$request->session()->forget('registration.pending');
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Аккаунт с таким email уже существует.',
|
||||
'errors' => ['email' => ['Аккаунт с таким email уже существует.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$tenant = Tenant::first();
|
||||
if (! $tenant) {
|
||||
return response()->json([
|
||||
@@ -141,14 +233,17 @@ class AuthController extends Controller
|
||||
|
||||
$user = User::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'email' => $request->string('email')->toString(),
|
||||
'password_hash' => Hash::make($request->string('password')->toString()),
|
||||
'email' => $pending['email'],
|
||||
'password_hash' => $pending['password_hash'],
|
||||
'phone' => $pending['phone'],
|
||||
'first_name' => 'Новый',
|
||||
'last_name' => 'Пользователь',
|
||||
'is_active' => true,
|
||||
'totp_enabled' => false,
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
$request->session()->forget('registration.pending');
|
||||
Auth::login($user);
|
||||
$request->session()->regenerate();
|
||||
|
||||
@@ -339,6 +434,57 @@ class AuthController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Повторная отправка кода: перегенерирует код, обновляет срок, шлёт письмо.
|
||||
* Cooldown RESEND_COOLDOWN_SECONDS между отправками (429 при нарушении).
|
||||
*/
|
||||
public function registerResend(Request $request): JsonResponse
|
||||
{
|
||||
$pending = $request->session()->get('registration.pending');
|
||||
|
||||
if (! is_array($pending)) {
|
||||
return $this->codeError('Регистрация не начата или истекла. Начните заново.');
|
||||
}
|
||||
|
||||
$elapsed = now()->getTimestamp() - Carbon::parse($pending['last_sent_at'])->getTimestamp();
|
||||
if ($elapsed < self::RESEND_COOLDOWN_SECONDS) {
|
||||
$retry = self::RESEND_COOLDOWN_SECONDS - $elapsed;
|
||||
|
||||
return response()->json([
|
||||
'message' => "Повторная отправка возможна через {$retry} сек.",
|
||||
'retry_after' => $retry,
|
||||
], 429)->header('Retry-After', (string) $retry);
|
||||
}
|
||||
|
||||
// Общий часовой лимит отправок — как у registerStart (spec §7.6):
|
||||
// cooldown не даёт спамить чаще 1/мин, лимит не даёт превысить 5/час.
|
||||
$key = 'auth:register:'.$pending['email'].'|'.($request->ip() ?? 'unknown');
|
||||
if (RateLimiter::tooManyAttempts($key, self::REGISTER_MAX_SENDS)) {
|
||||
return $this->lockoutResponse($key);
|
||||
}
|
||||
RateLimiter::hit($key, self::REGISTER_SEND_DECAY);
|
||||
|
||||
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
|
||||
$pending['code_hash'] = hash('sha256', $code);
|
||||
$pending['expires_at'] = now()->addMinutes(self::CODE_TTL_MINUTES)->toIso8601String();
|
||||
$pending['last_sent_at'] = now()->toIso8601String();
|
||||
$pending['attempts'] = 0;
|
||||
$request->session()->put('registration.pending', $pending);
|
||||
|
||||
Mail::to($pending['email'])->send(new RegisterEmailVerificationCode($code));
|
||||
|
||||
return response()->json(['message' => 'Новый код отправлен на ваш email.']);
|
||||
}
|
||||
|
||||
/** 422 с ошибкой под ключом 'code'. */
|
||||
private function codeError(string $message): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => $message,
|
||||
'errors' => ['code' => [$message]],
|
||||
], 422);
|
||||
}
|
||||
|
||||
/** 429 Too Many Requests + Retry-After header (секунды до следующей попытки). */
|
||||
private function lockoutResponse(string $throttleKey): JsonResponse
|
||||
{
|
||||
|
||||
@@ -8,7 +8,7 @@ trait HasPasswordRules
|
||||
{
|
||||
/**
|
||||
* Правила валидации поля password.
|
||||
* Используется в LoginRequest и RegisterRequest для DRY.
|
||||
* Используется в LoginRequest и RegisterStartRequest для DRY.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
|
||||
+17
-4
@@ -5,24 +5,35 @@ declare(strict_types=1);
|
||||
namespace App\Http\Requests\Auth;
|
||||
|
||||
use App\Http\Requests\Auth\Concerns\HasPasswordRules;
|
||||
use App\Services\PhoneNormalizer;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* Валидация POST /api/auth/register.
|
||||
* Валидация шага 1 регистрации (POST /api/auth/register/start).
|
||||
*
|
||||
* По ТЗ §1.5/§4.1: 2 обязательных click-wrap'а — оферта + согласие на ПДн
|
||||
* (3-й «маркетинговый» из handoff НЕ требуется — расхождение #2 реестра v1.13).
|
||||
* Телефон нормализуется в prepareForValidation() к 7XXXXXXXXXX до проверки
|
||||
* (если нормализовать нельзя — остаётся как есть и падает на regex).
|
||||
* По ТЗ §1.5/§4.1: два обязательных click-wrap'а (оферта + ПДн).
|
||||
*/
|
||||
class RegisterRequest extends FormRequest
|
||||
class RegisterStartRequest extends FormRequest
|
||||
{
|
||||
use HasPasswordRules;
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$normalized = PhoneNormalizer::normalize((string) $this->input('phone', ''));
|
||||
if ($normalized !== null) {
|
||||
$this->merge(['phone' => $normalized]);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
|
||||
'phone' => ['required', 'string', 'regex:/^7\d{10}$/'],
|
||||
'password' => $this->passwordRules(),
|
||||
'accept_offer' => ['required', 'accepted'],
|
||||
'accept_pdn' => ['required', 'accepted'],
|
||||
@@ -36,6 +47,8 @@ class RegisterRequest extends FormRequest
|
||||
'email.required' => 'Укажите email.',
|
||||
'email.email' => 'Email указан некорректно.',
|
||||
'email.unique' => 'Аккаунт с таким email уже существует.',
|
||||
'phone.required' => 'Укажите номер телефона.',
|
||||
'phone.regex' => 'Телефон указан некорректно. Формат: +7 (XXX) XXX-XX-XX.',
|
||||
'accept_offer.accepted' => 'Необходимо принять оферту.',
|
||||
'accept_pdn.accepted' => 'Необходимо согласие на обработку персональных данных.',
|
||||
]);
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Auth;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* Валидация шага 2 регистрации (POST /api/auth/register/verify).
|
||||
*/
|
||||
class RegisterVerifyRequest extends FormRequest
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'code' => ['required', 'string', 'regex:/^\d{6}$/'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'code.required' => 'Введите код из письма.',
|
||||
'code.regex' => 'Код состоит из 6 цифр.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ use App\Services\Supplier\SupplierPortalClient;
|
||||
use App\Services\Supplier\SupplierProjectGrouping;
|
||||
use App\Services\Supplier\SupplierQuotaAllocator;
|
||||
use App\Support\RussianRegions;
|
||||
use App\Support\SupplierIdentifier;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@@ -425,6 +426,31 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-link к корневому домену (spec 2026-05-22-root-domain-auto-link-design §4.2).
|
||||
// groupProjects шарят identifier — root один на всю группу.
|
||||
$firstProject = $groupProjects[0] ?? null;
|
||||
if ($firstProject !== null && $firstProject->signal_type === 'site') {
|
||||
$rootIdentifier = SupplierIdentifier::extractRootDomain(
|
||||
(string) $firstProject->signal_identifier
|
||||
);
|
||||
if ($rootIdentifier !== null) {
|
||||
$rootSps = SupplierProject::on(self::DB_CONNECTION)
|
||||
->where('unique_key', $rootIdentifier)
|
||||
->where('signal_type', 'site')
|
||||
->get();
|
||||
foreach ($groupProjects as $lp) {
|
||||
foreach ($rootSps as $rootSp) {
|
||||
DB::connection(self::DB_CONNECTION)->table('project_supplier_links')->insertOrIgnore([
|
||||
'project_id' => $lp->id,
|
||||
'supplier_project_id' => $rootSp->id,
|
||||
'platform' => $rootSp->platform,
|
||||
'subject_code' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,6 +15,7 @@ use App\Services\Supplier\SupplierExportMode;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use App\Services\Supplier\SupplierProjectGrouping;
|
||||
use App\Support\RussianRegions;
|
||||
use App\Support\SupplierIdentifier;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@@ -317,6 +318,29 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
]);
|
||||
}
|
||||
|
||||
// Auto-link к корневому домену (spec 2026-05-22-root-domain-auto-link-design §4.2).
|
||||
// Если signal_type=site и identifier — субдомен (carmoney.ru → corner; krasnoyarsk.carmoney.ru → subdomain),
|
||||
// дополнительно подключаем sp с unique_key=root, если такие существуют.
|
||||
if ($project->signal_type === 'site') {
|
||||
$rootIdentifier = SupplierIdentifier::extractRootDomain(
|
||||
(string) $project->signal_identifier
|
||||
);
|
||||
if ($rootIdentifier !== null) {
|
||||
$rootSps = SupplierProject::on(self::DB_CONNECTION)
|
||||
->where('unique_key', $rootIdentifier)
|
||||
->where('signal_type', 'site')
|
||||
->get();
|
||||
foreach ($rootSps as $rootSp) {
|
||||
DB::connection(self::DB_CONNECTION)->table('project_supplier_links')->insertOrIgnore([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $rootSp->id,
|
||||
'platform' => $rootSp->platform,
|
||||
'subject_code' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror the link into the legacy FK columns (supplier_b{1,2,3}_project_id) so the
|
||||
// UI sync-status (ProjectResource → aggregateSyncStatus, which reads supplierB1/B2/B3)
|
||||
// reflects the synced stack in online mode too — online primarily uses the pivot.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?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-значным кодом подтверждения email при регистрации.
|
||||
*
|
||||
* На dev (MAIL_MAILER=log) — пишется в storage/logs/laravel.log.
|
||||
* На prod/pilot — Яндекс SMTP (см. app/.env MAIL_*).
|
||||
*/
|
||||
class RegisterEmailVerificationCode extends Mailable
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(public string $code) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Код подтверждения регистрации — Лидерра',
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.register_verification_code',
|
||||
with: ['code' => $this->code],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ class User extends Authenticatable
|
||||
'is_active',
|
||||
'last_login_at',
|
||||
'last_active_at',
|
||||
'email_verified_at',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
/**
|
||||
* Нормализация телефонного номера РФ к каноническому виду 7XXXXXXXXXX
|
||||
* (консистентно с App\Services\PhonePrefixService, ожидающим ^7\d{10}$).
|
||||
*
|
||||
* Принимает любые человеко-вводимые формы: +7 (XXX) ..., 8 XXX ...,
|
||||
* голые 10 или 11 цифр, с пробелами/скобками/дефисами. Возвращает null,
|
||||
* если после очистки невозможно получить валидный 11-значный номер с ведущей 7.
|
||||
*/
|
||||
class PhoneNormalizer
|
||||
{
|
||||
public static function normalize(string $raw): ?string
|
||||
{
|
||||
$digits = preg_replace('/\D+/', '', $raw) ?? '';
|
||||
|
||||
if (strlen($digits) === 11 && $digits[0] === '8') {
|
||||
$digits = '7'.substr($digits, 1);
|
||||
} elseif (strlen($digits) === 10) {
|
||||
$digits = '7'.$digits;
|
||||
}
|
||||
|
||||
return preg_match('/^7\d{10}$/', $digits) === 1 ? $digits : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Утилиты для работы с identifier'ами поставщика (supplier_projects.unique_key).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-22-root-domain-auto-link-design.md §4.1
|
||||
*/
|
||||
class SupplierIdentifier
|
||||
{
|
||||
/**
|
||||
* Извлекает корневой домен из identifier'а проекта.
|
||||
*
|
||||
* Правило: если identifier выглядит как домен с ≥3 сегментами через точку —
|
||||
* вернуть последние 2 сегмента. Иначе (уже корень, телефон, sms-ключ) — null.
|
||||
*
|
||||
* Public-suffix-list (.co.uk и т.п.) НЕ поддерживается — у проекта только
|
||||
* .ru/.рф/.com, для которых правило «2 последних сегмента» корректно.
|
||||
*/
|
||||
public static function extractRootDomain(string $identifier): ?string
|
||||
{
|
||||
$trimmed = trim($identifier);
|
||||
if ($trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
if (! str_contains($trimmed, '.')) {
|
||||
return null;
|
||||
}
|
||||
$parts = explode('.', $trimmed);
|
||||
if (count($parts) < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return implode('.', array_slice($parts, -2));
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -38,7 +38,7 @@ deptrac:
|
||||
Job: [Service, Model, Repository, Mail, Exception]
|
||||
Console: [Service, Model, Repository, Job, Mail, Exception]
|
||||
Repository: [Model, Exception]
|
||||
Request: [Rule, Model]
|
||||
Request: [Rule, Model, Service]
|
||||
Resource: [Model]
|
||||
Rule: [Model]
|
||||
Mail: [Model]
|
||||
|
||||
+63
-27
@@ -627,7 +627,7 @@ parameters:
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 9
|
||||
count: 8
|
||||
path: tests/Feature/Auth/AuthControllerTest.php
|
||||
|
||||
-
|
||||
@@ -639,7 +639,7 @@ parameters:
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 14
|
||||
count: 10
|
||||
path: tests/Feature/Auth/AuthControllerTest.php
|
||||
|
||||
-
|
||||
@@ -738,6 +738,42 @@ parameters:
|
||||
count: 5
|
||||
path: tests/Feature/Auth/RecoveryCodeTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Auth/RegisterFlowTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:assertAuthenticatedAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Auth/RegisterFlowTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 17
|
||||
path: tests/Feature/Auth/RegisterFlowTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:travel\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Auth/RegisterFlowTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\|Pest\\Support\\HigherOrderTapProxy\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Auth/RegisterFlowTest.php
|
||||
|
||||
-
|
||||
message: '#^Variable \$this in PHPDoc tag @var does not match assigned variable \$payload\.$#'
|
||||
identifier: varTag.differentVariable
|
||||
count: 1
|
||||
path: tests/Feature/Auth/RegisterFlowTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -1629,7 +1665,7 @@ parameters:
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:mock\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 6
|
||||
count: 8
|
||||
path: tests/Feature/Plan5/Jobs/SyncSupplierProjectJobTest.php
|
||||
|
||||
-
|
||||
@@ -1656,6 +1692,24 @@ parameters:
|
||||
count: 14
|
||||
path: tests/Feature/Plan5/Projects/ProjectsUpdateTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 6
|
||||
path: tests/Feature/Project/ProjectCreateDedupTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:fail\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Project/ProjectCreateDedupTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Symfony\\Component\\HttpFoundation\\Response\:\:getData\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Project/ProjectCreateDedupTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -1920,6 +1974,12 @@ parameters:
|
||||
count: 1
|
||||
path: tests/Feature/Supplier/CsvReconcileJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:once\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Supplier/DeleteSupplierProjectJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:andThrow\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -1938,12 +1998,6 @@ parameters:
|
||||
count: 2
|
||||
path: tests/Feature/Supplier/FailoverProjectChannelLiveSmokeTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:once\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Supplier/DeleteSupplierProjectJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -2111,21 +2165,3 @@ parameters:
|
||||
identifier: argument.type
|
||||
count: 1
|
||||
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 6
|
||||
path: tests/Feature/Project/ProjectCreateDedupTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:fail\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Project/ProjectCreateDedupTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Symfony\\Component\\HttpFoundation\\Response\:\:getData\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Project/ProjectCreateDedupTest.php
|
||||
|
||||
@@ -45,22 +45,40 @@ export interface LoginResponse {
|
||||
requires_2fa: boolean;
|
||||
}
|
||||
|
||||
export interface RegisterPayload {
|
||||
export interface RegisterStartPayload {
|
||||
email: string;
|
||||
phone: string; // нормализованные цифры 7XXXXXXXXXX
|
||||
password: string;
|
||||
accept_offer: boolean;
|
||||
accept_pdn: boolean;
|
||||
}
|
||||
|
||||
export interface RegisterStartResponse {
|
||||
message: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export async function login(payload: LoginPayload): Promise<LoginResponse> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.post<LoginResponse>('/api/auth/login', payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function register(payload: RegisterPayload): Promise<LoginResponse> {
|
||||
export async function registerStart(payload: RegisterStartPayload): Promise<RegisterStartResponse> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.post<LoginResponse>('/api/auth/register', payload);
|
||||
const { data } = await apiClient.post<RegisterStartResponse>('/api/auth/register/start', payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function registerVerify(code: string): Promise<LoginResponse> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.post<LoginResponse>('/api/auth/register/verify', { code });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function registerResend(): Promise<{ message: string }> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.post<{ message: string }>('/api/auth/register/resend');
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import * as authApi from '../api/auth';
|
||||
import type { AuthUser, LoginPayload, RegisterPayload, ResetPasswordPayload } from '../api/auth';
|
||||
import type { AuthUser, LoginPayload, RegisterStartPayload, ResetPasswordPayload } from '../api/auth';
|
||||
import { extractRateLimitRetry } from '../api/client';
|
||||
|
||||
/**
|
||||
@@ -53,18 +53,36 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function register(payload: RegisterPayload) {
|
||||
async function registerStart(payload: RegisterStartPayload) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await authApi.register(payload);
|
||||
return await authApi.registerStart(payload);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function registerVerify(code: string) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await authApi.registerVerify(code);
|
||||
user.value = response.user;
|
||||
requires2fa.value = response.requires_2fa;
|
||||
requires2fa.value = false;
|
||||
return response;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function registerResend() {
|
||||
loading.value = true;
|
||||
try {
|
||||
return await authApi.registerResend();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestPasswordReset(email: string) {
|
||||
loading.value = true;
|
||||
lockoutSeconds.value = null;
|
||||
@@ -160,7 +178,9 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
lockoutSeconds,
|
||||
isAuthenticated,
|
||||
login,
|
||||
register,
|
||||
registerStart,
|
||||
registerVerify,
|
||||
registerResend,
|
||||
verifyTwoFactor,
|
||||
useRecoveryCode,
|
||||
requestPasswordReset,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Утилиты телефона РФ для формы регистрации.
|
||||
* Хранение/отправка — нормализованные цифры 7XXXXXXXXXX (как на backend).
|
||||
* Отображение — маска +7 (XXX) XXX-XX-XX.
|
||||
*/
|
||||
|
||||
/** Извлекает цифры и нормализует к 7XXXXXXXXXX (макс. 11 цифр). */
|
||||
export function phoneDigits(raw: string): string {
|
||||
let d = raw.replace(/\D+/g, '');
|
||||
if (d.length === 0) return '';
|
||||
if (d[0] === '8') d = '7' + d.slice(1);
|
||||
if (d[0] !== '7') d = '7' + d;
|
||||
return d.slice(0, 11);
|
||||
}
|
||||
|
||||
/** Прогрессивная маска: '' → '', '7912' → '+7 (912', полный → '+7 (NNN) NNN-NN-NN'. */
|
||||
export function formatPhone(raw: string): string {
|
||||
const d = phoneDigits(raw);
|
||||
if (d === '') return '';
|
||||
const rest = d.slice(1); // до 10 цифр после ведущей 7
|
||||
let out = '+7';
|
||||
if (rest.length > 0) out += ' (' + rest.slice(0, 3);
|
||||
if (rest.length > 3) out += ') ' + rest.slice(3, 6);
|
||||
if (rest.length > 6) out += '-' + rest.slice(6, 8);
|
||||
if (rest.length > 8) out += '-' + rest.slice(8, 10);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Полный валидный RU-номер? */
|
||||
export function isValidPhone(raw: string): boolean {
|
||||
return /^7\d{10}$/.test(phoneDigits(raw));
|
||||
}
|
||||
@@ -100,6 +100,17 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="pageCount > 1" class="projects-pagination mt-4">
|
||||
<v-pagination
|
||||
v-model="store.filters.page"
|
||||
:length="pageCount"
|
||||
:total-visible="7"
|
||||
density="comfortable"
|
||||
data-testid="projects-pagination"
|
||||
@update:model-value="store.fetch()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BulkActionsBar v-if="store.selectedIds.size >= 2" />
|
||||
|
||||
<ProjectDetailsDrawer
|
||||
@@ -135,6 +146,10 @@ function dismissCutoffBanner(): void {
|
||||
localStorage.setItem(CUTOFF_BANNER_KEY, '1');
|
||||
}
|
||||
|
||||
const pageCount = computed<number>(() =>
|
||||
Math.max(1, Math.ceil(store.total / store.filters.per_page)),
|
||||
);
|
||||
|
||||
const singleSelectedProject = computed<Project | null>(() => {
|
||||
if (store.selectedIds.size !== 1) return null;
|
||||
const [id] = store.selectedIds;
|
||||
|
||||
@@ -1,29 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Экран регистрации (RegisterView).
|
||||
* Экран регистрации (RegisterView) — двухшаговый.
|
||||
*
|
||||
* Источник дизайна: liderra_v8_handoff/concepts/v8_login.html секция #form-register.
|
||||
* Источник логики: ТЗ v8.5 §1.5/§4.1 — два обязательных click-wrap'а
|
||||
* (оферта + согласие на ПДн). 3-й «маркетинговый» click-wrap из handoff
|
||||
* НЕ реализован (handoff противоречит ТЗ — расхождение #2 из реестра v1.13).
|
||||
*
|
||||
* MVP: фронт-форма без backend submit. POST /register будет в отдельном коммите.
|
||||
* Шаг 1 (форма): email + телефон (маска) + пароль + 2 click-wrap'а (оферта/ПДн).
|
||||
* Шаг 2 (код): 6-значный код с email → создание аккаунта.
|
||||
* Источник логики: docs/superpowers/specs/2026-05-21-registration-email-verification-phone-design.md
|
||||
*/
|
||||
import { extractValidationErrors } from '../../api/client';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { computed, ref } from 'vue';
|
||||
import { formatPhone, phoneDigits } from '../../utils/phone';
|
||||
import { computed, onUnmounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
const phoneRaw = ref(''); // нормализованные цифры 7XXXXXXXXXX
|
||||
const showPassword = ref(false);
|
||||
const acceptOffer = ref(false);
|
||||
const acceptPdn = ref(false);
|
||||
const code = ref('');
|
||||
const stage = ref<'form' | 'code'>('form');
|
||||
const errors = ref<Record<string, string[]>>({});
|
||||
const resendCooldown = ref(0);
|
||||
let resendTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
const phoneModel = computed({
|
||||
get: () => formatPhone(phoneRaw.value),
|
||||
set: (v: string) => {
|
||||
phoneRaw.value = phoneDigits(v);
|
||||
},
|
||||
});
|
||||
const phoneValid = computed(() => /^7\d{10}$/.test(phoneRaw.value));
|
||||
|
||||
// Простая оценка силы пароля 0..4 для индикатора. На backend будет zxcvbn.
|
||||
const passwordStrength = computed(() => {
|
||||
const v = password.value;
|
||||
@@ -35,40 +46,77 @@ const passwordStrength = computed(() => {
|
||||
if (/[^A-Za-zА-Яа-я0-9]/.test(v)) score++;
|
||||
return score;
|
||||
});
|
||||
const strengthLabel = computed(() => ['—', 'Слабый', 'Средний', 'Хороший', 'Надёжный'][passwordStrength.value]);
|
||||
const strengthColor = computed(() => ['', 'error', 'warning', 'info', 'success'][passwordStrength.value]);
|
||||
|
||||
const strengthLabel = computed(() => {
|
||||
const map = ['—', 'Слабый', 'Средний', 'Хороший', 'Надёжный'];
|
||||
return map[passwordStrength.value];
|
||||
});
|
||||
|
||||
const strengthColor = computed(() => {
|
||||
const map = ['', 'error', 'warning', 'info', 'success'];
|
||||
return map[passwordStrength.value];
|
||||
});
|
||||
|
||||
const canSubmit = computed(
|
||||
() => email.value.length > 0 && password.value.length >= 8 && acceptOffer.value && acceptPdn.value,
|
||||
const canSubmitForm = computed(
|
||||
() =>
|
||||
email.value.length > 0 &&
|
||||
phoneValid.value &&
|
||||
password.value.length >= 8 &&
|
||||
acceptOffer.value &&
|
||||
acceptPdn.value,
|
||||
);
|
||||
const canSubmitCode = computed(() => /^\d{6}$/.test(code.value));
|
||||
|
||||
async function handleSubmit() {
|
||||
function startCooldown() {
|
||||
resendCooldown.value = 60;
|
||||
if (resendTimer) clearInterval(resendTimer);
|
||||
resendTimer = setInterval(() => {
|
||||
resendCooldown.value -= 1;
|
||||
if (resendCooldown.value <= 0 && resendTimer) {
|
||||
clearInterval(resendTimer);
|
||||
resendTimer = null;
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
onUnmounted(() => {
|
||||
if (resendTimer) clearInterval(resendTimer);
|
||||
});
|
||||
|
||||
async function handleStart() {
|
||||
errors.value = {};
|
||||
try {
|
||||
const response = await auth.register({
|
||||
await auth.registerStart({
|
||||
email: email.value,
|
||||
phone: phoneRaw.value,
|
||||
password: password.value,
|
||||
accept_offer: acceptOffer.value,
|
||||
accept_pdn: acceptPdn.value,
|
||||
});
|
||||
await router.push(response.requires_2fa ? '/2fa' : '/dashboard');
|
||||
stage.value = 'code';
|
||||
startCooldown();
|
||||
} catch (error: unknown) {
|
||||
const validationErrors = extractValidationErrors(error);
|
||||
if (validationErrors) {
|
||||
errors.value = validationErrors;
|
||||
} else {
|
||||
errors.value = { email: ['Произошла ошибка. Попробуйте позже.'] };
|
||||
}
|
||||
errors.value = extractValidationErrors(error) ?? { email: ['Произошла ошибка. Попробуйте позже.'] };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVerify() {
|
||||
errors.value = {};
|
||||
try {
|
||||
await auth.registerVerify(code.value);
|
||||
await router.push('/dashboard');
|
||||
} catch (error: unknown) {
|
||||
errors.value = extractValidationErrors(error) ?? { code: ['Произошла ошибка. Попробуйте позже.'] };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResend() {
|
||||
if (resendCooldown.value > 0) return;
|
||||
errors.value = {};
|
||||
try {
|
||||
await auth.registerResend();
|
||||
startCooldown();
|
||||
} catch (error: unknown) {
|
||||
errors.value = extractValidationErrors(error) ?? { code: ['Не удалось отправить код. Попробуйте позже.'] };
|
||||
}
|
||||
}
|
||||
|
||||
function backToForm() {
|
||||
stage.value = 'form';
|
||||
code.value = '';
|
||||
errors.value = {};
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -81,7 +129,8 @@ async function handleSubmit() {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<v-form class="register-form" @submit.prevent="handleSubmit">
|
||||
<!-- Шаг 1: форма -->
|
||||
<v-form v-if="stage === 'form'" class="register-form" @submit.prevent="handleStart">
|
||||
<v-text-field
|
||||
v-model="email"
|
||||
label="Рабочий email"
|
||||
@@ -94,6 +143,18 @@ async function handleSubmit() {
|
||||
:error-messages="errors.email"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="phoneModel"
|
||||
label="Телефон"
|
||||
type="tel"
|
||||
autocomplete="tel"
|
||||
placeholder="+7 (___) ___-__-__"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
required
|
||||
:error-messages="errors.phone"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="password"
|
||||
label="Пароль"
|
||||
@@ -156,12 +217,59 @@ async function handleSubmit() {
|
||||
block
|
||||
size="large"
|
||||
variant="flat"
|
||||
:disabled="!canSubmit"
|
||||
:disabled="!canSubmitForm"
|
||||
:loading="auth.loading"
|
||||
>
|
||||
Создать аккаунт
|
||||
Получить код
|
||||
</v-btn>
|
||||
</v-form>
|
||||
|
||||
<!-- Шаг 2: код -->
|
||||
<v-form v-else class="register-form" @submit.prevent="handleVerify">
|
||||
<p class="text-body-2 text-medium-emphasis mb-2">
|
||||
Мы отправили 6-значный код на <strong>{{ email }}</strong
|
||||
>. Введите его ниже.
|
||||
</p>
|
||||
|
||||
<v-text-field
|
||||
v-model="code"
|
||||
label="Код из письма"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
placeholder="______"
|
||||
maxlength="6"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
required
|
||||
:error-messages="errors.code"
|
||||
/>
|
||||
|
||||
<v-btn
|
||||
type="submit"
|
||||
color="primary"
|
||||
block
|
||||
size="large"
|
||||
variant="flat"
|
||||
:disabled="!canSubmitCode"
|
||||
:loading="auth.loading"
|
||||
>
|
||||
Подтвердить и создать аккаунт
|
||||
</v-btn>
|
||||
|
||||
<div class="code-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="link-btn text-primary"
|
||||
:disabled="resendCooldown > 0"
|
||||
@click="handleResend"
|
||||
>
|
||||
{{ resendCooldown > 0 ? `Отправить код повторно (${resendCooldown})` : 'Отправить код повторно' }}
|
||||
</button>
|
||||
<button type="button" class="link-btn text-medium-emphasis" @click="backToForm">
|
||||
Изменить данные
|
||||
</button>
|
||||
</div>
|
||||
</v-form>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
@@ -196,6 +304,31 @@ async function handleSubmit() {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.code-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.link-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.link-btn:focus-visible {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.password-toggle:focus-visible {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 1px;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Код подтверждения регистрации</title>
|
||||
</head>
|
||||
<body style="font-family: Inter, -apple-system, sans-serif; max-width: 600px; margin: 0 auto; padding: 24px; color: #081319;">
|
||||
<h1 style="color: #0F6E56; font-size: 20px;">Лидерра. Подтверждение регистрации</h1>
|
||||
|
||||
<p>Ваш код подтверждения регистрации:</p>
|
||||
|
||||
<p style="font-size: 32px; font-weight: 700; letter-spacing: 6px; color: #0F6E56; margin: 16px 0;">{{ $code }}</p>
|
||||
|
||||
<p>Код действует 15 минут. Введите его на странице регистрации, чтобы завершить создание аккаунта.</p>
|
||||
|
||||
<p style="color: #66635C; font-size: 12px; margin-top: 32px;">
|
||||
Если вы не регистрировались в Лидерре — просто проигнорируйте это письмо.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
+3
-1
@@ -19,7 +19,9 @@ use Illuminate\Support\Facades\Route;
|
||||
// добавляется только к web-группе. См. laravel.com/docs/sanctum#spa-authentication.
|
||||
Route::prefix('/api/auth')->group(function () {
|
||||
Route::post('/login', 'App\Http\Controllers\Api\AuthController@login');
|
||||
Route::post('/register', 'App\Http\Controllers\Api\AuthController@register');
|
||||
Route::post('/register/start', 'App\Http\Controllers\Api\AuthController@registerStart');
|
||||
Route::post('/register/verify', 'App\Http\Controllers\Api\AuthController@registerVerify');
|
||||
Route::post('/register/resend', 'App\Http\Controllers\Api\AuthController@registerResend');
|
||||
// /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,200 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Mail\RegisterEmailVerificationCode;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
Mail::fake();
|
||||
});
|
||||
|
||||
test('RegisterEmailVerificationCode содержит код и тему', function () {
|
||||
$mailable = new RegisterEmailVerificationCode('123456');
|
||||
|
||||
$mailable->assertHasSubject('Код подтверждения регистрации — Лидерра');
|
||||
$mailable->assertSeeInHtml('123456');
|
||||
});
|
||||
|
||||
test('register/start принимает валидную форму, шлёт код, аккаунт ещё не создан', function () {
|
||||
$response = $this->postJson('/api/auth/register/start', [
|
||||
'email' => 'newcomer@example.ru',
|
||||
'phone' => '+7 (912) 345-67-89',
|
||||
'password' => 'fresh-pass-123',
|
||||
'accept_offer' => true,
|
||||
'accept_pdn' => true,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('email', 'newcomer@example.ru');
|
||||
|
||||
Mail::assertSent(RegisterEmailVerificationCode::class);
|
||||
expect(User::where('email', 'newcomer@example.ru')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('register/start отвергает существующий email', function () {
|
||||
User::factory()->create(['tenant_id' => $this->tenant->id, 'email' => 'dup@example.ru']);
|
||||
|
||||
$this->postJson('/api/auth/register/start', [
|
||||
'email' => 'dup@example.ru',
|
||||
'phone' => '+7 (912) 345-67-89',
|
||||
'password' => 'fresh-pass-123',
|
||||
'accept_offer' => true,
|
||||
'accept_pdn' => true,
|
||||
])->assertStatus(422)->assertJsonValidationErrors(['email']);
|
||||
});
|
||||
|
||||
test('register/start требует корректный телефон', function () {
|
||||
$this->postJson('/api/auth/register/start', [
|
||||
'email' => 'badphone@example.ru',
|
||||
'phone' => '12345',
|
||||
'password' => 'fresh-pass-123',
|
||||
'accept_offer' => true,
|
||||
'accept_pdn' => true,
|
||||
])->assertStatus(422)->assertJsonValidationErrors(['phone']);
|
||||
|
||||
$this->postJson('/api/auth/register/start', [
|
||||
'email' => 'nophone@example.ru',
|
||||
'password' => 'fresh-pass-123',
|
||||
'accept_offer' => true,
|
||||
'accept_pdn' => true,
|
||||
])->assertStatus(422)->assertJsonValidationErrors(['phone']);
|
||||
});
|
||||
|
||||
test('register/start требует пароль ≥8 и оба согласия', function () {
|
||||
$this->postJson('/api/auth/register/start', [
|
||||
'email' => 'weak@example.ru', 'phone' => '+7 (912) 345-67-89',
|
||||
'password' => 'short', 'accept_offer' => true, 'accept_pdn' => true,
|
||||
])->assertStatus(422)->assertJsonValidationErrors(['password']);
|
||||
|
||||
$this->postJson('/api/auth/register/start', [
|
||||
'email' => 'noconsent@example.ru', 'phone' => '+7 (912) 345-67-89',
|
||||
'password' => 'fresh-pass-123', 'accept_pdn' => true,
|
||||
])->assertStatus(422)->assertJsonValidationErrors(['accept_offer']);
|
||||
});
|
||||
|
||||
test('register/start ограничивает число отправок кода (5/час по email|ip)', function () {
|
||||
$payload = [
|
||||
'email' => 'throttle@example.ru',
|
||||
'phone' => '+7 (912) 345-67-89',
|
||||
'password' => 'fresh-pass-123',
|
||||
'accept_offer' => true,
|
||||
'accept_pdn' => true,
|
||||
];
|
||||
|
||||
// 5 отправок разрешены (аккаунт не создаётся до verify, email остаётся свободным).
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$this->postJson('/api/auth/register/start', $payload)->assertOk();
|
||||
}
|
||||
|
||||
// 6-я — превышение лимита.
|
||||
$this->postJson('/api/auth/register/start', $payload)->assertStatus(429);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task 4: register/verify
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Делает register/start и возвращает 6-значный код из отправленного письма.
|
||||
*
|
||||
* @param array<string, mixed> $overrides
|
||||
*/
|
||||
$startAndGetCode = function (array $overrides = []): string {
|
||||
/** @var TestCase $this */
|
||||
$payload = array_merge([
|
||||
'email' => 'verify-flow@example.ru',
|
||||
'phone' => '+7 (912) 345-67-89',
|
||||
'password' => 'fresh-pass-123',
|
||||
'accept_offer' => true,
|
||||
'accept_pdn' => true,
|
||||
], $overrides);
|
||||
|
||||
test()->postJson('/api/auth/register/start', $payload)->assertOk();
|
||||
|
||||
return Mail::sent(RegisterEmailVerificationCode::class)->first()->code;
|
||||
};
|
||||
|
||||
test('register/verify создаёт аккаунт с подтверждённой почтой и нормализованным телефоном', function () use ($startAndGetCode) {
|
||||
$code = $startAndGetCode();
|
||||
|
||||
$response = $this->postJson('/api/auth/register/verify', ['code' => $code]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonPath('user.email', 'verify-flow@example.ru');
|
||||
$response->assertJsonPath('requires_2fa', false);
|
||||
|
||||
$user = User::where('email', 'verify-flow@example.ru')->first();
|
||||
expect($user)->not->toBeNull();
|
||||
expect($user->phone)->toBe('79123456789');
|
||||
expect($user->email_verified_at)->not->toBeNull();
|
||||
$this->assertAuthenticatedAs($user);
|
||||
});
|
||||
|
||||
test('register/verify отклоняет неверный код и считает попытки', function () use ($startAndGetCode) {
|
||||
$startAndGetCode();
|
||||
|
||||
$this->postJson('/api/auth/register/verify', ['code' => '000000'])
|
||||
->assertStatus(422)->assertJsonValidationErrors(['code']);
|
||||
|
||||
expect(User::where('email', 'verify-flow@example.ru')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('register/verify сбрасывает pending после 5 неверных попыток', function () use ($startAndGetCode) {
|
||||
$startAndGetCode();
|
||||
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$this->postJson('/api/auth/register/verify', ['code' => '000000'])->assertStatus(422);
|
||||
}
|
||||
|
||||
// 6-я попытка — pending уже сброшен (нет сессии регистрации).
|
||||
$this->postJson('/api/auth/register/verify', ['code' => '000000'])
|
||||
->assertStatus(422)->assertJsonValidationErrors(['code']);
|
||||
});
|
||||
|
||||
test('register/verify без начатой регистрации возвращает 422', function () {
|
||||
$this->postJson('/api/auth/register/verify', ['code' => '123456'])
|
||||
->assertStatus(422)->assertJsonValidationErrors(['code']);
|
||||
});
|
||||
|
||||
test('register/verify отклоняет истёкший код', function () use ($startAndGetCode) {
|
||||
$code = $startAndGetCode();
|
||||
|
||||
$this->travel(16)->minutes();
|
||||
|
||||
$this->postJson('/api/auth/register/verify', ['code' => $code])
|
||||
->assertStatus(422)->assertJsonValidationErrors(['code']);
|
||||
expect(User::where('email', 'verify-flow@example.ru')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task 5: register/resend
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('register/resend в течение cooldown возвращает 429', function () use ($startAndGetCode) {
|
||||
$startAndGetCode();
|
||||
|
||||
$this->postJson('/api/auth/register/resend')->assertStatus(429);
|
||||
});
|
||||
|
||||
test('register/resend после cooldown шлёт новый код', function () use ($startAndGetCode) {
|
||||
$startAndGetCode();
|
||||
Mail::fake(); // сбрасываем счётчик отправок
|
||||
|
||||
$this->travel(61)->seconds();
|
||||
|
||||
$this->postJson('/api/auth/register/resend')->assertOk();
|
||||
Mail::assertSent(RegisterEmailVerificationCode::class, 1);
|
||||
});
|
||||
|
||||
test('register/resend без начатой регистрации возвращает 422', function () {
|
||||
$this->postJson('/api/auth/register/resend')
|
||||
->assertStatus(422)->assertJsonValidationErrors(['code']);
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
|
||||
it('backfill: добавляет root-link для проекта-субдомена с уже-существующими линками', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'krasnoyarsk.carmoney.ru',
|
||||
]);
|
||||
|
||||
$subdomainSp = SupplierProject::create([
|
||||
'platform' => 'B2',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'krasnoyarsk.carmoney.ru',
|
||||
'supplier_external_id' => 'ext-sub',
|
||||
'current_limit' => 100,
|
||||
'sync_status' => 'ok',
|
||||
]);
|
||||
$rootSp = SupplierProject::create([
|
||||
'platform' => 'B2',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'carmoney.ru',
|
||||
'supplier_external_id' => 'ext-root',
|
||||
'current_limit' => 100,
|
||||
'sync_status' => 'ok',
|
||||
]);
|
||||
|
||||
DB::connection('pgsql_supplier')->table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $subdomainSp->id,
|
||||
'platform' => 'B2',
|
||||
'subject_code' => null,
|
||||
]);
|
||||
|
||||
$exitCode = Artisan::call('supplier:backfill-root-links');
|
||||
expect($exitCode)->toBe(0);
|
||||
|
||||
expect(
|
||||
DB::connection('pgsql_supplier')->table('project_supplier_links')
|
||||
->where('project_id', $project->id)
|
||||
->where('supplier_project_id', $rootSp->id)
|
||||
->exists()
|
||||
)->toBeTrue();
|
||||
});
|
||||
|
||||
it('backfill: idempotent — повторный прогон ничего не добавляет', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'client.carmoney.ru',
|
||||
]);
|
||||
$subSp = SupplierProject::create([
|
||||
'platform' => 'B2', 'signal_type' => 'site', 'unique_key' => 'client.carmoney.ru',
|
||||
'supplier_external_id' => 'ext1', 'current_limit' => 100, 'sync_status' => 'ok',
|
||||
]);
|
||||
SupplierProject::create([
|
||||
'platform' => 'B2', 'signal_type' => 'site', 'unique_key' => 'carmoney.ru',
|
||||
'supplier_external_id' => 'ext2', 'current_limit' => 100, 'sync_status' => 'ok',
|
||||
]);
|
||||
DB::connection('pgsql_supplier')->table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id, 'supplier_project_id' => $subSp->id,
|
||||
'platform' => 'B2', 'subject_code' => null,
|
||||
]);
|
||||
|
||||
Artisan::call('supplier:backfill-root-links');
|
||||
$afterFirst = DB::connection('pgsql_supplier')->table('project_supplier_links')
|
||||
->where('project_id', $project->id)->count();
|
||||
Artisan::call('supplier:backfill-root-links');
|
||||
$afterSecond = DB::connection('pgsql_supplier')->table('project_supplier_links')
|
||||
->where('project_id', $project->id)->count();
|
||||
|
||||
expect($afterFirst)->toBe(2);
|
||||
expect($afterSecond)->toBe(2);
|
||||
});
|
||||
|
||||
it('backfill --dry-run: ничего не пишет в БД', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'next.vashinvestor.ru',
|
||||
]);
|
||||
$subSp = SupplierProject::create([
|
||||
'platform' => 'B2', 'signal_type' => 'site', 'unique_key' => 'next.vashinvestor.ru',
|
||||
'supplier_external_id' => 'extn1', 'current_limit' => 100, 'sync_status' => 'ok',
|
||||
]);
|
||||
SupplierProject::create([
|
||||
'platform' => 'B2', 'signal_type' => 'site', 'unique_key' => 'vashinvestor.ru',
|
||||
'supplier_external_id' => 'extn2', 'current_limit' => 100, 'sync_status' => 'ok',
|
||||
]);
|
||||
DB::connection('pgsql_supplier')->table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id, 'supplier_project_id' => $subSp->id,
|
||||
'platform' => 'B2', 'subject_code' => null,
|
||||
]);
|
||||
|
||||
Artisan::call('supplier:backfill-root-links', ['--dry-run' => true]);
|
||||
|
||||
$count = DB::connection('pgsql_supplier')->table('project_supplier_links')
|
||||
->where('project_id', $project->id)->count();
|
||||
expect($count)->toBe(1);
|
||||
});
|
||||
@@ -7,7 +7,9 @@ use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Supplier\Channel\SupplierProjectChannel;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
// TestCase auto-bound via tests/Pest.php (->in('Feature')).
|
||||
@@ -151,3 +153,82 @@ it('idempotency: pre-existing supplier_project row is reused, channel not called
|
||||
expect($project->supplier_b1_project_id)->not->toBeNull();
|
||||
expect($project->supplier_b3_project_id)->not->toBeNull();
|
||||
});
|
||||
|
||||
// Spec 2026-05-22: автолинковка субдомен-проекта к корневому supplier_project.
|
||||
// Тесты идут по handleOnline-ветке (project_supplier_links заполняется только там).
|
||||
it('site subdomain project: also links to root-domain supplier_project if exists', function () {
|
||||
DB::connection('pgsql_supplier')->table('system_settings')->updateOrInsert(
|
||||
['key' => 'supplier_export_mode'],
|
||||
['key' => 'supplier_export_mode', 'value' => 'online'],
|
||||
);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'krasnoyarsk.carmoney.ru',
|
||||
]);
|
||||
|
||||
$rootSp = SupplierProject::create([
|
||||
'platform' => 'B2',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'carmoney.ru',
|
||||
'supplier_external_id' => 'ext-root-b2',
|
||||
'current_limit' => 100,
|
||||
'sync_status' => 'ok',
|
||||
]);
|
||||
|
||||
// handleOnline path использует SupplierPortalClient::saveProjectMultiFlag, не channel.
|
||||
$this->mock(SupplierPortalClient::class, function ($mock) {
|
||||
$mock->shouldReceive('saveProjectMultiFlag')->andReturn([
|
||||
'B1' => 700101, 'B2' => 700102, 'B3' => 700103,
|
||||
]);
|
||||
});
|
||||
|
||||
dispatchJobSync(new SyncSupplierProjectJob($project->id));
|
||||
|
||||
$ownLinks = DB::connection('pgsql_supplier')->table('project_supplier_links')
|
||||
->where('project_id', $project->id)->count();
|
||||
expect($ownLinks)->toBe(4);
|
||||
|
||||
$rootLink = DB::connection('pgsql_supplier')->table('project_supplier_links')
|
||||
->where('project_id', $project->id)
|
||||
->where('supplier_project_id', $rootSp->id)
|
||||
->exists();
|
||||
expect($rootLink)->toBeTrue();
|
||||
});
|
||||
|
||||
it('site root-level project: does NOT create additional links (no recursion to TLD)', function () {
|
||||
DB::connection('pgsql_supplier')->table('system_settings')->updateOrInsert(
|
||||
['key' => 'supplier_export_mode'],
|
||||
['key' => 'supplier_export_mode', 'value' => 'online'],
|
||||
);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'carmoney.ru',
|
||||
]);
|
||||
|
||||
SupplierProject::create([
|
||||
'platform' => 'B2',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'ru',
|
||||
'supplier_external_id' => 'ext-ru-b2',
|
||||
'current_limit' => 100,
|
||||
'sync_status' => 'ok',
|
||||
]);
|
||||
|
||||
$this->mock(SupplierPortalClient::class, function ($mock) {
|
||||
$mock->shouldReceive('saveProjectMultiFlag')->andReturn([
|
||||
'B1' => 700201, 'B2' => 700202, 'B3' => 700203,
|
||||
]);
|
||||
});
|
||||
|
||||
dispatchJobSync(new SyncSupplierProjectJob($project->id));
|
||||
|
||||
$linksCount = DB::connection('pgsql_supplier')->table('project_supplier_links')
|
||||
->where('project_id', $project->id)->count();
|
||||
expect($linksCount)->toBe(3);
|
||||
});
|
||||
|
||||
@@ -272,3 +272,61 @@ describe('ProjectsView 18:00 cutoff banner', () => {
|
||||
expect(wrapper.find('[data-testid="cutoff-banner"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// 2026-05-22: при total > per_page фронт должен показывать переключатель
|
||||
// страниц (бэкенд уже отдаёт постранично — без UI пользователь видел только
|
||||
// первые 20 проектов из 125).
|
||||
describe('ProjectsView pagination', () => {
|
||||
function makeCard(id: number) {
|
||||
return {
|
||||
id,
|
||||
name: `P${id}`,
|
||||
signal_type: 'site' as const,
|
||||
signal_identifier: `${id}.ru`,
|
||||
daily_limit_target: 10,
|
||||
delivered_today: 0,
|
||||
is_active: true,
|
||||
sync_status: 'ok' as const,
|
||||
};
|
||||
}
|
||||
|
||||
it('renders pagination control when total > per_page', async () => {
|
||||
const items = Array.from({ length: 20 }, (_, i) => makeCard(i + 1));
|
||||
(axios.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
data: { data: items, meta: { total: 125, current_page: 1, per_page: 20 } },
|
||||
});
|
||||
const wrapper = factory();
|
||||
await flushPromises();
|
||||
expect(wrapper.find('[data-testid="projects-pagination"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT render pagination when total <= per_page', async () => {
|
||||
const items = [makeCard(1), makeCard(2)];
|
||||
(axios.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
data: { data: items, meta: { total: 2, current_page: 1, per_page: 20 } },
|
||||
});
|
||||
const wrapper = factory();
|
||||
await flushPromises();
|
||||
expect(wrapper.find('[data-testid="projects-pagination"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('changing page triggers refetch with new page param', async () => {
|
||||
const items = Array.from({ length: 20 }, (_, i) => makeCard(i + 1));
|
||||
const mockGet = axios.get as unknown as ReturnType<typeof vi.fn>;
|
||||
mockGet.mockResolvedValue({
|
||||
data: { data: items, meta: { total: 125, current_page: 1, per_page: 20 } },
|
||||
});
|
||||
const wrapper = factory();
|
||||
await flushPromises();
|
||||
mockGet.mockClear();
|
||||
// Emulate user picking page 2 via VPagination's model-value.
|
||||
const pagination = wrapper.findComponent({ name: 'VPagination' });
|
||||
expect(pagination.exists()).toBe(true);
|
||||
pagination.vm.$emit('update:modelValue', 2);
|
||||
await flushPromises();
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'/api/projects',
|
||||
expect.objectContaining({ params: expect.objectContaining({ page: 2 }) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||
import RegisterView from '../../resources/js/views/auth/RegisterView.vue';
|
||||
|
||||
// Smoke-тесты RegisterView. Ключевое: проверяем что 3-й «маркетинговый» click-wrap
|
||||
// из v8_login.html НЕ присутствует — он противоречит ТЗ §1.5/§4.1 (расхождение #2
|
||||
// handoff vs ТЗ из реестра v1.13). Только два обязательных: оферта + ПДн.
|
||||
// Мокаем API-слой, чтобы тестировать переход шаг1 → шаг2 без сети.
|
||||
vi.mock('../../resources/js/api/auth', () => ({
|
||||
registerStart: vi.fn().mockResolvedValue({ message: 'ok', email: 'manager@yourcompany.ru' }),
|
||||
registerVerify: vi.fn().mockResolvedValue({ user: { id: 1 }, requires_2fa: false }),
|
||||
registerResend: vi.fn().mockResolvedValue({ message: 'ok' }),
|
||||
}));
|
||||
|
||||
const mountRegister = async () => {
|
||||
const router = createRouter({
|
||||
@@ -15,6 +18,7 @@ const mountRegister = async () => {
|
||||
routes: [
|
||||
{ path: '/register', name: 'register', component: RegisterView },
|
||||
{ path: '/login', name: 'login', component: { template: '<div>stub</div>' } },
|
||||
{ path: '/dashboard', name: 'dashboard', component: { template: '<div>stub</div>' } },
|
||||
],
|
||||
});
|
||||
await router.push('/register');
|
||||
@@ -27,43 +31,55 @@ const mountRegister = async () => {
|
||||
describe('RegisterView.vue', () => {
|
||||
it('монтируется и содержит заголовок «Создать аккаунт»', async () => {
|
||||
const wrapper = await mountRegister();
|
||||
expect(wrapper.exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain('Создать аккаунт');
|
||||
});
|
||||
|
||||
it('содержит поля email/password с правильным autocomplete', async () => {
|
||||
it('шаг 1 содержит email/телефон/пароль и кнопку «Получить код»', async () => {
|
||||
const wrapper = await mountRegister();
|
||||
expect(wrapper.find('input[type="email"]').attributes('autocomplete')).toBe('email');
|
||||
expect(wrapper.find('input[type="password"]').attributes('autocomplete')).toBe('new-password');
|
||||
expect(wrapper.find('input[type="email"]').exists()).toBe(true);
|
||||
expect(wrapper.find('input[type="tel"]').exists()).toBe(true);
|
||||
expect(wrapper.find('input[type="password"]').exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain('Получить код');
|
||||
});
|
||||
|
||||
it('телефон форматируется по маске при вводе', async () => {
|
||||
const wrapper = await mountRegister();
|
||||
const tel = wrapper.find('input[type="tel"]');
|
||||
await tel.setValue('89123456789');
|
||||
expect((tel.element as HTMLInputElement).value).toBe('+7 (912) 345-67-89'); // gitleaks:allow
|
||||
});
|
||||
|
||||
it('содержит ровно 2 click-wrap-чекбокса (оферта + ПДн), без маркетингового', async () => {
|
||||
const wrapper = await mountRegister();
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]');
|
||||
expect(checkboxes).toHaveLength(2);
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('оферту');
|
||||
expect(text).toContain('политикой обработки персональных данных');
|
||||
expect(text).not.toContain('информационных сообщений');
|
||||
expect(text).not.toContain('маркетинг');
|
||||
});
|
||||
|
||||
it('содержит ссылку на /login', async () => {
|
||||
const wrapper = await mountRegister();
|
||||
const links = wrapper.findAll('a').map((a) => a.text());
|
||||
expect(links.some((t) => t.includes('Войдите'))).toBe(true);
|
||||
expect(wrapper.text()).toContain('оферту');
|
||||
expect(wrapper.text()).toContain('политикой обработки персональных данных');
|
||||
expect(wrapper.text()).not.toContain('маркетинг');
|
||||
});
|
||||
|
||||
it('A9: переключатель видимости пароля имеет accessible-name и работает', async () => {
|
||||
const wrapper = await mountRegister();
|
||||
const toggle = wrapper.find('[aria-label="Показать пароль"]');
|
||||
expect(toggle.exists()).toBe(true);
|
||||
expect(toggle.attributes('role')).toBe('button');
|
||||
await toggle.trigger('click');
|
||||
expect(wrapper.find('[aria-label="Скрыть пароль"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
// keyboard activation (Enter) — toggle back
|
||||
await wrapper.find('[aria-label="Скрыть пароль"]').trigger('keydown', { key: 'Enter' });
|
||||
expect(wrapper.find('[aria-label="Показать пароль"]').exists()).toBe(true);
|
||||
it('после заполнения формы и «Получить код» переходит к вводу кода', async () => {
|
||||
const wrapper = await mountRegister();
|
||||
await wrapper.find('input[type="email"]').setValue('manager@yourcompany.ru');
|
||||
await wrapper.find('input[type="tel"]').setValue('9123456789');
|
||||
await wrapper.find('input[type="password"]').setValue('fresh-pass-123');
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]');
|
||||
await checkboxes[0].setValue(true);
|
||||
await checkboxes[1].setValue(true);
|
||||
|
||||
await wrapper.find('form').trigger('submit.prevent');
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).toContain('Подтвердить и создать аккаунт');
|
||||
expect(wrapper.text()).toContain('Отправить код повторно');
|
||||
expect(wrapper.find('input[autocomplete="one-time-code"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ vi.mock('../../resources/js/api/client', () => ({
|
||||
|
||||
import {
|
||||
login,
|
||||
register,
|
||||
registerStart,
|
||||
me,
|
||||
logout,
|
||||
verifyTwoFactor,
|
||||
@@ -49,12 +49,13 @@ describe('api/auth', () => {
|
||||
expect(result.user.email).toBe('demo@x.ru');
|
||||
});
|
||||
|
||||
it('register() POSTs /api/auth/register с accept-флагами', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { user: FAKE_USER, requires_2fa: false } });
|
||||
await register({ email: 'a@x.ru', password: 'pw', accept_offer: true, accept_pdn: true });
|
||||
it('registerStart() POSTs /api/auth/register/start с email+phone+accept-флагами', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { message: 'ok', email: 'a@x.ru' } });
|
||||
await registerStart({ email: 'a@x.ru', phone: '79991234567', password: 'pw', accept_offer: true, accept_pdn: true }); // gitleaks:allow
|
||||
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/api/auth/register', {
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/api/auth/register/start', {
|
||||
email: 'a@x.ru',
|
||||
phone: '79991234567', // gitleaks:allow
|
||||
password: 'pw',
|
||||
accept_offer: true,
|
||||
accept_pdn: true,
|
||||
|
||||
@@ -4,7 +4,9 @@ import { createPinia, setActivePinia } from 'pinia';
|
||||
// Мокаем api/auth до import'а auth-store.
|
||||
vi.mock('../../resources/js/api/auth', () => ({
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
registerStart: vi.fn(),
|
||||
registerVerify: vi.fn(),
|
||||
registerResend: vi.fn(),
|
||||
me: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
verifyTwoFactor: vi.fn(),
|
||||
@@ -131,8 +133,26 @@ describe('useAuthStore', () => {
|
||||
expect(auth.isAuthenticated).toBe(false);
|
||||
});
|
||||
|
||||
it('register() → success ставит user', async () => {
|
||||
vi.mocked(authApi.register).mockResolvedValue({
|
||||
it('registerStart() → success возвращает email без изменения user-state', async () => {
|
||||
vi.mocked(authApi.registerStart).mockResolvedValue({ message: 'Код отправлен', email: 'new@example.ru' });
|
||||
|
||||
const auth = useAuthStore();
|
||||
const result = await auth.registerStart({
|
||||
email: 'new@example.ru',
|
||||
phone: '79991234567', // gitleaks:allow
|
||||
password: 'pass1234',
|
||||
accept_offer: true,
|
||||
accept_pdn: true,
|
||||
});
|
||||
|
||||
expect(result.email).toBe('new@example.ru');
|
||||
// user НЕ ставится на шаге 1 — аккаунт ещё не создан.
|
||||
expect(auth.user).toBeNull();
|
||||
expect(auth.isAuthenticated).toBe(false);
|
||||
});
|
||||
|
||||
it('registerVerify() → success ставит user + isAuthenticated=true', async () => {
|
||||
vi.mocked(authApi.registerVerify).mockResolvedValue({
|
||||
user: {
|
||||
id: 2,
|
||||
email: 'new@example.ru',
|
||||
@@ -146,12 +166,7 @@ describe('useAuthStore', () => {
|
||||
});
|
||||
|
||||
const auth = useAuthStore();
|
||||
await auth.register({
|
||||
email: 'new@example.ru',
|
||||
password: 'pass1234',
|
||||
accept_offer: true,
|
||||
accept_pdn: true,
|
||||
});
|
||||
await auth.registerVerify('123456');
|
||||
|
||||
expect(auth.user?.email).toBe('new@example.ru');
|
||||
expect(auth.isAuthenticated).toBe(true);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { phoneDigits, formatPhone, isValidPhone } from '../../resources/js/utils/phone';
|
||||
|
||||
// Phone numbers below are test fixtures, not real PII. gitleaks:allow
|
||||
describe('phone utils', () => {
|
||||
it('phoneDigits нормализует к 7XXXXXXXXXX', () => {
|
||||
expect(phoneDigits('+7 (912) 345-67-89')).toBe('79123456789'); // gitleaks:allow
|
||||
expect(phoneDigits('8 912 345 67 89')).toBe('79123456789'); // gitleaks:allow
|
||||
expect(phoneDigits('9123456789')).toBe('79123456789'); // gitleaks:allow
|
||||
expect(phoneDigits('')).toBe('');
|
||||
});
|
||||
|
||||
it('formatPhone строит маску прогрессивно', () => {
|
||||
expect(formatPhone('')).toBe('');
|
||||
expect(formatPhone('7912')).toBe('+7 (912');
|
||||
expect(formatPhone('79123456789')).toBe('+7 (912) 345-67-89'); // gitleaks:allow
|
||||
// лишние цифры обрезаются до 11
|
||||
expect(formatPhone('791234567890000')).toBe('+7 (912) 345-67-89'); // gitleaks:allow
|
||||
});
|
||||
|
||||
it('isValidPhone true только для полного 7+10', () => {
|
||||
expect(isValidPhone('+7 (912) 345-67-89')).toBe(true); // gitleaks:allow
|
||||
expect(isValidPhone('7912345')).toBe(false);
|
||||
expect(isValidPhone('')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\PhoneNormalizer;
|
||||
|
||||
test('нормализует разные форматы RU-номера в 7XXXXXXXXXX', function (string $input, ?string $expected) {
|
||||
expect(PhoneNormalizer::normalize($input))->toBe($expected);
|
||||
})->with([
|
||||
'маска +7' => ['+7 (912) 345-67-89', '79123456789'],
|
||||
'через 8' => ['8 (912) 345-67-89', '79123456789'],
|
||||
'голые 7+10' => ['79123456789', '79123456789'],
|
||||
'голые 10' => ['9123456789', '79123456789'],
|
||||
'с мусором' => ['тел: +7-912-345-67-89 ', '79123456789'],
|
||||
'слишком коротко' => ['12345', null],
|
||||
'слишком длинно' => ['791234567890123', null],
|
||||
'пусто' => ['', null],
|
||||
]);
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\SupplierIdentifier;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('extracts root domain from subdomain', function (): void {
|
||||
expect(SupplierIdentifier::extractRootDomain('krasnoyarsk.carmoney.ru'))->toBe('carmoney.ru');
|
||||
expect(SupplierIdentifier::extractRootDomain('client.carmoney.ru'))->toBe('carmoney.ru');
|
||||
expect(SupplierIdentifier::extractRootDomain('next.vashinvestor.ru'))->toBe('vashinvestor.ru');
|
||||
expect(SupplierIdentifier::extractRootDomain('cabinet.caranga.ru'))->toBe('caranga.ru');
|
||||
});
|
||||
|
||||
it('returns null for already-root domain', function (): void {
|
||||
expect(SupplierIdentifier::extractRootDomain('carmoney.ru'))->toBeNull();
|
||||
expect(SupplierIdentifier::extractRootDomain('заложитьптс.рф'))->toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-domain identifiers', function (): void {
|
||||
expect(SupplierIdentifier::extractRootDomain('7800XXXXXXX'))->toBeNull();
|
||||
expect(SupplierIdentifier::extractRootDomain(''))->toBeNull();
|
||||
expect(SupplierIdentifier::extractRootDomain(' '))->toBeNull();
|
||||
expect(SupplierIdentifier::extractRootDomain('TINKOFF'))->toBeNull();
|
||||
});
|
||||
@@ -1608,3 +1608,12 @@ lemed
|
||||
дочерпывание
|
||||
creds
|
||||
незавершёнку
|
||||
субдомен
|
||||
субдомены
|
||||
субдомена
|
||||
брейнсторминг
|
||||
брейнсторминга
|
||||
гэп
|
||||
артизан
|
||||
Артизан
|
||||
Sps
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Brain Status (auto-generated)
|
||||
|
||||
Last updated: 2026-05-21T08:42:35.722Z
|
||||
Last updated: 2026-05-22T09:23:38.723Z
|
||||
|
||||
| Контролёр | Состояние | Детали |
|
||||
|---|---|---|
|
||||
@@ -8,14 +8,14 @@ Last updated: 2026-05-21T08:42:35.722Z
|
||||
| C2 Cross-ref consistency | ✅ | [cross-ref-checker] OK — 0 drift in 4 files |
|
||||
| C3 Observer-of-observer | ✅ | [observer-of-observer] OK — last read 0 week(s) ago |
|
||||
| C4 Сигнальный статус | ✅ | This file (self-reference) |
|
||||
| C5 Observer-coverage | ✅ | 71 episode(s) this month · Stop-hook + post-commit OK |
|
||||
| C5 Observer-coverage | ✅ | 189 episode(s) this month · Stop-hook + post-commit OK |
|
||||
| C6 Chain map sync | ✅ | [chain-map-checker] OK — 14 chains in sync |
|
||||
|
||||
## Метрики (информационные, не алерты)
|
||||
|
||||
- Observer evidence: 71 episodes this month, 0 observer_error markers, 52 PII matches before filter
|
||||
- Observer evidence: 189 episodes this month, 0 observer_error markers, 220 PII matches before filter
|
||||
- Legacy v1 episodes (not in factor analysis): 5
|
||||
- Last /brain-retro: 2 day(s) ago
|
||||
- Last /brain-retro: 3 day(s) ago
|
||||
- Использование узлов: см. `/brain-retro` (раз в спринт). **Неиспользованные узлы — не проблема** (capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
|
||||
|
||||
## Алерт-индикаторы
|
||||
|
||||
@@ -0,0 +1,776 @@
|
||||
# Автолинковка проекта-субдомена к корневому домену — план реализации
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Подписчик субдомена (`krasnoyarsk.carmoney.ru`) автоматически получает также лиды с корневого домена (`carmoney.ru`).
|
||||
|
||||
**Architecture:** Утилита `SupplierIdentifier::extractRootDomain` извлекает root по последним 2 сегментам. Два sync-джоба (`SyncSupplierProjectJob` одиночный + `SyncSupplierProjectsJob` массовый) после своих основных линков добавляют ещё один блок — `insertOrIgnore` линков к `supplier_projects.unique_key = root`. Артизан-команда `supplier:backfill-root-links` делает то же для уже существующих проектов однократно (идемпотентно).
|
||||
|
||||
**Tech Stack:** PHP 8.3, Laravel 13, Pest 4, PostgreSQL 16. БД-соединение для записи в `project_supplier_links` — `pgsql_supplier` (BYPASSRLS).
|
||||
|
||||
**Spec:** [docs/superpowers/specs/2026-05-22-root-domain-auto-link-design.md](../specs/2026-05-22-root-domain-auto-link-design.md)
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Создаются:**
|
||||
|
||||
- `app/app/Support/SupplierIdentifier.php` — утилита с методом `extractRootDomain`
|
||||
- `app/tests/Unit/Support/SupplierIdentifierTest.php` — unit-тесты утилиты
|
||||
- `app/app/Console/Commands/BackfillRootSupplierLinksCommand.php` — артизан-команда
|
||||
- `app/tests/Feature/Console/BackfillRootSupplierLinksCommandTest.php` — тесты команды
|
||||
|
||||
**Модифицируются:**
|
||||
|
||||
- `app/app/Jobs/SyncSupplierProjectJob.php` — добавляется блок auto-root-link после строки 318
|
||||
- `app/app/Jobs/Supplier/SyncSupplierProjectsJob.php` — добавляется блок auto-root-link после строки 427
|
||||
- `app/tests/Feature/Plan5/Jobs/SyncSupplierProjectJobTest.php` — +2 теста на root-link
|
||||
- `app/bootstrap/app.php` или `app/routes/console.php` — регистрация артизан-команды (если не auto-discovered)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Утилита `SupplierIdentifier::extractRootDomain`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Support/SupplierIdentifier.php`
|
||||
- Test: `app/tests/Unit/Support/SupplierIdentifierTest.php`
|
||||
|
||||
- [ ] **Step 1: Написать падающий unit-тест**
|
||||
|
||||
Создать файл `app/tests/Unit/Support/SupplierIdentifierTest.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\SupplierIdentifier;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('extracts root domain from subdomain', function (): void {
|
||||
expect(SupplierIdentifier::extractRootDomain('krasnoyarsk.carmoney.ru'))->toBe('carmoney.ru');
|
||||
expect(SupplierIdentifier::extractRootDomain('client.carmoney.ru'))->toBe('carmoney.ru');
|
||||
expect(SupplierIdentifier::extractRootDomain('next.vashinvestor.ru'))->toBe('vashinvestor.ru');
|
||||
expect(SupplierIdentifier::extractRootDomain('cabinet.caranga.ru'))->toBe('caranga.ru');
|
||||
});
|
||||
|
||||
it('returns null for already-root domain', function (): void {
|
||||
expect(SupplierIdentifier::extractRootDomain('carmoney.ru'))->toBeNull();
|
||||
expect(SupplierIdentifier::extractRootDomain('заложитьптс.рф'))->toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-domain identifiers', function (): void {
|
||||
expect(SupplierIdentifier::extractRootDomain('7800XXXXXXX'))->toBeNull();
|
||||
expect(SupplierIdentifier::extractRootDomain(''))->toBeNull();
|
||||
expect(SupplierIdentifier::extractRootDomain(' '))->toBeNull();
|
||||
expect(SupplierIdentifier::extractRootDomain('TINKOFF'))->toBeNull();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить тест — должен упасть «class not found»**
|
||||
|
||||
```bash
|
||||
cd app && npx --no-install vendor/bin/pest tests/Unit/Support/SupplierIdentifierTest.php --filter='extracts root domain' 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Альтернатива (если без npx):
|
||||
|
||||
```bash
|
||||
cd app && ./vendor/bin/pest tests/Unit/Support/SupplierIdentifierTest.php 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: ERROR — `Class "App\Support\SupplierIdentifier" not found`
|
||||
|
||||
- [ ] **Step 3: Создать минимальную реализацию**
|
||||
|
||||
Создать `app/app/Support/SupplierIdentifier.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Утилиты для работы с identifier'ами поставщика (supplier_projects.unique_key).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-22-root-domain-auto-link-design.md §4.1
|
||||
*/
|
||||
class SupplierIdentifier
|
||||
{
|
||||
/**
|
||||
* Извлекает корневой домен из identifier'а проекта.
|
||||
*
|
||||
* Правило: если identifier выглядит как домен с ≥3 сегментами через точку —
|
||||
* вернуть последние 2 сегмента. Иначе (уже корень, телефон, sms-ключ) — null.
|
||||
*
|
||||
* Public-suffix-list (.co.uk и т.п.) НЕ поддерживается — у проекта только
|
||||
* .ru/.рф/.com, для которых правило «2 последних сегмента» корректно.
|
||||
*/
|
||||
public static function extractRootDomain(string $identifier): ?string
|
||||
{
|
||||
$trimmed = trim($identifier);
|
||||
if ($trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
// Без точки — не домен (телефон, sms-ключ).
|
||||
if (! str_contains($trimmed, '.')) {
|
||||
return null;
|
||||
}
|
||||
$parts = explode('.', $trimmed);
|
||||
if (count($parts) < 3) {
|
||||
return null; // уже корневой
|
||||
}
|
||||
|
||||
return implode('.', array_slice($parts, -2));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Запустить тест — должен пройти**
|
||||
|
||||
```bash
|
||||
cd app && ./vendor/bin/pest tests/Unit/Support/SupplierIdentifierTest.php 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: PASS (3 tests, 11 assertions).
|
||||
|
||||
- [ ] **Step 5: Закоммитить**
|
||||
|
||||
```bash
|
||||
git add app/app/Support/SupplierIdentifier.php app/tests/Unit/Support/SupplierIdentifierTest.php
|
||||
git commit -m "feat(supplier): SupplierIdentifier::extractRootDomain — извлечение корневого домена
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-22-root-domain-auto-link-design.md §4.1
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Hook в `SyncSupplierProjectJob` (одиночная синхронизация)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Jobs/SyncSupplierProjectJob.php:318` (после существующего foreach с insertOrIgnore)
|
||||
- Test: `app/tests/Feature/Plan5/Jobs/SyncSupplierProjectJobTest.php` (добавить 2 теста)
|
||||
|
||||
- [ ] **Step 1: Написать падающий feature-тест**
|
||||
|
||||
Дописать в конец `app/tests/Feature/Plan5/Jobs/SyncSupplierProjectJobTest.php`:
|
||||
|
||||
```php
|
||||
it('site subdomain project: also links to root-domain supplier_project if exists', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'krasnoyarsk.carmoney.ru',
|
||||
]);
|
||||
|
||||
// Предсуществующий supplier_project на корневом домене (например, от другого тенанта
|
||||
// или от ночной массовой синхронизации) — B2.
|
||||
$rootSp = SupplierProject::create([
|
||||
'platform' => 'B2',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'carmoney.ru',
|
||||
'supplier_external_id' => 'ext-root-b2',
|
||||
'current_limit' => 100,
|
||||
'sync_status' => 'ok',
|
||||
]);
|
||||
|
||||
$this->mock(SupplierProjectChannel::class, function ($mock) {
|
||||
$mock->shouldReceive('createProject')->times(3)
|
||||
->andReturn(700101, 700102, 700103);
|
||||
});
|
||||
|
||||
dispatchJobSync(new SyncSupplierProjectJob($project->id));
|
||||
|
||||
// Базовая проверка: 3 линка от 3 supplier_projects по субдомену (B1/B2/B3).
|
||||
$ownLinks = DB::connection('pgsql_supplier')->table('project_supplier_links')
|
||||
->where('project_id', $project->id)->count();
|
||||
|
||||
// Доп. линк к root-sp должен появиться. Итого 4: 3 субдомен + 1 root.
|
||||
expect($ownLinks)->toBe(4);
|
||||
|
||||
// Конкретно к root-sp.
|
||||
$rootLink = DB::connection('pgsql_supplier')->table('project_supplier_links')
|
||||
->where('project_id', $project->id)
|
||||
->where('supplier_project_id', $rootSp->id)
|
||||
->exists();
|
||||
expect($rootLink)->toBeTrue();
|
||||
});
|
||||
|
||||
it('site root-level project: does NOT create additional links (no recursion)', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'carmoney.ru',
|
||||
]);
|
||||
|
||||
// Не должен создаваться линк к sp 'ru' — extractRootDomain возвращает null.
|
||||
SupplierProject::create([
|
||||
'platform' => 'B2',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'ru',
|
||||
'supplier_external_id' => 'ext-ru-b2',
|
||||
'current_limit' => 100,
|
||||
'sync_status' => 'ok',
|
||||
]);
|
||||
|
||||
$this->mock(SupplierProjectChannel::class, function ($mock) {
|
||||
$mock->shouldReceive('createProject')->times(3)
|
||||
->andReturn(700201, 700202, 700203);
|
||||
});
|
||||
|
||||
dispatchJobSync(new SyncSupplierProjectJob($project->id));
|
||||
|
||||
$linksCount = DB::connection('pgsql_supplier')->table('project_supplier_links')
|
||||
->where('project_id', $project->id)->count();
|
||||
expect($linksCount)->toBe(3); // только 3 от своего root, без рекурсии вверх
|
||||
});
|
||||
```
|
||||
|
||||
И добавить два use-import'а в начало файла (после строки 11):
|
||||
|
||||
```php
|
||||
use Illuminate\Support\Facades\DB;
|
||||
```
|
||||
|
||||
(Остальные модели уже импортированы выше.)
|
||||
|
||||
- [ ] **Step 2: Запустить новые тесты — должны упасть**
|
||||
|
||||
```bash
|
||||
cd app && ./vendor/bin/pest tests/Feature/Plan5/Jobs/SyncSupplierProjectJobTest.php --filter='also links to root-domain' 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: FAIL — `Failed asserting that 3 is 4` (root-link не создаётся, потому что кода ещё нет).
|
||||
|
||||
- [ ] **Step 3: Добавить блок auto-root-link в `SyncSupplierProjectJob`**
|
||||
|
||||
Открыть `app/app/Jobs/SyncSupplierProjectJob.php`. Сразу **после** существующего блока (после строки 318, перед комментарием `// Mirror the link into the legacy FK columns ...` на строке 320), вставить:
|
||||
|
||||
```php
|
||||
|
||||
// Auto-link к корневому домену (spec 2026-05-22).
|
||||
// Если проект на субдомене (krasnoyarsk.carmoney.ru), а у поставщика
|
||||
// зарегистрирован также корневой sp (carmoney.ru) — добавляем линк и к нему.
|
||||
if ($project->signal_type === 'site') {
|
||||
$rootIdentifier = \App\Support\SupplierIdentifier::extractRootDomain(
|
||||
(string) $project->signal_identifier
|
||||
);
|
||||
if ($rootIdentifier !== null) {
|
||||
$rootSps = \App\Models\SupplierProject::on(self::DB_CONNECTION)
|
||||
->where('unique_key', $rootIdentifier)
|
||||
->where('signal_type', 'site')
|
||||
->get();
|
||||
foreach ($rootSps as $rootSp) {
|
||||
DB::connection(self::DB_CONNECTION)->table('project_supplier_links')->insertOrIgnore([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $rootSp->id,
|
||||
'platform' => $rootSp->platform,
|
||||
'subject_code' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
NB: использую FQCN inline (`\App\Support\SupplierIdentifier`, `\App\Models\SupplierProject`), чтобы не возиться с use-операторами в верху файла — если у тебя в файле уже есть `use App\Support\SupplierIdentifier;` и `use App\Models\SupplierProject;`, можно убрать backslash.
|
||||
|
||||
- [ ] **Step 4: Запустить тесты — должны пройти**
|
||||
|
||||
```bash
|
||||
cd app && ./vendor/bin/pest tests/Feature/Plan5/Jobs/SyncSupplierProjectJobTest.php 2>&1 | tail -15
|
||||
```
|
||||
|
||||
Expected: PASS (все тесты, включая 2 новых).
|
||||
|
||||
- [ ] **Step 5: Закоммитить**
|
||||
|
||||
```bash
|
||||
git add app/app/Jobs/SyncSupplierProjectJob.php app/tests/Feature/Plan5/Jobs/SyncSupplierProjectJobTest.php
|
||||
git commit -m "feat(supplier): auto-link субдомен-проекта к корневому supplier_project
|
||||
|
||||
В SyncSupplierProjectJob после создания основных линков добавляется
|
||||
блок: если signal_type=site и identifier — субдомен, ищем sp с
|
||||
unique_key=root и для каждого insertOrIgnore линк.
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-22-root-domain-auto-link-design.md §4.2
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Hook в `SyncSupplierProjectsJob` (массовая ночная синхронизация)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Jobs/Supplier/SyncSupplierProjectsJob.php:427` (после foreach с insertOrIgnore)
|
||||
- Test: расширить существующий `app/tests/Feature/Plan5/Jobs/SyncSupplierProjectJobTest.php` НЕ обязательно — массовый job вызывает ту же логику; покрытие из Task 2 достаточно для самой логики, но для регрессии массового джоба добавим feature-тест.
|
||||
|
||||
- [ ] **Step 1: Написать падающий feature-тест для массового джоба**
|
||||
|
||||
Найти существующий тест-файл для массового джоба:
|
||||
|
||||
```bash
|
||||
find app/tests -name "SyncSupplierProjectsJob*Test*" 2>&1
|
||||
```
|
||||
|
||||
Если файл существует — дописать тест в него. Если нет — создать `app/tests/Feature/Supplier/SyncSupplierProjectsRootLinkTest.php` (минимальный новый файл).
|
||||
|
||||
Минимальный тест (без полной mock'инфраструктуры — фокус на блоке auto-root):
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\Supplier\SyncSupplierProjectsJob;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Supplier\Channel\SupplierProjectChannel;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
|
||||
it('SyncSupplierProjectsJob bulk: subdomain projects получают root-link', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'client.creddy.ru',
|
||||
]);
|
||||
|
||||
$rootSp = SupplierProject::create([
|
||||
'platform' => 'B2',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'creddy.ru',
|
||||
'supplier_external_id' => 'ext-creddy-root',
|
||||
'current_limit' => 100,
|
||||
'sync_status' => 'ok',
|
||||
]);
|
||||
|
||||
$this->mock(SupplierProjectChannel::class, function ($mock) {
|
||||
$mock->shouldReceive('listProjects')->andReturn([]);
|
||||
$mock->shouldReceive('createProject')->andReturn(800301, 800302, 800303);
|
||||
});
|
||||
|
||||
(new SyncSupplierProjectsJob())->handle(app(SupplierProjectChannel::class));
|
||||
|
||||
expect(
|
||||
DB::connection('pgsql_supplier')->table('project_supplier_links')
|
||||
->where('project_id', $project->id)
|
||||
->where('supplier_project_id', $rootSp->id)
|
||||
->exists()
|
||||
)->toBeTrue();
|
||||
});
|
||||
```
|
||||
|
||||
NB: если интерфейс `SupplierProjectChannel` в проекте отличается (другие методы, обязательные параметры) — посмотри как мокается в существующем `SyncSupplierProjectJobTest.php` и адаптируй mock. Главное — добиться, чтобы джоб дошёл до блока `foreach ($groupProjects as $lp) { foreach ($existingSps as $sp) { insertOrIgnore ... } }` (строка 418).
|
||||
|
||||
- [ ] **Step 2: Запустить тест — должен упасть**
|
||||
|
||||
```bash
|
||||
cd app && ./vendor/bin/pest tests/Feature/Supplier/SyncSupplierProjectsRootLinkTest.php 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: FAIL — `expect()->toBeTrue()` не сработает, потому что root-link ещё не создаётся.
|
||||
|
||||
- [ ] **Step 3: Добавить блок auto-root-link в `SyncSupplierProjectsJob`**
|
||||
|
||||
Открыть `app/app/Jobs/Supplier/SyncSupplierProjectsJob.php`. Сразу **после** строки 427 (после закрытия двойного `foreach ($groupProjects as $lp) { foreach ($existingSps as $sp) {...} }`), вставить:
|
||||
|
||||
```php
|
||||
|
||||
// Auto-link к корневому домену (spec 2026-05-22).
|
||||
// groupProjects шарят один и тот же identifier — вычисляем root один раз.
|
||||
$firstProject = $groupProjects[0] ?? null;
|
||||
if ($firstProject !== null && $firstProject->signal_type === 'site') {
|
||||
$rootIdentifier = \App\Support\SupplierIdentifier::extractRootDomain(
|
||||
(string) $firstProject->signal_identifier
|
||||
);
|
||||
if ($rootIdentifier !== null) {
|
||||
$rootSps = \App\Models\SupplierProject::on(self::DB_CONNECTION)
|
||||
->where('unique_key', $rootIdentifier)
|
||||
->where('signal_type', 'site')
|
||||
->get();
|
||||
foreach ($groupProjects as $lp) {
|
||||
foreach ($rootSps as $rootSp) {
|
||||
DB::connection(self::DB_CONNECTION)->table('project_supplier_links')->insertOrIgnore([
|
||||
'project_id' => $lp->id,
|
||||
'supplier_project_id' => $rootSp->id,
|
||||
'platform' => $rootSp->platform,
|
||||
'subject_code' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Запустить тест — должен пройти**
|
||||
|
||||
```bash
|
||||
cd app && ./vendor/bin/pest tests/Feature/Supplier/SyncSupplierProjectsRootLinkTest.php 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Закоммитить**
|
||||
|
||||
```bash
|
||||
git add app/app/Jobs/Supplier/SyncSupplierProjectsJob.php app/tests/Feature/Supplier/SyncSupplierProjectsRootLinkTest.php
|
||||
git commit -m "feat(supplier): auto-link к root-домену в массовой ночной синхронизации
|
||||
|
||||
Аналогичный блок в SyncSupplierProjectsJob после двойного foreach
|
||||
insertOrIgnore основных линков (строка 427).
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-22-root-domain-auto-link-design.md §4.2
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Артизан-команда `supplier:backfill-root-links`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Console/Commands/BackfillRootSupplierLinksCommand.php`
|
||||
- Test: `app/tests/Feature/Console/BackfillRootSupplierLinksCommandTest.php`
|
||||
|
||||
- [ ] **Step 1: Написать падающий feature-тест**
|
||||
|
||||
Создать `app/tests/Feature/Console/BackfillRootSupplierLinksCommandTest.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
|
||||
it('backfill: добавляет root-link для проекта-субдомена с уже-существующими линками', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'krasnoyarsk.carmoney.ru',
|
||||
]);
|
||||
|
||||
$subdomainSp = SupplierProject::create([
|
||||
'platform' => 'B2',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'krasnoyarsk.carmoney.ru',
|
||||
'supplier_external_id' => 'ext-sub',
|
||||
'current_limit' => 100,
|
||||
'sync_status' => 'ok',
|
||||
]);
|
||||
$rootSp = SupplierProject::create([
|
||||
'platform' => 'B2',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'carmoney.ru',
|
||||
'supplier_external_id' => 'ext-root',
|
||||
'current_limit' => 100,
|
||||
'sync_status' => 'ok',
|
||||
]);
|
||||
|
||||
// Существующий линк только к субдомену.
|
||||
DB::connection('pgsql_supplier')->table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $subdomainSp->id,
|
||||
'platform' => 'B2',
|
||||
'subject_code' => null,
|
||||
]);
|
||||
|
||||
// Запуск команды.
|
||||
$exitCode = \Illuminate\Support\Facades\Artisan::call('supplier:backfill-root-links');
|
||||
expect($exitCode)->toBe(0);
|
||||
|
||||
// Должен появиться второй линк — к root-sp.
|
||||
expect(
|
||||
DB::connection('pgsql_supplier')->table('project_supplier_links')
|
||||
->where('project_id', $project->id)
|
||||
->where('supplier_project_id', $rootSp->id)
|
||||
->exists()
|
||||
)->toBeTrue();
|
||||
});
|
||||
|
||||
it('backfill: idempotent — повторный прогон ничего не добавляет', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'client.carmoney.ru',
|
||||
]);
|
||||
$subSp = SupplierProject::create([
|
||||
'platform' => 'B2', 'signal_type' => 'site', 'unique_key' => 'client.carmoney.ru',
|
||||
'supplier_external_id' => 'ext1', 'current_limit' => 100, 'sync_status' => 'ok',
|
||||
]);
|
||||
$rootSp = SupplierProject::create([
|
||||
'platform' => 'B2', 'signal_type' => 'site', 'unique_key' => 'carmoney.ru',
|
||||
'supplier_external_id' => 'ext2', 'current_limit' => 100, 'sync_status' => 'ok',
|
||||
]);
|
||||
DB::connection('pgsql_supplier')->table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id, 'supplier_project_id' => $subSp->id,
|
||||
'platform' => 'B2', 'subject_code' => null,
|
||||
]);
|
||||
|
||||
\Illuminate\Support\Facades\Artisan::call('supplier:backfill-root-links');
|
||||
$linksAfterFirstRun = DB::connection('pgsql_supplier')->table('project_supplier_links')
|
||||
->where('project_id', $project->id)->count();
|
||||
\Illuminate\Support\Facades\Artisan::call('supplier:backfill-root-links');
|
||||
$linksAfterSecondRun = DB::connection('pgsql_supplier')->table('project_supplier_links')
|
||||
->where('project_id', $project->id)->count();
|
||||
|
||||
expect($linksAfterFirstRun)->toBe(2); // sub + root
|
||||
expect($linksAfterSecondRun)->toBe(2); // никаких новых
|
||||
});
|
||||
|
||||
it('backfill --dry-run: ничего не пишет в БД', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'next.vashinvestor.ru',
|
||||
]);
|
||||
$subSp = SupplierProject::create([
|
||||
'platform' => 'B2', 'signal_type' => 'site', 'unique_key' => 'next.vashinvestor.ru',
|
||||
'supplier_external_id' => 'extn1', 'current_limit' => 100, 'sync_status' => 'ok',
|
||||
]);
|
||||
SupplierProject::create([
|
||||
'platform' => 'B2', 'signal_type' => 'site', 'unique_key' => 'vashinvestor.ru',
|
||||
'supplier_external_id' => 'extn2', 'current_limit' => 100, 'sync_status' => 'ok',
|
||||
]);
|
||||
DB::connection('pgsql_supplier')->table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id, 'supplier_project_id' => $subSp->id,
|
||||
'platform' => 'B2', 'subject_code' => null,
|
||||
]);
|
||||
|
||||
\Illuminate\Support\Facades\Artisan::call('supplier:backfill-root-links', ['--dry-run' => true]);
|
||||
|
||||
$count = DB::connection('pgsql_supplier')->table('project_supplier_links')
|
||||
->where('project_id', $project->id)->count();
|
||||
expect($count)->toBe(1); // только исходный линк, root не добавился
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить тест — должен упасть**
|
||||
|
||||
```bash
|
||||
cd app && ./vendor/bin/pest tests/Feature/Console/BackfillRootSupplierLinksCommandTest.php 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: FAIL — `Command "supplier:backfill-root-links" is not defined.`
|
||||
|
||||
- [ ] **Step 3: Реализовать команду**
|
||||
|
||||
Создать `app/app/Console/Commands/BackfillRootSupplierLinksCommand.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Support\SupplierIdentifier;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Однократный back-fill: для каждого активного site-проекта с identifier-субдоменом —
|
||||
* добавить линки к supplier_projects на корневом домене (если те существуют).
|
||||
*
|
||||
* Идемпотентна (insertOrIgnore по UNIQUE (project_id, supplier_project_id)).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-22-root-domain-auto-link-design.md §4.3
|
||||
*/
|
||||
class BackfillRootSupplierLinksCommand extends Command
|
||||
{
|
||||
protected $signature = 'supplier:backfill-root-links {--dry-run : показать что бы добавилось, не писать в БД}';
|
||||
|
||||
protected $description = 'Back-fill линков project_supplier_links к корневому домену для проектов-субдоменов';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$scanned = 0;
|
||||
$added = 0;
|
||||
$skippedAlreadyRoot = 0;
|
||||
$skippedNoRootSp = 0;
|
||||
|
||||
$projects = Project::query()
|
||||
->on('pgsql_supplier')
|
||||
->where('signal_type', 'site')
|
||||
->whereExists(function ($q): void {
|
||||
$q->select(DB::raw(1))
|
||||
->from('project_supplier_links')
|
||||
->whereColumn('project_supplier_links.project_id', 'projects.id');
|
||||
})
|
||||
->get(['id', 'signal_identifier']);
|
||||
|
||||
foreach ($projects as $project) {
|
||||
$scanned++;
|
||||
$root = SupplierIdentifier::extractRootDomain((string) $project->signal_identifier);
|
||||
if ($root === null) {
|
||||
$skippedAlreadyRoot++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$rootSps = SupplierProject::on('pgsql_supplier')
|
||||
->where('unique_key', $root)
|
||||
->where('signal_type', 'site')
|
||||
->get();
|
||||
|
||||
if ($rootSps->isEmpty()) {
|
||||
$skippedNoRootSp++;
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($rootSps as $rootSp) {
|
||||
$alreadyExists = DB::connection('pgsql_supplier')
|
||||
->table('project_supplier_links')
|
||||
->where('project_id', $project->id)
|
||||
->where('supplier_project_id', $rootSp->id)
|
||||
->exists();
|
||||
|
||||
if ($alreadyExists) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
DB::connection('pgsql_supplier')->table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $rootSp->id,
|
||||
'platform' => $rootSp->platform,
|
||||
'subject_code' => null,
|
||||
]);
|
||||
}
|
||||
$added++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'%sScanned: %d / Added: %d / Skipped (already root): %d / Skipped (no root sp): %d',
|
||||
$dryRun ? '[DRY-RUN] ' : '',
|
||||
$scanned,
|
||||
$added,
|
||||
$skippedAlreadyRoot,
|
||||
$skippedNoRootSp,
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
NB: если auto-discovery команд не работает (не Laravel-default) — проверить регистрацию в `app/app/Console/Kernel.php` или `app/bootstrap/app.php`. Команда из `app/app/Console/Commands/` автоматически подбирается в стандартной конфигурации Laravel 11+.
|
||||
|
||||
- [ ] **Step 4: Запустить тест — должен пройти**
|
||||
|
||||
```bash
|
||||
cd app && ./vendor/bin/pest tests/Feature/Console/BackfillRootSupplierLinksCommandTest.php 2>&1 | tail -15
|
||||
```
|
||||
|
||||
Expected: PASS (3 теста).
|
||||
|
||||
- [ ] **Step 5: Закоммитить**
|
||||
|
||||
```bash
|
||||
git add app/app/Console/Commands/BackfillRootSupplierLinksCommand.php app/tests/Feature/Console/BackfillRootSupplierLinksCommandTest.php
|
||||
git commit -m "feat(supplier): артизан-команда supplier:backfill-root-links
|
||||
|
||||
Однократный back-fill для уже существующих проектов: для каждого
|
||||
site-проекта с identifier-субдоменом добавляет линки к корневым sp,
|
||||
если те уже есть в supplier_projects. Идемпотентна.
|
||||
|
||||
--dry-run для предварительного просмотра.
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-22-root-domain-auto-link-design.md §4.3
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Финальная регрессия фичи root-link
|
||||
|
||||
**Files:** ничего не создаём, только проверяем.
|
||||
|
||||
- [ ] **Step 1: Запустить тесты по затронутым областям**
|
||||
|
||||
```bash
|
||||
cd app && ./vendor/bin/pest tests/Unit/Support/SupplierIdentifierTest.php tests/Feature/Plan5/Jobs/SyncSupplierProjectJobTest.php tests/Feature/Supplier/SyncSupplierProjectsRootLinkTest.php tests/Feature/Console/BackfillRootSupplierLinksCommandTest.php 2>&1 | tail -20
|
||||
```
|
||||
|
||||
Expected: всё PASS.
|
||||
|
||||
- [ ] **Step 2: Запустить более широкую регрессию по supplier-области**
|
||||
|
||||
```bash
|
||||
cd app && ./vendor/bin/pest tests/Feature/Supplier tests/Feature/Plan5 2>&1 | tail -20
|
||||
```
|
||||
|
||||
Expected: всё PASS. Если что-то падает — исправлять прежде чем двигаться к Phase B/C/D.
|
||||
|
||||
- [ ] **Step 3: Type-check + Larastan baseline (быстрая проверка)**
|
||||
|
||||
```bash
|
||||
cd app && composer stan 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: 0 ошибок поверх baseline.
|
||||
|
||||
- [ ] **Step 4: Pint code-style**
|
||||
|
||||
```bash
|
||||
cd app && composer pint -- --test 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Если есть нарушения — `composer pint` (без `-- --test`), затем `git add -u && git commit --amend --no-edit`.
|
||||
|
||||
Expected: clean.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (проводится после написания плана автором)
|
||||
|
||||
**Spec coverage:**
|
||||
|
||||
- §4.1 утилита extractRootDomain → Task 1 ✓
|
||||
- §4.2 hook в SyncSupplierProjectJob → Task 2 ✓
|
||||
- §4.2 hook в SyncSupplierProjectsJob (массовый) → Task 3 ✓
|
||||
- §4.3 артизан-команда + --dry-run + идемпотентность → Task 4 ✓
|
||||
- §5.1 unit-тесты утилиты → Task 1 Step 1 ✓
|
||||
- §5.2 feature subdomain → 2 link sets → Task 2 Step 1 ✓
|
||||
- §5.3 root project → только свои линки → Task 2 Step 1 (второй it) ✓
|
||||
- §5.4 call/sms → root-логика skip → covered by Task 1 (unit tests на не-домены) + по факту не идёт через if signal_type === 'site' в Task 2/3 ✓
|
||||
- §5.5 идемпотентность + dry-run → Task 4 Step 1 ✓
|
||||
- §6 деплой / прод-backfill — Phase E плана выше, вне этого плана
|
||||
- §7 rollback — readiness описана в спеке, не код
|
||||
|
||||
**Placeholder scan:** ни одного TBD / TODO / «implement later» / «similar to». Везде показан код. ✓
|
||||
|
||||
**Type consistency:** `extractRootDomain` возвращает `?string` — везде нулл-чек. `signal_type === 'site'` — string match. `unique_key`/`platform`/`signal_type` — поля `supplier_projects` (по schema.sql). `DB_CONNECTION = 'pgsql_supplier'` — константа джоба, явно использую в каждом insertOrIgnore. ✓
|
||||
@@ -0,0 +1,186 @@
|
||||
# Автолинковка проекта-субдомена к корневому домену поставщика
|
||||
|
||||
**Дата:** 2026-05-22
|
||||
**Статус:** approved (Дмитрий, через брейнсторминг 22.05)
|
||||
**Связано:** [PR будет добавлен после реализации]
|
||||
|
||||
## 1. Контекст
|
||||
|
||||
Поставщик `crm.bp-gr.ru` шлёт лиды через webhook с полем `raw_payload.project`, например:
|
||||
|
||||
- `"B2_заявка carmoney.ru/"` — корневой домен
|
||||
- `"B1_Платежи cabinet.caranga.ru/login"` — субдомен внутри текста
|
||||
- `"B3_next.vashinvestor.ru"` — субдомен
|
||||
- `"B1_7800XXXXXXX"` — телефон (call-сигнал)
|
||||
|
||||
`parseProjectField()` в [RouteSupplierLeadJob.php:175](../../../app/app/Jobs/RouteSupplierLeadJob.php#L175) извлекает чистый identifier (`carmoney.ru` / `cabinet.caranga.ru` / `next.vashinvestor.ru` / телефон), под него `resolveOrStub()` находит/создаёт `supplier_projects.unique_key`, и `LeadRouter::matchEligibleProjects()` ищет привязанные к этому `supplier_project.id` тенант-проекты через таблицу `project_supplier_links`.
|
||||
|
||||
Текущий гэп: поставщик иногда шлёт **корневой домен** (например, `carmoney.ru/`), а тенант подписан только на **субдомены** (`krasnoyarsk.carmoney.ru` и `client.carmoney.ru`). `unique_key`'и не совпадают → ни одной матчинг-связки → `deals_created_count = 0` тихо, без инцидента.
|
||||
|
||||
Аналитика 22.05:
|
||||
|
||||
- 99 лидов с `carmoney.ru` (root) за 24 часа → 0 сделок
|
||||
- 7 silent-no-deal из последних 13 лидов после починки старого ретрай-шторма
|
||||
|
||||
## 2. Цели
|
||||
|
||||
1. Подписчик на любой субдомен крупного сайта автоматически получает также лиды с корневого домена этого сайта.
|
||||
2. Не задеть существующие линки/маршрутизацию.
|
||||
3. Решить и для уже существующих проектов (back-fill).
|
||||
|
||||
## 3. Что не закрывает (non-goals)
|
||||
|
||||
- **348 потерянных лидов 21.05–22.05 утра** до создания проектов тенанта 2 — отдельный back-fill (Phase E плана).
|
||||
- **Случай, когда подписчика на бренд нет вообще.** Если никто не подписан ни на корень, ни на субдомен — silent skip остаётся ожидаемым поведением.
|
||||
- **Реверс-кейс:** клиент с корневым `carmoney.ru` НЕ получает автоматически субдомены. Явная подписка — явный матч (option C из брейнсторминга).
|
||||
- **`incidents_log` observability** — отдельная фаза C плана.
|
||||
- **Реакция на появление корневого `supplier_project` ПОСЛЕ создания проекта-субдомена** — в данных она уже отработана разовым back-fill'ом; future-proof механизм (триггер на upsert supplier_projects) — out of scope.
|
||||
|
||||
## 4. Архитектура
|
||||
|
||||
### 4.1. Утилита извлечения корня
|
||||
|
||||
Новый класс `App\Support\SupplierIdentifier::extractRootDomain(string $identifier): ?string`.
|
||||
|
||||
Правило:
|
||||
|
||||
1. Если строка не похожа на домен (нет точки или есть только цифры/спецсимволы) — вернуть `null`.
|
||||
2. Если в идентификаторе ≤ 2 сегментов через точку — он уже корневой, вернуть `null`.
|
||||
3. Иначе — `implode('.', array_slice(explode('.', $identifier), -2))`.
|
||||
|
||||
Таблица:
|
||||
|
||||
| вход | выход | объяснение |
|
||||
|------|-------|------------|
|
||||
| `krasnoyarsk.carmoney.ru` | `carmoney.ru` | 3 сегмента → 2 последних |
|
||||
| `next.vashinvestor.ru` | `vashinvestor.ru` | 3 → 2 |
|
||||
| `cabinet.caranga.ru` | `caranga.ru` | 3 → 2 |
|
||||
| `client.carmoney.ru` | `carmoney.ru` | 3 → 2 |
|
||||
| `carmoney.ru` | `null` | уже корень |
|
||||
| `заложитьптс.рф` | `null` | уже корень, кириллица допустима |
|
||||
| `7800XXXXXXX` | `null` | не домен (нет точки) |
|
||||
| `` | `null` | пустая |
|
||||
|
||||
Не поддерживается public-suffix-list (т.е. `*.co.uk` обработается как `*.co.uk` → root `co.uk` — некорректно), но у нас таких доменов нет — фиксируем явно и не усложняем.
|
||||
|
||||
### 4.2. Точка врезки в синхронизацию
|
||||
|
||||
После создания основных линков для проекта (в обоих синк-джобах) добавляется блок «auto-root-link»:
|
||||
|
||||
**В [SyncSupplierProjectJob.php](../../../app/app/Jobs/SyncSupplierProjectJob.php) после строки 318 (внутри метода `handle`)**:
|
||||
|
||||
```php
|
||||
// Auto-link к корневому домену для проектов-субдоменов (spec 2026-05-22).
|
||||
if ($project->signal_type === 'site') {
|
||||
$rootIdentifier = SupplierIdentifier::extractRootDomain((string) $project->signal_identifier);
|
||||
if ($rootIdentifier !== null) {
|
||||
$rootSps = SupplierProject::query()
|
||||
->where('unique_key', $rootIdentifier)
|
||||
->where('signal_type', 'site')
|
||||
->get();
|
||||
foreach ($rootSps as $rootSp) {
|
||||
DB::connection(self::DB_CONNECTION)->table('project_supplier_links')->insertOrIgnore([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $rootSp->id,
|
||||
'platform' => $rootSp->platform,
|
||||
'subject_code' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Аналогичный блок в [SyncSupplierProjectsJob.php:420](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L420)** — массовая ночная синхронизация — после существующего блока вставки.
|
||||
|
||||
Хук НЕ нужен в:
|
||||
|
||||
- `LeadRouter` — он остаётся декларативным: ищет по `project_supplier_links`, что лежит.
|
||||
- `RouteSupplierLeadJob` — не трогаем (только зависит от router'а).
|
||||
- `DeleteSupplierProjectJob` — удаление линка по `supplier_project_id` каскадом снесёт и автодобавленный root-линк, что корректно.
|
||||
|
||||
### 4.3. Однократный back-fill уже существующих проектов
|
||||
|
||||
Артизан-команда `App\Console\Commands\BackfillRootSupplierLinks`:
|
||||
|
||||
```bash
|
||||
php artisan supplier:backfill-root-links [--dry-run]
|
||||
```
|
||||
|
||||
Алгоритм:
|
||||
|
||||
1. Выбрать все активные проекты с `signal_type='site'`, у которых есть хотя бы один линк (`project_supplier_links`).
|
||||
2. Для каждого вычислить `rootIdentifier = extractRootDomain($project->signal_identifier)`.
|
||||
3. Если `null` → пропуск.
|
||||
4. Иначе — найти все `supplier_projects` с `unique_key = rootIdentifier`, `signal_type='site'`.
|
||||
5. Для каждого — `insertOrIgnore` в `project_supplier_links`.
|
||||
6. Логировать счётчики: `projects_scanned / links_added / projects_skipped_root / projects_skipped_no_root_sp`.
|
||||
|
||||
Идемпотентна (insertOrIgnore). Можно запускать многократно без эффекта на уже добавленные.
|
||||
|
||||
`--dry-run` режим — печатает, что бы добавил, но не пишет в БД.
|
||||
|
||||
## 5. Тесты (TDD)
|
||||
|
||||
### 5.1. Unit: `SupplierIdentifier::extractRootDomain`
|
||||
|
||||
Покрытие 7 кейсов из таблицы §4.1 + null/пустая строка + строка с пробелами.
|
||||
|
||||
Файл: `app/tests/Unit/Support/SupplierIdentifierTest.php`.
|
||||
|
||||
### 5.2. Feature: `SyncSupplierProjectJob` создаёт root-link
|
||||
|
||||
Сетап: тенант + проект `site krasnoyarsk.carmoney.ru` + supplier_projects `carmoney.ru` (B1/B2/B3) + `krasnoyarsk.carmoney.ru` (B1/B2/B3) уже синкнуты.
|
||||
|
||||
Ожидание: после `SyncSupplierProjectJob::handle()` в `project_supplier_links` есть 6 строк — по одной для каждого из 6 supplier_projects.
|
||||
|
||||
Файл: дополняет `app/tests/Feature/Jobs/SyncSupplierProjectJobTest.php` (или новый).
|
||||
|
||||
### 5.3. Feature: корневой проект → только свои линки, без рекурсии
|
||||
|
||||
Сетап: проект `site carmoney.ru` (root) + только sp `carmoney.ru` (B1/B2/B3).
|
||||
|
||||
Ожидание: 3 линка, не пытается рекурсивно искать sp `ru`.
|
||||
|
||||
### 5.4. Feature: call/sms проект → root-логика skip
|
||||
|
||||
Сетап: проект `call 7800XXXXXXX` + sp с тем же ключом.
|
||||
|
||||
Ожидание: основные линки создаются, root-блок не активируется (extractRootDomain вернёт null).
|
||||
|
||||
### 5.5. Feature: артизан-команда `supplier:backfill-root-links`
|
||||
|
||||
Сетап: проект `site krasnoyarsk.carmoney.ru` с одним линком к `krasnoyarsk.carmoney.ru` (B2). Существует sp `carmoney.ru` (B2).
|
||||
|
||||
Ожидание:
|
||||
|
||||
1. `--dry-run` → ничего не пишет, выводит «added: 1»
|
||||
2. Реальный прогон → добавляется 1 строка в `project_supplier_links` (к sp `carmoney.ru`)
|
||||
3. Повторный прогон → ничего не добавляется, выводит «added: 0»
|
||||
|
||||
## 6. Деплой
|
||||
|
||||
Поэтапно (см. план Phase D-E):
|
||||
|
||||
1. Деплой кода через копирование (как принято — ПИЛОТ.md §2).
|
||||
2. На проде: `php artisan supplier:backfill-root-links --dry-run` → визуальная проверка ожидаемых счётчиков.
|
||||
3. Реальный прогон: `php artisan supplier:backfill-root-links` → создаются root-линки для уже существующих проектов.
|
||||
4. Верификация через `psql`: для одного проекта тенанта 2 (например, project 87 `krasnoyarsk.carmoney.ru`) убедиться, что `project_supplier_links` содержит линки к sp `carmoney.ru` (37/38/39) и `krasnoyarsk.carmoney.ru` (248/249/250).
|
||||
|
||||
## 7. Риск и откат
|
||||
|
||||
**Риск:** низкий.
|
||||
|
||||
- Чистая addition: только добавляем `project_supplier_links`, ничего не удаляем и не меняем.
|
||||
- `insertOrIgnore` исключает дубликаты по PRIMARY KEY (project_id, supplier_project_id).
|
||||
- Роутер не меняется — логика декларативная.
|
||||
|
||||
**Откат:**
|
||||
|
||||
- Если что-то пошло не так после деплоя кода — `git revert` коммита, копирование старого файла обратно на сервер.
|
||||
- Если backfill-команда создала лишнее — точечный SQL `DELETE FROM project_supplier_links WHERE created_at > 'момент-запуска' AND supplier_project_id IN (SELECT id FROM supplier_projects WHERE unique_key NOT LIKE '%.%.%')`. `created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()` уже есть в схеме (`db/schema.sql` определение `project_supplier_links`).
|
||||
|
||||
## 8. Out-of-spec на будущее (не сейчас)
|
||||
|
||||
- Public-suffix-list (PSL) для корректной обработки `.co.uk` / `.com.au` и т.п.
|
||||
- Триггер «появился корневой supplier_project → добавить линки от всех проектов-субдоменов» — если будет регрессия с появлением новых корней.
|
||||
- UI-индикация на странице проекта: «также получает лиды с корневого `carmoney.ru`».
|
||||
Reference in New Issue
Block a user