1 Commits

Author SHA1 Message Date
Дмитрий ddad894404 fix(сделки): в колонке «Конкурент» показываем только бренд (без описания)
Имя конкурента в БД хранится как «Бренд, описание…» (напр. «AutoMoney,
микрокредитная компания»). Показывали полностью — описание, которое просили
убрать, возвращалось. Теперь берём часть до первой запятой (полное имя — в title).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 08:17:20 +03:00
94 changed files with 273 additions and 3273 deletions
@@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Autopodbor\AutopodborStudyScheduler;
use Illuminate\Console\Command;
class AutopodborStudyTickCommand extends Command
{
protected $signature = 'autopodbor:study-tick';
protected $description = 'Страховка: если дорожка шага 2 свободна, а очередь не пуста — запустить следующего.';
public function handle(AutopodborStudyScheduler $scheduler): int
{
$scheduler->tick();
return self::SUCCESS;
}
}
@@ -33,8 +33,7 @@ class IncidentsWatchFailures extends Command
{--threshold-daily=50 : Порог суммы за 24ч для failed_jobs}
{--persistent-hours=3 : Порог возраста persistent-exception для failed_jobs}
{--dedup-window=60 : Окно дедупа открытых инцидентов в минутах}
{--threshold-single-lead=1000 : Порог storm detection: failures одного supplier_lead_id за окно}
{--threshold-manual-queue=10 : Порог spike ручной очереди поставщика (за окно) возможное падение кабинета}';
{--threshold-single-lead=1000 : Порог storm detection: failures одного supplier_lead_id за окно}';
protected $description = 'Сканирует failed_webhook_jobs и failed_jobs, создаёт incidents_log на превышение порогов';
@@ -48,7 +47,6 @@ class IncidentsWatchFailures extends Command
$dedupMinutes = (int) $this->option('dedup-window');
$thresholdSingleLead = (int) $this->option('threshold-single-lead');
$thresholdManualQueue = (int) $this->option('threshold-manual-queue');
$since = Carbon::now()->subMinutes($windowMinutes);
$since24h = Carbon::now()->subHours(24);
@@ -223,35 +221,6 @@ class IncidentsWatchFailures extends Command
}
}
// ===== БЛОК 6: supplier-manual-queue spike =====
// Пачка проектов, ушедших в ручную очередь за короткое окно = вероятное падение
// кабинета поставщика. Инцидент дедупим (60 мин, чтобы не засорять журнал), НО письмо
// шлём на КАЖДОМ прогоне пока спайк держится — «сигналит как ненормальный» при реальной
// аварии, одним сводным письмом на оба адреса (не N писем на проект).
if ($thresholdManualQueue > 0) {
$manualQueueCount = (int) DB::connection(self::DB_CONNECTION)
->table('supplier_manual_sync_queue')
->where('created_at', '>=', $since)
->count();
if ($manualQueueCount >= $thresholdManualQueue) {
$dedupKey = 'supplier-manual-queue-spike';
$summary = "Похоже, кабинет поставщика упал: {$manualQueueCount} проект(ов) ушло в ручную очередь за {$windowMinutes} мин.";
// Инцидент — только если открытого с той же сигнатурой нет (дедуп 60 мин).
if (! $this->isDup($dedupKey, $dedupAt)) {
$this->createIncident($adminId, 'other', 'high', $summary, $since, $now, $dedupKey, sendMail: false);
$created++;
}
// Письмо — каждый прогон при активном спайке, на оба адреса.
Mail::to(['kdv1@bk.ru', 'ops@liderra.ru'])
->send(new IncidentDetectedMail($summary, 'high'));
$this->info("Supplier manual-queue spike [high]: {$manualQueueCount} in {$windowMinutes}m");
}
}
$this->info("Done. Created {$created} incident(s).");
return self::SUCCESS;
@@ -276,7 +245,6 @@ class IncidentsWatchFailures extends Command
Carbon $startedAt,
Carbon $now,
string $dedupKey = '',
bool $sendMail = true,
): void {
DB::connection(self::DB_CONNECTION)->table('incidents_log')->insert([
'type' => $type,
@@ -291,7 +259,7 @@ class IncidentsWatchFailures extends Command
'updated_at' => $now,
]);
if ($sendMail && $severity === 'high') {
if ($severity === 'high') {
Mail::to('kdv1@bk.ru')->send(new IncidentDetectedMail($summary, $severity));
}
}
@@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Autopodbor;
use RuntimeException;
final class BatchBudgetException extends RuntimeException
{
public function __construct(public readonly string $topupRub, public readonly int $maxAffordable)
{
parent::__construct('insufficient_balance_for_batch');
}
}
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Exceptions\Autopodbor\BatchBudgetException;
use App\Exceptions\Autopodbor\RunInFlightException;
use App\Exceptions\Billing\InsufficientBalanceException;
use App\Http\Controllers\Controller;
@@ -168,32 +167,6 @@ class AutopodborController extends Controller
}
}
/** POST /api/autopodbor/study/batch */
public function studyBatch(Request $request, AutopodborRunService $svc): JsonResponse
{
$data = $request->validate([
'competitor_ids' => ['required', 'array', 'min:1'],
'competitor_ids.*' => ['integer'],
]);
$uid = $request->user()->tenant_id;
try {
$res = $svc->startStudyBatch($uid, $data['competitor_ids']);
} catch (BatchBudgetException $e) {
return response()->json([
'message' => 'Не хватает баланса для сбора источников по выбранным конкурентам.',
'topup_rub' => $e->topupRub,
'max_affordable' => $e->maxAffordable,
], 422);
}
if ($res->queued === 0) {
return response()->json(['message' => 'Нечего ставить: выбранные конкуренты уже в очереди или не найдены.'], 422);
}
return response()->json(['batch_id' => $res->batch_id, 'queued' => $res->queued], 202);
}
/** POST /api/autopodbor/resolve */
public function resolve(Request $request, AutopodborRunService $svc): JsonResponse
{
@@ -14,7 +14,6 @@ use App\Models\SupplierLeadCost;
use App\Models\User;
use App\Services\Pd\PdAuditLogger;
use App\Services\SupplierResolver;
use App\Support\SupplierProjectName;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
@@ -232,7 +231,7 @@ class DealController extends Controller
'id' => $d->id,
'tenant_id' => $d->tenant_id,
'project_id' => $d->project_id,
'project_name' => SupplierProjectName::strip($d->project?->name),
'project_name' => $d->project?->name,
'phone' => $d->phone,
'contact_name' => $d->contact_name,
'status' => $d->status,
@@ -344,7 +343,7 @@ class DealController extends Controller
'id' => $deal->id,
'tenant_id' => $deal->tenant_id,
'project_id' => $deal->project_id,
'project_name' => SupplierProjectName::strip($deal->project?->name),
'project_name' => $deal->project?->name,
'phone' => $deal->phone,
'contact_name' => $deal->contact_name,
'comment' => $deal->comment,
@@ -8,7 +8,6 @@ use App\Http\Controllers\Controller;
use App\Models\Deal;
use App\Services\Pd\PdAuditLogger;
use App\Support\CsvFormulaGuard;
use App\Support\SupplierProjectName;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
@@ -122,7 +121,7 @@ class DealExportController extends Controller
foreach ($deals as $deal) {
/** @var Deal $deal */
$signal = $deal->project?->signal_type;
$source = trim((SupplierProjectName::strip($deal->project?->name) ?? '—').' · '
$source = trim(($deal->project?->name ?? '—').' · '
.(self::SIGNAL_LABELS[$signal] ?? '—'));
// F-CSV: свободный текст (телефон/источник/город/статус/
// комментарий) экранируем от formula-инъекции. Дата —
@@ -18,7 +18,7 @@ use Illuminate\Support\Facades\Auth;
/**
* Самозапись клиента (G1/SP1): register confirm-email (вход).
* Подтверждение почты 6-значным кодом; новый тенант создаётся в статусе
* pending_email_confirm, активируется и получает 1000 при подтверждении.
* pending_email_confirm, активируется и получает 300 при подтверждении.
*/
class RegistrationController extends Controller
{
@@ -6,7 +6,6 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Deal;
use App\Support\SupplierProjectName;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -88,7 +87,7 @@ class DealsController extends Controller
'contact_name' => $d->contact_name,
'city' => $d->city,
'status' => $d->status,
'project' => SupplierProjectName::strip($d->project?->name),
'project' => $d->project?->name,
])->all(),
'next_cursor' => $next,
]);
+96 -107
View File
@@ -12,7 +12,6 @@ use App\Services\Autopodbor\Agent\Dto\StudyCompetitorRequest;
use App\Services\Autopodbor\AutopodborChargeService;
use App\Services\Autopodbor\AutopodborDedup;
use App\Services\Autopodbor\AutopodborNormalizer;
use App\Services\Autopodbor\AutopodborStudyScheduler;
use App\Services\Autopodbor\RunProgressChannel;
use App\Support\SystemSettings;
use Illuminate\Bus\Queueable;
@@ -54,126 +53,116 @@ class RunAutopodborStudyJob implements ShouldQueue
AutopodborChargeService $charge,
AutopodborNormalizer $norm,
RunProgressChannel $progress,
AutopodborStudyScheduler $scheduler,
): void {
// Внешний try/finally: распорядитель должен взять следующего кандидата ПОСЛЕ ЛЮБОГО
// завершения этого прогона — done/empty/failed, включая ранние return'ы и проброшенное
// исключение из внутреннего catch. Дорожка шага 2 одна на весь портал (см.
// AutopodborStudyScheduler) — если не звать tick() здесь, освободившаяся дорожка простаивала
// бы до следующего крон-тика (autopodbor:study-tick, страховка раз в минуту).
// Сначала представляемся базе, ПОТОМ читаем прогон: на боевом роль crm_app_user под FORCE RLS
// не видит строку без tenant-контекста (на dev маскировалось суперюзером postgres → ModelNotFound
// всплыл только на проде 07.07.2026). tenantId приходит из dispatch. Старый путь без tenantId
// (только тесты, где ambient-контекст уже задан) — безопасный откат ниже. Контекст сессионный —
// все дальнейшие запросы (включая вложенные транзакции charge) видят GUC.
if ($this->tenantId !== null) {
DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $this->tenantId]);
}
$run = AutopodborRun::findOrFail($this->runId);
if ($this->tenantId === null) {
DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $run->tenant_id]);
}
// Идемпотентность: завершённый прогон повторно не обрабатываем (защита от лишнего ретрая/повторного dispatch).
if (in_array($run->status, ['done', 'empty', 'failed'], true)) {
return;
}
$run->update(['status' => 'running', 'started_at' => now()]);
// Привязываем канал прогресса к этому прогону — движок отметит этапы изучения именно сюда.
$progress->bind($run->id);
try {
// Сначала представляемся базе, ПОТОМ читаем прогон: на боевом роль crm_app_user под FORCE RLS
// не видит строку без tenant-контекста (на dev маскировалось суперюзером postgres → ModelNotFound
// всплыл только на проде 07.07.2026). tenantId приходит из dispatch. Старый путь без tenantId
// (только тесты, где ambient-контекст уже задан) — безопасный откат ниже. Контекст сессионный —
// все дальнейшие запросы (включая вложенные транзакции charge) видят GUC.
if ($this->tenantId !== null) {
DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $this->tenantId]);
}
$comp = AutopodborCompetitor::findOrFail($run->competitor_id);
$run = AutopodborRun::findOrFail($this->runId);
$res = $agent->studyCompetitor(new StudyCompetitorRequest(
competitor: [
'name' => $comp->name,
'site_url' => $comp->site_url,
'directory_urls' => $comp->directory_urls ?? [],
'elements' => $comp->elements ?? [],
'is_federal' => (bool) $comp->is_federal,
],
regionCode: (int) $run->region_code,
));
if ($this->tenantId === null) {
DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $run->tenant_id]);
}
$unique = $dedup->dedupSources($res->sources);
if ($unique === []) {
$run->update(['status' => 'empty', 'finished_at' => now()]);
// Идемпотентность: завершённый прогон повторно не обрабатываем (защита от лишнего ретрая/повторного dispatch).
if (in_array($run->status, ['done', 'empty', 'failed'], true)) {
return;
}
$run->update(['status' => 'running', 'started_at' => now()]);
// Классификация для итога (важно при ПОВТОРНОМ сборе): сколько источников совсем новых,
// а какие — ранее удалённые (box=archived), которые движок нашёл снова. Известные (в работе/
// предложениях) не трогаем — updateOrCreate не меняет им box (его нет в массиве значений).
$newCount = 0;
$refoundDeletedIds = [];
foreach ($unique as $s) {
$identifier = $s['signal_type'] === 'call'
? $norm->phone($s['identifier'])
: $norm->domainHead($s['identifier']);
// Привязываем канал прогресса к этому прогону — движок отметит этапы изучения именно сюда.
$progress->bind($run->id);
$existing = AutopodborSource::where('competitor_id', $comp->id)
->where('dedup_key', $s['dedup_key'])
->first();
try {
$comp = AutopodborCompetitor::findOrFail($run->competitor_id);
$res = $agent->studyCompetitor(new StudyCompetitorRequest(
competitor: [
'name' => $comp->name,
'site_url' => $comp->site_url,
'directory_urls' => $comp->directory_urls ?? [],
'elements' => $comp->elements ?? [],
'is_federal' => (bool) $comp->is_federal,
AutopodborSource::updateOrCreate(
[
'competitor_id' => $comp->id,
'dedup_key' => $s['dedup_key'],
],
regionCode: (int) $run->region_code,
));
[
'tenant_id' => $run->tenant_id,
'study_run_id' => $run->id,
'signal_type' => $s['signal_type'],
'identifier' => $identifier,
'phone_kind' => $s['phone_kind'] ?? null,
'phone_type' => $s['phone_type'] ?? null,
'provenance_url' => $s['provenance_url'] ?? null,
'provenance_label' => $s['provenance_label'] ?? null,
'where_found' => $s['where_found'] ?? null,
'office' => $s['office'] ?? null,
'confirmations' => $s['confirmations'] ?? 1,
]
);
$unique = $dedup->dedupSources($res->sources);
if ($unique === []) {
$run->update(['status' => 'empty', 'finished_at' => now()]);
return;
if ($existing === null) {
$newCount++; // совсем новый → в предложения
} elseif ($existing->box === 'archived') {
$refoundDeletedIds[] = $existing->id; // ранее удалённый, найден снова → «вернуть?»
}
// Классификация для итога (важно при ПОВТОРНОМ сборе): сколько источников совсем новых,
// а какие — ранее удалённые (box=archived), которые движок нашёл снова. Известные (в работе/
// предложениях) не трогаем — updateOrCreate не меняет им box (его нет в массиве значений).
$newCount = 0;
$refoundDeletedIds = [];
foreach ($unique as $s) {
$identifier = $s['signal_type'] === 'call'
? $norm->phone($s['identifier'])
: $norm->domainHead($s['identifier']);
$existing = AutopodborSource::where('competitor_id', $comp->id)
->where('dedup_key', $s['dedup_key'])
->first();
AutopodborSource::updateOrCreate(
[
'competitor_id' => $comp->id,
'dedup_key' => $s['dedup_key'],
],
[
'tenant_id' => $run->tenant_id,
'study_run_id' => $run->id,
'signal_type' => $s['signal_type'],
'identifier' => $identifier,
'phone_kind' => $s['phone_kind'] ?? null,
'phone_type' => $s['phone_type'] ?? null,
'provenance_url' => $s['provenance_url'] ?? null,
'provenance_label' => $s['provenance_label'] ?? null,
'where_found' => $s['where_found'] ?? null,
'office' => $s['office'] ?? null,
'confirmations' => $s['confirmations'] ?? 1,
]
);
if ($existing === null) {
$newCount++; // совсем новый → в предложения
} elseif ($existing->box === 'archived') {
$refoundDeletedIds[] = $existing->id; // ранее удалённый, найден снова → «вернуть?»
}
}
$price = (string) (SystemSettings::get('autopodbor_price_study_rub') ?? '0');
$charge->chargeForRun($run, $price);
$comp->update(['studied_at' => now(), 'study_run_id' => $run->id]);
$run->update([
'status' => 'done',
'finished_at' => now(),
'result' => [
'new_sources' => $newCount,
'refound_deleted' => count($refoundDeletedIds),
'refound_deleted_ids' => array_values($refoundDeletedIds),
],
]);
} catch (\Throwable $e) {
$run->update([
'status' => 'failed',
'error_code' => substr($e->getMessage(), 0, 64),
'finished_at' => now(),
]);
throw $e;
}
} finally {
$scheduler->tick();
$price = (string) (SystemSettings::get('autopodbor_price_study_rub') ?? '0');
$charge->chargeForRun($run, $price);
$comp->update(['studied_at' => now(), 'study_run_id' => $run->id]);
$run->update([
'status' => 'done',
'finished_at' => now(),
'result' => [
'new_sources' => $newCount,
'refound_deleted' => count($refoundDeletedIds),
'refound_deleted_ids' => array_values($refoundDeletedIds),
],
]);
} catch (\Throwable $e) {
$run->update([
'status' => 'failed',
'error_code' => substr($e->getMessage(), 0, 64),
'finished_at' => now(),
]);
throw $e;
}
}
}
+39 -36
View File
@@ -100,32 +100,36 @@ final class CsvReconcileJob implements ShouldQueue
'created_at' => now(),
]);
// Гарантированный канал (Путь 2, переработан 09.07.2026): сверяемся с журналом
// ОТДАННОГО («Мои сделки», по vid), а НЕ с пулом «Запрос номеров» (phones_cnt).
// Инцидент roistat✓ 09.07: пул отдавал 157 собранных, из них поставщик отгрузил 15,
// а старый reconcile лепил из пула 15 фантомов + выедал лимит клиента. Сверка по vid
// (точная личность лида) исключает фантомы и ловит подмену при совпадении количества.
$delivered = $portal->fetchDeliveredLeads($windowStart, $windowEnd);
$totalCsvRows = count($delivered);
$reportId = $portal->requestNumbersReport($windowStart, $windowEnd);
$portal->waitReportReady($reportId);
$csv = $portal->downloadReport($reportId);
// Уже принятые нами vid'ы — проверяем ГЛОБАЛЬНО и точечно по самим отданным vid
// (не по окну): idx_supplier_leads_vid_unique глобальный, а точечный whereIn ограничен
// числом отданных (сотни). Так дедуп исключает любой существующий vid ДО вставки —
// unique-violation в норме не наступает (иначе abort транзакции).
$existingVids = [];
$deliveredVids = array_map('intval', array_keys($delivered));
if ($deliveredVids !== []) {
DB::connection(self::DB_CONNECTION)
->table('supplier_leads')
->whereIn('vid', $deliveredVids)
->pluck('vid')
->each(function ($vid) use (&$existingVids): void {
$existingVids[(int) $vid] = true;
});
// CSV-строки по ключу phone|project (последняя строка с тем же ключом перетирает).
/** @var array<string, array{project: string, tag: string, phone: string}> $csvByKey */
$csvByKey = [];
foreach ($parser->parse($csv) as $row) {
$csvByKey[$this->dedupKey((string) $row['phone'], (string) $row['project'])] = $row;
}
$totalCsvRows = count($csvByKey);
// Недостача = отданные поставщиком vid'ы, которых у нас нет (webhook потерял).
$missing = array_diff_key($delivered, $existingVids);
// Существующие лиды за окно → set ключей phone|project.
$existingKeys = [];
DB::connection(self::DB_CONNECTION)
->table('supplier_leads')
->where('received_at', '>=', $windowStart)
->select('phone', 'raw_payload')
->orderBy('id')
->chunk(500, function ($leads) use (&$existingKeys): void {
foreach ($leads as $lead) {
$payload = is_string($lead->raw_payload)
? json_decode($lead->raw_payload, true)
: (array) $lead->raw_payload;
$project = (string) ($payload['project'] ?? '');
$existingKeys[$this->dedupKey((string) $lead->phone, $project)] = true;
}
});
$missing = array_diff_key($csvByKey, $existingKeys);
$recoveredCount = 0;
$unparseableCount = 0;
@@ -145,18 +149,11 @@ final class CsvReconcileJob implements ShouldQueue
}
try {
// vid — НАСТОЯЩИЙ (из журнала отданного). idx_supplier_leads_vid_unique делает
// recovery идемпотентным с поздним webhook: SupplierWebhookController при том же
// vid вернёт существующий лид, второй сделки не будет.
$lead = SupplierLead::create([
'vid' => $row['vid'],
'vid' => null,
'platform' => $platform,
'phone' => (string) $row['phone'],
'raw_payload' => [
'project' => $row['project'],
'phone' => (string) $row['phone'],
'vid' => $row['vid'],
],
'raw_payload' => $row,
'received_at' => now(),
'recovered_from_csv_at' => now(),
'source' => 'csv_recovery',
@@ -165,10 +162,8 @@ final class CsvReconcileJob implements ShouldQueue
RouteSupplierLeadJob::dispatch($lead->id);
$recoveredCount++;
} catch (QueryException $e) {
// Гонка с webhook (тот же vid успел вставиться между chunk-снимком и insert'ом)
// ловится unique-индексом. Это не потеря — лид уже у нас, добор не нужен.
Log::info('csv_reconcile.lead_insert_skipped_race', [
'vid' => $row['vid'],
Log::warning('csv_reconcile.lead_insert_failed', [
'phone' => $row['phone'],
'project' => $row['project'],
'error' => $e->getMessage(),
]);
@@ -261,6 +256,14 @@ final class CsvReconcileJob implements ShouldQueue
}
}
/**
* Ключ дедупа: нормализованный phone + project.
*/
private function dedupKey(string $phone, string $project): string
{
return trim($phone).'|'.trim($project);
}
/** Был ли алерт о падении сверки за последнее окно троттла (анти-спам). */
private function failureAlertRecentlySent(): bool
{
@@ -134,10 +134,6 @@ class SyncSupplierProjectsJob implements ShouldQueue
$groups[$key]['projects'][] = $project;
}
// Активные сегодня источники (по слепку) — их заказы НЕ гасим в deactivation-проходе.
/** @var array<string, bool> $activeKeys */
$activeKeys = array_fill_keys(array_keys($groups), true);
// 3. Sync each group
try {
foreach ($groups as $group) {
@@ -196,13 +192,6 @@ class SyncSupplierProjectsJob implements ShouldQueue
continue;
}
}
// 4. Deactivation (money-critical, 18:00 ДО снимка поставщика в 21:00): гасим
// заказы источников, у которых на сегодняшний слепок не осталось ни одного
// активного проекта. Пропускаем при aborted (time-budget/mass-fail/auth).
if (! $aborted) {
$this->deactivateOrphanedOrders($activeKeys);
}
} finally {
$this->recordRunSummary(
startedAt: $startedAt,
@@ -216,103 +205,6 @@ class SyncSupplierProjectsJob implements ShouldQueue
}
}
/**
* Deactivation (18:00, money-critical owner-reported 2026-07-08, verified on prod live DB):
* гасим у поставщика заказы источников, у которых на СЕГОДНЯШНИЙ слепок не осталось ни
* одного активного проекта. Поставщик фиксирует завтрашнюю заливку своим снимком в 21:00,
* поэтому единственное окно остановить заказ этот 18:00-прогон ДО снимка. Без этого
* осиротевший заказ оставался включённым (галочка вкл, лимит > 0) поставщик лил лиды и
* списывал за остановленный клиентом проект.
*
* Активность из СЛЕПКА ($activeKeys = ключи sync-групп за завтра), НЕ из live is_active:
* слепок-инвариант проект, paused'нутый уже ПОСЛЕ снятия слепка, честно доезжает
* поставщику (см. collectEligibleProjects); гасим только то, чего в слепке нет.
*
* Осиротевший = supplier_project с external_id, inactive_since IS NULL, чей
* (signal_type|unique_key) не входит в $activeKeys. Шлём updateProject(status='paused') с
* ненулевым лимитом (кабинет отклоняет limit=0 даже при паузе), помечаем inactive_since
* ТОЛЬКО после успешной отправки (иначе ретрай следующим прогоном). Реактивацию при
* возврате проекта делают CleanupInactiveSupplierProjectsJob (Phase A) + syncGroup(active).
*
* @param array<string, bool> $activeKeys ключи «signal_type|identifier» активных групп
*/
private function deactivateOrphanedOrders(array $activeKeys): int
{
$paused = 0;
// get() (не cursor) — на shared PDO тестов курсор конфликтует с write'ами в цикле;
// набор ограничен (активные + ещё-не-погашенные осиротевшие заказы).
$candidates = SupplierProject::on(self::DB_CONNECTION)
->whereNotNull('supplier_external_id')
->whereNull('inactive_since')
->orderBy('id')
->get();
foreach ($candidates as $sp) {
if (isset($activeKeys[$sp->signal_type.'|'.$sp->unique_key])) {
continue; // источник ещё активен в сегодняшнем слепке — не трогаем
}
if (now()->timezone('Europe/Moscow')->format('H:i') >= self::TIME_BUDGET_CUTOFF) {
break; // время вышло — недогашенное подберёт следующий прогон
}
$regions = array_map('intval', (array) ($sp->current_regions ?? []));
$workdays = $sp->current_workdays !== null && $sp->current_workdays !== []
? array_map('intval', (array) $sp->current_workdays)
: [1, 2, 3, 4, 5, 6, 7];
$tag = count($regions) === 1
? (RussianRegions::CODE_TO_NAME[$regions[0]] ?? (string) $regions[0])
: 'РФ';
$dto = new SupplierProjectDto(
platform: $sp->platform,
signalType: (string) $sp->signal_type,
uniqueKey: (string) $sp->unique_key,
limit: max(1, (int) $sp->current_limit),
workdays: $workdays,
regions: $regions,
regionsReverse: false,
status: 'paused',
tag: $tag,
platforms: [$sp->platform],
);
try {
$this->channel->updateProject((int) $sp->supplier_external_id, $dto);
} catch (Throwable $e) {
// Transient/portal error — inactive_since НЕ ставим, ретрай следующим прогоном.
Log::warning('supplier.sync.deactivate_failed', [
'unique_key' => (string) $sp->unique_key,
'platform' => $sp->platform,
'error' => $e->getMessage(),
]);
continue;
}
$sp->forceFill([
'sync_status' => 'ok',
'last_synced_at' => now(),
'inactive_since' => now(),
])->save();
SupplierSyncLog::on(self::DB_CONNECTION)->create([
'supplier_project_id' => $sp->id,
'action' => 'update',
'http_status' => 200,
'created_at' => now(),
]);
$paused++;
}
if ($paused > 0) {
Log::info('supplier.sync.deactivated_orphaned', ['count' => $paused]);
}
return $paused;
}
/**
* Эпик 5: одна строка-сводка в supplier_sync_runs по завершении заливки.
* Пишется и при раннем abort (time-budget / mass-fail / auth) через finally.
-1
View File
@@ -18,7 +18,6 @@ class AutopodborRun extends Model
'region_code',
'params',
'competitor_id',
'batch_id',
'price_rub_charged',
'balance_transaction_id',
'error_code',
+2
View File
@@ -21,6 +21,7 @@ use App\Services\Supplier\ProcessFactory;
use App\Services\Supplier\SymfonyProcessFactory;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
@@ -48,6 +49,7 @@ class AppServiceProvider extends ServiceProvider
fn ($app) => new FailoverProjectChannel(
$app->make(AjaxProjectChannel::class),
$app->make(FormProjectChannel::class),
$app->make(Mailer::class),
),
);
+6 -9
View File
@@ -13,11 +13,8 @@ use InvalidArgumentException;
* verify (App\Console\Commands\VerifyAuditChains) and rebuild
* (App\Console\Commands\AuditRebuildChain).
*
* ADR-021 (supersedes ADR-018): global-within-partition для ВСЕХ таблиц.
* Эмпирика на проде 2026-07-09 (все 4 бывшие «per-tenant» таблицы, включая
* balance_transactions): триггер `audit_chain_hash()` де-факто пишет глобальную
* цепочку (RLS не скоупит его SELECT) per-tenant изоляция ADR-018 никогда не
* была реализована. Verify/rebuild приведены к реальности (partition => '').
* ADR-018: per-tenant via RLS scope for tenant tables,
* global for BYPASSRLS tables.
*
* columns: list in ordinal_position order from db/schema.sql.
* '__log_hash__' -- marker for log_hash position -> NULL::bytea in ROW().
@@ -46,7 +43,7 @@ final class AuditChainConfig
'old_value', 'new_value', 'context', 'ip_address', 'user_agent',
'__log_hash__', 'created_at',
],
'partition' => '', // ADR-021: global-within-partition (per-tenant never реализован)
'partition' => 'PARTITION BY tenant_id',
],
'tenant_operations_log' => [
'columns' => [
@@ -54,7 +51,7 @@ final class AuditChainConfig
'event', 'payload_before', 'payload_after', 'ip_address', 'user_agent',
'__log_hash__', 'created_at',
],
'partition' => '', // ADR-021: global-within-partition (per-tenant never реализован)
'partition' => 'PARTITION BY tenant_id',
],
'balance_transactions' => [
'columns' => [
@@ -63,7 +60,7 @@ final class AuditChainConfig
'related_type', 'related_id', 'user_id', 'admin_user_id',
'__log_hash__', 'created_at',
],
'partition' => '', // ADR-021: global-within-partition (per-tenant never реализован)
'partition' => 'PARTITION BY tenant_id',
],
'pd_processing_log' => [
'columns' => [
@@ -71,7 +68,7 @@ final class AuditChainConfig
'purpose', 'actor_tenant_user_id', 'actor_admin_user_id', 'ip_address',
'__log_hash__', 'created_at',
],
'partition' => '', // ADR-021: global-within-partition (per-tenant never реализован)
'partition' => 'PARTITION BY tenant_id',
],
'saas_admin_audit_log' => [
'columns' => [
@@ -35,7 +35,7 @@ class RegistrationService
private const MAX_FAILED_ATTEMPTS = 5;
private const START_BALANCE_RUB = '1000.00';
private const START_BALANCE_RUB = '300.00';
public function __construct(private readonly CaptchaVerifier $captcha) {}
@@ -1,79 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor;
use App\Models\PricingTier;
use App\Services\Billing\BalancePreflightService;
use Illuminate\Database\Eloquent\Collection;
final class AutopodborBudgetGate
{
public function __construct(
private readonly BalancePreflightService $preflight = new BalancePreflightService,
) {}
/** @param Collection<int, PricingTier> $tiers */
public function evaluateBatch(
string $balanceRub,
int $deliveredInMonth,
int $committedLeads,
Collection $tiers,
int $count,
string $priceRub,
): BudgetDecision {
// Бесплатный сбор (цена = 0): пакет всегда разрешён независимо от баланса и резерва
// проектов — списывать нечего. Иначе binary-search ниже даёт hardMax=0 (деление на цену
// 0 → 0) и пакет ложно отказывается «пополните на 0 ₽».
if (bccomp($priceRub, '0', 2) === 0) {
return new BudgetDecision(
allowed: true,
maxAffordable: $count,
topupRub: '0.00',
);
}
$passesAfter = function (int $k) use ($balanceRub, $deliveredInMonth, $committedLeads, $tiers, $priceRub): bool {
$spent = bcmul((string) $k, $priceRub, 2);
$after = bcsub($balanceRub, $spent, 2);
if (bccomp($after, '0.00', 2) < 0) {
return false;
}
if ($committedLeads <= 0) {
return true;
}
return $this->preflight->evaluate($after, $deliveredInMonth, $committedLeads, $tiers)->passes;
};
$priceKop = (int) bcmul($priceRub, '100', 0);
$balanceKop = (int) bcmul($balanceRub, '100', 0);
$hardMax = $priceKop > 0 ? intdiv($balanceKop, $priceKop) : 0;
$lo = 0;
$hi = $hardMax;
while ($lo < $hi) {
$mid = intdiv($lo + $hi + 1, 2);
if ($passesAfter($mid)) {
$lo = $mid;
} else {
$hi = $mid - 1;
}
}
$maxAffordable = $lo;
$allowed = $count <= $maxAffordable;
$topupRub = '0.00';
if (! $allowed) {
$deficitUnits = $count - $maxAffordable;
$topupRub = bcmul((string) $deficitUnits, $priceRub, 2);
}
return new BudgetDecision(
allowed: $allowed,
maxAffordable: $maxAffordable,
topupRub: $topupRub,
);
}
}
@@ -4,26 +4,20 @@ declare(strict_types=1);
namespace App\Services\Autopodbor;
use App\Exceptions\Autopodbor\BatchBudgetException;
use App\Exceptions\Autopodbor\RunInFlightException;
use App\Exceptions\Billing\InsufficientBalanceException;
use App\Jobs\Autopodbor\RunAutopodborResolveJob;
use App\Jobs\Autopodbor\RunAutopodborSearchJob;
use App\Jobs\Autopodbor\RunAutopodborStudyJob;
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborRun;
use App\Models\Project;
use App\Models\Tenant;
use App\Repositories\PricingTierRepository;
use App\Support\SystemSettings;
use Illuminate\Support\Str;
final class AutopodborRunService
{
public function __construct(
private AutopodborNormalizer $normalizer = new AutopodborNormalizer,
private AutopodborBudgetGate $budgetGate = new AutopodborBudgetGate,
private AutopodborStudyScheduler $scheduler = new AutopodborStudyScheduler,
private PricingTierRepository $tiers = new PricingTierRepository,
) {}
private function assertNoInFlight(int $tenantId, string $kind): void
@@ -84,19 +78,9 @@ final class AutopodborRunService
{
$comp = AutopodborCompetitor::where('tenant_id', $tenantId)->findOrFail($competitorId);
// Идемпотентность двойного клика: если у конкурента УЖЕ есть study-прогон в
// очереди/в работе — вернуть его, второго не создаём. Жёсткий стоп по всему
// тенанту (assertNoInFlight) снят — распорядитель сам держит одну дорожку и
// разруливает нахлёст между конкурентами/тенантами.
$existing = AutopodborRun::where('tenant_id', $tenantId)
->where('kind', 'study')
->where('competitor_id', $comp->id)
->whereIn('status', ['queued', 'running'])
->first();
if ($existing !== null) {
return $existing;
}
// Повторный сбор источников разрешён (клиент жмёт «Собрать ещё раз»): жёсткого стопа
// «уже изучали» больше нет. От двойного клика/нахлёста защищает assertNoInFlight ниже.
$this->assertNoInFlight($tenantId, 'study');
$this->priceGate($tenantId, 'autopodbor_price_study_rub');
$run = AutopodborRun::create([
@@ -106,90 +90,14 @@ final class AutopodborRunService
// Регион: у авто-конкурента — из его поиска; у ручного (без searchRun) — из прошлого изучения.
'region_code' => $comp->searchRun?->region_code ?? $comp->studyRun?->region_code,
'competitor_id' => $comp->id,
'batch_id' => (string) Str::uuid(),
'params' => [],
]);
// Не диспатчим напрямую — отдаём распорядителю (одна дорожка, round-robin).
$this->scheduler->tick();
RunAutopodborStudyJob::dispatch($run->id, $tenantId);
return $run;
}
/**
* Ставит пакет изучений: N study-прогонов под общим batch_id.
*
* Нормализация: только конкуренты этого тенанта, дубли убираются, конкуренты с
* уже активным (queued/running) study-прогоном отсеиваются. Денежный гейт
* на ВЕСЬ пакет разом (с резервом под активные проекты): не хватает на все N
* BatchBudgetException, ни одного прогона не создаём.
*
* @param array<int, int> $competitorIds
*/
public function startStudyBatch(int $tenantId, array $competitorIds): BatchResult
{
$ids = array_values(array_unique(array_map('intval', $competitorIds)));
$competitors = AutopodborCompetitor::where('tenant_id', $tenantId)
->whereIn('id', $ids)
->get();
// Конкуренты с уже активным study-прогоном — не ставим повторно.
$busyCompetitorIds = AutopodborRun::where('tenant_id', $tenantId)
->where('kind', 'study')
->whereIn('status', ['queued', 'running'])
->whereNotNull('competitor_id')
->pluck('competitor_id')
->all();
$targets = $competitors
->reject(fn (AutopodborCompetitor $c): bool => in_array($c->id, $busyCompetitorIds, true))
->values();
if ($targets->isEmpty()) {
return new BatchResult(null, 0);
}
// Денежный гейт на весь пакет.
$tenant = Tenant::whereKey($tenantId)->firstOrFail();
$price = (string) (SystemSettings::get('autopodbor_price_study_rub') ?? '0');
$committed = (int) Project::where('tenant_id', $tenantId)
->where('is_active', true)
->whereNull('preflight_blocked_at')
->sum('daily_limit_target');
$tiers = $this->tiers->activeAt(now('Europe/Moscow'));
$decision = $this->budgetGate->evaluateBatch(
(string) $tenant->balance_rub,
(int) $tenant->delivered_in_month,
$committed,
$tiers,
$targets->count(),
$price,
);
if (! $decision->allowed) {
throw new BatchBudgetException($decision->topupRub, $decision->maxAffordable);
}
$batchId = (string) Str::uuid();
foreach ($targets as $comp) {
AutopodborRun::create([
'tenant_id' => $tenantId,
'kind' => 'study',
'status' => 'queued',
'region_code' => $comp->searchRun?->region_code ?? $comp->studyRun?->region_code,
'competitor_id' => $comp->id,
'batch_id' => $batchId,
'params' => [],
]);
}
$this->scheduler->tick();
return new BatchResult($batchId, $targets->count());
}
/**
* Ручное изучение: создаём конкурента origin='manual' и сразу ставим study-прогон
* с ЯВНЫМ регионом (у ручного конкурента нет searchRun, откуда взять регион).
@@ -198,6 +106,7 @@ final class AutopodborRunService
*/
public function startManualStudy(int $tenantId, array $competitorData, int $regionCode): AutopodborRun
{
$this->assertNoInFlight($tenantId, 'study');
$this->priceGate($tenantId, 'autopodbor_price_study_rub');
$comp = AutopodborCompetitor::create([
@@ -217,12 +126,10 @@ final class AutopodborRunService
'status' => 'queued',
'region_code' => $regionCode,
'competitor_id' => $comp->id,
'batch_id' => (string) Str::uuid(),
'params' => [],
]);
// Не диспатчим напрямую — отдаём распорядителю.
$this->scheduler->tick();
RunAutopodborStudyJob::dispatch($run->id, $tenantId);
return $run;
}
@@ -1,161 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor;
use App\Jobs\Autopodbor\RunAutopodborStudyJob;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
/**
* Планировщик шага 2 «изучение» одна дорожка на весь портал + честный
* round-robin между тенантами.
*
* Изучение конкурентов тяжёлый сетевой шаг. Чтобы не забивать очередь одним
* тенантом и не гонять два изучения разом, планировщик держит РОВНО ОДНУ активную
* дорожку (не более одного study-прогона в статусе `running` на весь портал) и,
* когда дорожка свободна, отдаёт её «наименее недавно обслуженному» тенанту.
*
* Все чтения идут через соединение `pgsql_supplier` (роль crm_supplier_worker)
* распорядителю нужна межтенантная видимость всей очереди. Межтенантность обеспечивает
* permissive RLS-политика `autopodbor_runs_supplier_read` (FOR SELECT TO crm_supplier_worker
* USING true, миграция 2026_07_08_130000), работающая независимо от того, есть ли у роли
* атрибут BYPASSRLS (на Managed PG кастомным ролям он не гарантирован).
*/
class AutopodborStudyScheduler
{
/**
* Соединение под межтенантную видимость очереди. Видимость по всем тенантам даёт
* permissive-политика autopodbor_runs_supplier_read для роли crm_supplier_worker
* (+ BYPASSRLS, если он выдан).
*/
private const CONNECTION = 'pgsql_supplier';
private const LOCK_KEY = 'autopodbor:study:tick';
/**
* Выбрать id следующего study-прогона для запуска, либо null.
*
* Правила:
* 1. Если хоть один study-прогон уже `running` (по ВСЕМ тенантам) дорожка
* занята, вернуть null.
* 2. Иначе среди тенантов с `queued` study-прогонами выбрать «наименее недавно
* обслуженного»: ключ тенанта = MAX(finished_at) его study-прогонов; NULL
* (никогда не обслуживался) наивысший приоритет; тай-брейк самый старый
* created_at среди его queued-прогонов.
* 3. Внутри выбранного тенанта его самый старый queued-прогон. Вернуть id.
*/
public function pickNextRunId(): ?int
{
$conn = DB::connection(self::CONNECTION);
// (1) одна дорожка: есть running study → занято.
$busy = $conn->table('autopodbor_runs')
->where('kind', 'study')
->where('status', 'running')
->exists();
if ($busy) {
return null;
}
// Все queued study-прогоны (по всем тенантам).
$queued = $conn->table('autopodbor_runs')
->where('kind', 'study')
->where('status', 'queued')
->get(['id', 'tenant_id', 'created_at']);
if ($queued->isEmpty()) {
return null;
}
// last-served по тенанту = MAX(finished_at) среди ВСЕХ его study-прогонов.
// NULL означает «никогда не обслуживался».
$lastServed = $conn->table('autopodbor_runs')
->where('kind', 'study')
->groupBy('tenant_id')
->select('tenant_id', DB::raw('MAX(finished_at) AS last_finished'))
->pluck('last_finished', 'tenant_id');
// (2) выбираем тенанта.
$best = null; // ['tenant' => int, 'last' => ?string, 'oldestCreated' => string]
foreach ($queued->groupBy('tenant_id') as $tenantId => $runs) {
$candidate = [
'tenant' => (int) $tenantId,
'last' => $lastServed[$tenantId] ?? null,
'oldestCreated' => (string) $runs->min('created_at'),
];
if ($best === null || $this->isBetter($candidate, $best)) {
$best = $candidate;
}
}
// (3) внутри выбранного тенанта — самый старый queued.
$winner = $queued
->where('tenant_id', $best['tenant'])
->sortBy('created_at')
->first();
return (int) $winner->id;
}
/**
* Запустить одну итерацию планировщика под Redis-локом: если дорожка свободна и
* есть кандидат задиспатчить его изучение. Лок защищает от гонки нескольких
* тиков (два воркера не отправят два изучения разом).
*/
public function tick(): void
{
$lock = Cache::lock(self::LOCK_KEY, 10);
if (! $lock->get()) {
return;
}
try {
$runId = $this->pickNextRunId();
if ($runId === null) {
return;
}
// Перечитать прогон под тем же межтенантным соединением: статус мог
// измениться между выбором и диспатчем.
$run = DB::connection(self::CONNECTION)
->table('autopodbor_runs')
->where('id', $runId)
->first();
if ($run === null || $run->status !== 'queued') {
return;
}
RunAutopodborStudyJob::dispatch((int) $run->id, (int) $run->tenant_id);
} finally {
$lock->release();
}
}
/**
* $a приоритетнее $b? Приоритет: никогда не обслуженный (last === null) вперёд;
* затем обслуженный раньше вперёд; тай-брейк более старый created_at.
*
* @param array{tenant:int,last:?string,oldestCreated:string} $a
* @param array{tenant:int,last:?string,oldestCreated:string} $b
*/
private function isBetter(array $a, array $b): bool
{
$aNever = $a['last'] === null;
$bNever = $b['last'] === null;
if ($aNever !== $bNever) {
return $aNever; // никогда не обслуженный — выше
}
if (! $aNever && $a['last'] !== $b['last']) {
// оба обслужены: обслуженный раньше — выше
return strcmp((string) $a['last'], (string) $b['last']) < 0;
}
// тай-брейк: самый старый queued created_at — выше
return strcmp($a['oldestCreated'], $b['oldestCreated']) < 0;
}
}
@@ -1,10 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor;
final readonly class BatchResult
{
public function __construct(public ?string $batch_id, public int $queued) {}
}
@@ -1,14 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Autopodbor;
final readonly class BudgetDecision
{
public function __construct(
public bool $allowed,
public int $maxAffordable,
public string $topupRub,
) {}
}
-65
View File
@@ -1,65 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\External;
/**
* Живость почты: TCP/TLS-connect к SMTP-порту Yandex 360 + чтение приветственного
* баннера (должен начинаться с «220»). Без логина/отправки денег/квоты не тратит.
* Соединитель инъектируется (тестируемость): возвращает первую строку баннера или бросает.
*/
class SmtpLivenessProbe implements LivenessProbe
{
/** @var (callable():string)|null */
private $connector;
/** @param (callable():string)|null $connector фейковый соединитель для тестов */
public function __construct(?callable $connector = null)
{
$this->connector = $connector;
}
public function serviceKey(): string
{
return 'email';
}
public function check(): LivenessReading
{
try {
$banner = ($this->connector ?? $this->defaultConnector())();
if (! str_starts_with(ltrim($banner), '220')) {
return LivenessReading::down('email', 'SMTP-баннер не 220: '.mb_substr(trim($banner), 0, 120));
}
return LivenessReading::alive('email', 'SMTP отвечает');
} catch (\Throwable $e) {
return LivenessReading::down('email', $e->getMessage());
}
}
/** @return callable():string */
private function defaultConnector(): callable
{
return function (): string {
$host = (string) config('services.smtp_probe.host');
$port = (int) config('services.smtp_probe.port');
$timeout = (int) config('services.smtp_probe.timeout', 5);
// 465 — implicit TLS; ssl:// нужен на connect.
$scheme = $port === 465 ? 'ssl://' : 'tcp://';
$fp = @stream_socket_client($scheme.$host.':'.$port, $errno, $errstr, $timeout);
if ($fp === false) {
throw new \RuntimeException($errstr !== '' ? $errstr : 'Connection refused (errno '.$errno.')');
}
try {
stream_set_timeout($fp, $timeout);
$line = fgets($fp, 512);
return $line === false ? '' : $line;
} finally {
fclose($fp);
}
};
}
}
@@ -7,11 +7,13 @@ namespace App\Services\Supplier\Channel;
use App\Exceptions\Supplier\SupplierAuthException;
use App\Exceptions\Supplier\SupplierClientException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Mail\SupplierCriticalAlertMail;
use App\Models\Project;
use App\Models\SupplierManualSyncQueue;
use App\Services\Supplier\Channel\Exceptions\TierEscalatedException;
use App\Services\Supplier\Channel\Exceptions\WindowDeferredException;
use App\Services\Supplier\Dto\SupplierProjectDto;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Support\Facades\Log;
use Throwable;
@@ -29,6 +31,7 @@ final class FailoverProjectChannel implements SupplierProjectChannel
public function __construct(
private readonly SupplierProjectChannel $tier1,
private readonly SupplierProjectChannel $tier2,
private readonly Mailer $mailer,
) {}
public function createProject(SupplierProjectDto $dto): int
@@ -78,6 +81,7 @@ final class FailoverProjectChannel implements SupplierProjectChannel
} catch (SupplierClientException|SupplierAuthException $e) {
try {
$id = $this->tier2->createProject($dto);
$this->alertFailoverToForm($project, 'create', $e);
return $id;
} catch (Throwable $tier2Error) {
@@ -104,6 +108,7 @@ final class FailoverProjectChannel implements SupplierProjectChannel
} catch (SupplierClientException|SupplierAuthException $e) {
try {
$this->tier2->updateProject($externalId, $dto);
$this->alertFailoverToForm($project, 'update', $e);
return;
} catch (Throwable $tier2Error) {
@@ -142,9 +147,24 @@ final class FailoverProjectChannel implements SupplierProjectChannel
'created_at' => now(),
]);
$this->mailer->to((string) config('services.supplier.alert_email'))
->queue(new SupplierCriticalAlertMail(
alertType: 'manual_required',
details: "Project #{$project->id} ({$dto->platform}/{$dto->uniqueKey}) — {$operation} queued #{$row->id}, reason: {$reason}. Cause: ".mb_substr($cause->getMessage(), 0, 300),
));
throw new TierEscalatedException($row->id, $reason);
}
private function alertFailoverToForm(Project $project, string $operation, Throwable $cause): void
{
$this->mailer->to((string) config('services.supplier.alert_email'))
->queue(new SupplierCriticalAlertMail(
alertType: 'failover_to_form',
details: "Project #{$project->id} {$operation}: Tier 1 (AJAX) failed, Tier 2 (browser) succeeded. Cause: ".mb_substr($cause->getMessage(), 0, 300),
));
}
/**
* Портальная сверка: ищет уже существующий проект на портале по тройке
* (platform, signal_type, unique_key). Возвращает external_id найденного
@@ -69,81 +69,6 @@ class SupplierPortalClient
return is_array($projects) ? array_values($projects) : [];
}
/** Строк на странице «Мои сделки» (index-visit) — портал отдаёт по 50. */
private const DELIVERED_PAGE_SIZE = 50;
/** Backstop от бесконечной пагинации (аномалия портала). */
private const DELIVERED_MAX_PAGES = 500;
/**
* Журнал ОТДАННОГО («Мои сделки») за окно [from, to] гарантированный канал доставки.
*
* В отличие от «Запрос номеров» (selectType=49), который отдаёт ПУЛ собранных номеров
* (`phones_cnt`, `prophones:'curr'`, `state_id:0`), эта страница содержит только реально
* ОТГРУЖЕННЫЕ поставщиком лиды каждый с настоящим `vid` (= checkbox value / view?id).
* Сверка по `vid` даёт точную личность лида: фантомы из пула невозможны, а совпадение
* количества при подменённых номерах (инцидент roistat✓ 09.07.2026: отдано 15 = сделок 15,
* но все 15 пул) ловится, чего потолок по `crms_cnt` не умеет.
*
* GET /admin/visit/index-visit?visit=rt&date_from&date_to&page=N server-rendered HTML,
* 50 строк/страница, newest-first. Пагинируем до страницы с < 50 строк.
* Verified live 2026-07-09: строка = `name="visit-checbox" value="<vid>"` +
* `B{1,2,3}_<ident>` (проект) + `7\d{10}` (телефон). date_from/date_to формат d.m.Y.
*
* @return array<int, array{vid: int, phone: string, project: string}> ключ = vid (дедуп)
*/
public function fetchDeliveredLeads(CarbonInterface $from, CarbonInterface $to): array
{
$byVid = [];
$page = 1;
do {
$response = $this->request('GET', '/admin/visit/index-visit', [
'visit' => 'rt',
'date_from' => $from->format('d.m.Y'),
'date_to' => $to->format('d.m.Y'),
'page' => $page,
]);
$rows = $this->parseDeliveredRows($response->body());
foreach ($rows as $row) {
$byVid[$row['vid']] = $row;
}
$page++;
} while (count($rows) >= self::DELIVERED_PAGE_SIZE && $page <= self::DELIVERED_MAX_PAGES);
return $byVid;
}
/**
* Парсит строки таблицы «Мои сделки» [vid, phone, project].
* Строки без vid (шапка/служебные) или без телефона пропускаются.
*
* @return list<array{vid: int, phone: string, project: string}>
*/
private function parseDeliveredRows(string $html): array
{
$out = [];
foreach (preg_split('/<tr[ >]/', $html) ?: [] as $row) {
if (preg_match('/name="visit-checbox"\s+value="(\d+)"/', $row, $vid) !== 1) {
continue;
}
if (preg_match('/(7\d{10})/', $row, $phone) !== 1) {
continue;
}
$project = preg_match('#(B[123]_[^\s<"]+)#', $row, $proj) === 1 ? $proj[1] : '';
$out[] = [
'vid' => (int) $vid[1],
'phone' => $phone[1],
'project' => $project,
];
}
return $out;
}
public function saveProject(SupplierProjectDto $dto): int
{
$response = $this->request(
-35
View File
@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support;
/**
* Утилита отображения имён проектов поставщика display-only.
*
* Поставщик префиксует имена проектов кодом канала-провайдера (B1_/B2_/B3_/B6_/B8_/B<N>_).
* Клиенту этот префикс показывать нельзя: он раскрывает нашу внутреннюю схему каналов и то,
* что лиды перекупаются. Срезаем префикс во ВСЕХ клиентских ответах СЕРВЕРНО (API, экспорт),
* а не только на фронте иначе прямой API-потребитель и скачанный CSV/XLSX всё равно видят «B1_…».
*
* Серверный аналог resources/js/composables/projectName.ts::stripChannelPrefix.
* Данные в БД (`supplier_projects.name` / `projects.name`) НЕ трогаем только вывод.
*/
final class SupplierProjectName
{
/** Любой B + одна-или-более цифр + подчёркивание в начале (B1_/B6_/B8_/B10_…), но не буква (BX_). */
private const CHANNEL_PREFIX_RE = '/^B\d+_/i';
/**
* Срезает канальный префикс из начала имени проекта.
* null null (не ломаем nullable-контракт API), '' '', остальное без префикса.
*/
public static function strip(?string $name): ?string
{
if ($name === null || $name === '') {
return $name;
}
return preg_replace(self::CHANNEL_PREFIX_RE, '', $name) ?? $name;
}
}
-6
View File
@@ -146,12 +146,6 @@ return [
'widget_url_template' => env('JIVO_WIDGET_URL_TEMPLATE', 'https://code.jivo.ru/widget/{id}'),
],
// Яндекс.Метрика портала (lk.liderra.ru). Пусто = Метрика не грузится.
// Вебвизор пишет только кабинет клиента (см. resources/js/plugins/metrika.ts + router).
'metrika' => [
'counter_id' => env('METRIKA_COUNTER_ID'),
],
// Платёжный шлюз ЮKassa. webhook_ip_allowlist — CSV IP/CIDR из env (defense-in-depth
// на /api/webhook/payment). Пусто → fail-open (поток не ломается). На проде заполнить
// опубликованными ЮKassa подсетями: 185.71.76.0/27,185.71.77.0/27,77.75.153.0/25,
@@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('autopodbor_runs', function (Blueprint $table) {
$table->uuid('batch_id')->nullable()->after('kind');
$table->index(['tenant_id', 'batch_id'], 'autopodbor_runs_tenant_batch_idx');
$table->index(['status', 'kind'], 'autopodbor_runs_status_kind_idx');
});
}
public function down(): void
{
Schema::table('autopodbor_runs', function (Blueprint $table) {
$table->dropIndex('autopodbor_runs_status_kind_idx');
$table->dropIndex('autopodbor_runs_tenant_batch_idx');
$table->dropColumn('batch_id');
});
}
};
@@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
// Распорядитель шага 2 (AutopodborStudyScheduler) читает очередь по ВСЕМ тенантам
// через соединение pgsql_supplier (роль crm_supplier_worker). Явная permissive
// SELECT-политика гарантирует межтенантную видимость независимо от атрибута
// BYPASSRLS роли (Managed PG может его не выдавать). Изоляция не нарушается:
// это системная queue-роль (как в supplier-flow), пишет по-прежнему под tenant-контекстом.
//
// На dev/тест-БД роль crm_supplier_worker может отсутствовать (её создаёт
// db/00_create_roles.sql, а не миграции) — тогда политику не создаём, чтобы migrate
// не падал.
DB::statement('DROP POLICY IF EXISTS autopodbor_runs_supplier_read ON autopodbor_runs');
$roleExists = (bool) DB::selectOne("SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker'");
if ($roleExists) {
DB::statement('CREATE POLICY autopodbor_runs_supplier_read ON autopodbor_runs FOR SELECT TO crm_supplier_worker USING (true)');
}
}
public function down(): void
{
DB::statement('DROP POLICY IF EXISTS autopodbor_runs_supplier_read ON autopodbor_runs');
}
};
+1 -1
View File
@@ -1317,7 +1317,7 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
identifier: method.notFound
count: 9
count: 5
path: tests/Feature/Console/IncidentsWatchFailuresTest.php
-
-19
View File
@@ -221,25 +221,6 @@ export async function startStudy(competitor_id: number): Promise<RunDto> {
return data.data;
}
/** Итог постановки пакетного сбора источников в очередь (для нескольких конкурентов разом). */
export interface StudyBatchOk {
batch_id: string;
queued: number;
}
/**
* Пакетный сбор источников по нескольким конкурентам разом (шаг 2 для выборки в «Поле»).
* Бэкенд ставит задачи в очередь и отвечает 202 `{ batch_id, queued }`. При нехватке баланса —
* 422 `{ message, topup_rub, max_affordable }` (компонент показывает яркий алерт баланса).
* CSRF — как у соседних POST: `apiClient` уже шлёт X-XSRF-TOKEN автоматически (withXSRFToken).
*/
export async function startStudyBatch(competitorIds: number[]): Promise<StudyBatchOk> {
const { data } = await apiClient.post<StudyBatchOk>('/api/autopodbor/study/batch', {
competitor_ids: competitorIds,
});
return data;
}
export async function startResolve(p: { name: string; region_code: number }): Promise<RunDto> {
const { data } = await apiClient.post<{ data: RunDto }>('/api/autopodbor/resolve', p);
return data.data;
@@ -70,24 +70,4 @@ onMounted(() => {
color: #0f6e56;
white-space: nowrap;
}
/* Телефон: три колонки не помещаются — название крошилось по слову.
Раскладываем: название на всю ширину, ниже строкой «когда списываем» + цена. */
@media (max-width: 599.98px) {
.ap-row {
flex-wrap: wrap;
gap: 4px 12px;
}
.ap-row__name {
flex: 1 1 100%;
}
.ap-row__when {
min-width: 0;
text-align: left;
flex: 0 1 auto;
}
.ap-row__price {
margin-left: auto;
}
}
</style>
@@ -137,11 +137,6 @@ const runwaySub = computed(() =>
.chain-cards {
flex-direction: column;
}
/* В столбце плитки — по высоте контента (иначе flex-basis:0 схлопывал их,
а overflow:hidden обрезал цифры/кнопки — «Кошелёк/Лиды/Запас» пустели). */
.chain-cards > .wallet-card {
flex: 0 0 auto;
}
.chain-arrow {
transform: rotate(90deg);
padding: 8px 0;
@@ -167,34 +167,4 @@ defineExpose({ load, invoices });
gap: 4px;
justify-content: flex-end;
}
/* Телефон: 4 колонки (дата | номер | сумма | кнопки) не влезают — при кнопках
«Счёт»+«Акт» номер крошился, а кнопки уезжали за край. Раскладываем в ярусы:
номер+сумма, дата, кнопки во всю ширину. */
@media (max-width: 599.98px) {
.inv-row {
grid-template-columns: 1fr auto;
grid-template-areas:
'name amount'
'when when'
'actions actions';
row-gap: 6px;
}
.inv-when {
grid-area: when;
}
.inv-name {
grid-area: name;
}
.inv-amount {
grid-area: amount;
text-align: right;
align-self: start;
}
.inv-actions {
grid-area: actions;
justify-content: flex-start;
flex-wrap: wrap;
}
}
</style>
@@ -6,8 +6,6 @@
*
* Billing v2 Spec A §3.4.8.
*/
import { tierRangeText } from '../../utils/pricingTiers';
interface TierPreview {
tier_no: number;
leads_in_tier: number | null;
@@ -20,7 +18,14 @@ const props = defineProps<{
}>();
function rangeText(idx: number): string {
return tierRangeText(props.tiers, idx);
let start = 1;
for (let i = 0; i < idx; i++) {
const cap = props.tiers[i].leads_in_tier;
if (cap !== null) start += cap;
}
const cap = props.tiers[idx].leads_in_tier;
if (cap === null) return `${start}+`;
return `${start}${start + cap - 1}`;
}
</script>
@@ -12,10 +12,6 @@ import { ref, computed, watch } from 'vue';
import { topup, createInvoice } from '../../api/billing';
import { extractErrorMessage, extractValidationErrors } from '../../api/client';
import { redirectTo } from '../../utils/redirect';
import { useLayoutTier } from '../../composables/useLayoutTier';
// Телефон: диалог на весь экран (плитки сумм/способы оплаты — шире узкого модала).
const { isPhone } = useLayoutTier();
const model = defineModel<boolean>({ required: true });
const emit = defineEmits<{ success: [balanceRub: string]; invoiced: [invoiceNumber: string] }>();
@@ -99,7 +95,7 @@ defineExpose({ method, amount, submit, canSubmit, errorMsg });
</script>
<template>
<v-dialog v-model="model" max-width="460" :fullscreen="isPhone" scrollable>
<v-dialog v-model="model" max-width="460">
<v-card>
<v-card-title class="text-h6">Пополнить баланс</v-card-title>
<v-card-text>
@@ -4,10 +4,9 @@
* (Все / Пополнения / Списания / Возвраты). Данные — GET
* /api/billing/transactions (E3). Паттерн self-fetching из ChargesTab.
*/
import { ref, computed, onMounted } from 'vue';
import { ref, onMounted } from 'vue';
import { getTransactions, type BillingTransaction } from '../../api/billing';
import { formatCost, txAmountClass } from '../../composables/billingFormatters';
import ResponsiveTable from '../common/ResponsiveTable.vue';
interface Tab {
id: string;
@@ -44,7 +43,6 @@ const OP_LABELS: Record<string, string> = {
lead_charge: 'Списание за лид',
topup: 'Пополнение баланса',
migration: 'Конвертация лидов в ₽',
autopodbor_charge: 'Списание за автоподбор',
};
function opLabel(tx: BillingTransaction): string {
@@ -102,15 +100,6 @@ async function loadOptions(opts: { page: number }): Promise<void> {
await load();
}
// Пейджер для карточек (телефон): страниц по 20, как у таблицы.
const PER_PAGE = 20;
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PER_PAGE)));
async function goToPage(p: number): Promise<void> {
page.value = p;
await load();
}
async function refresh(): Promise<void> {
page.value = 1;
await load();
@@ -136,65 +125,30 @@ defineExpose({ load, refresh, changeTab, activeTab, total, rows });
{{ loadError }}
</v-alert>
<ResponsiveTable :items="rows">
<template #table>
<v-data-table-server
:headers="headers"
:items="rows"
:items-length="total"
:loading="loading"
:items-per-page="20"
density="comfortable"
@update:options="loadOptions"
>
<template #[`item.created_at`]="{ item }">
<span class="tx-when num">{{ formatWhen(item.created_at) }}</span>
</template>
<template #[`item.description`]="{ item }">
<span class="tx-op">{{ opLabel(item) }}</span>
</template>
<template #[`item.code`]="{ item }">
<span class="tx-id">#{{ item.code }}</span>
</template>
<template #[`item.amount_rub`]="{ item }">
<span class="num" :class="txAmountClass(txAmountValue(item))">
{{ txAmountText(item) }}
</span>
</template>
</v-data-table-server>
<v-data-table-server
:headers="headers"
:items="rows"
:items-length="total"
:loading="loading"
:items-per-page="20"
density="comfortable"
@update:options="loadOptions"
>
<template #[`item.created_at`]="{ item }">
<span class="tx-when num">{{ formatWhen(item.created_at) }}</span>
</template>
<template #card>
<div class="tx-cards">
<article v-for="tx in rows" :key="tx.code" class="tx-card">
<div class="tx-card__top">
<span class="tx-card__op">{{ opLabel(tx) }}</span>
<span class="num tx-card__amount" :class="txAmountClass(txAmountValue(tx))">{{
txAmountText(tx)
}}</span>
</div>
<div class="tx-card__meta">
<span class="tx-when num">{{ formatWhen(tx.created_at) }}</span>
<span class="tx-id">#{{ tx.code }}</span>
</div>
</article>
<div v-if="rows.length === 0 && !loading" class="tx-empty text-medium-emphasis">
Нет транзакций
</div>
<v-pagination
v-if="pageCount > 1"
:model-value="page"
:length="pageCount"
:total-visible="5"
density="comfortable"
class="tx-pager"
@update:model-value="goToPage"
/>
</div>
<template #[`item.description`]="{ item }">
<span class="tx-op">{{ opLabel(item) }}</span>
</template>
</ResponsiveTable>
<template #[`item.code`]="{ item }">
<span class="tx-id">#{{ item.code }}</span>
</template>
<template #[`item.amount_rub`]="{ item }">
<span class="num" :class="txAmountClass(txAmountValue(item))">
{{ txAmountText(item) }}
</span>
</template>
</v-data-table-server>
</v-card>
</template>
@@ -238,48 +192,4 @@ defineExpose({ load, refresh, changeTab, activeTab, total, rows });
.tx-amount-neutral {
color: #66635c;
}
/* Карточки транзакций (телефон) */
.tx-cards {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 16px 16px;
}
.tx-card {
border: 1px solid rgba(1, 32, 25, 0.1);
border-radius: 12px;
padding: 12px 14px;
background: #fff;
}
.tx-card__top {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.tx-card__op {
font-weight: 500;
color: #081319;
font-size: 14px;
min-width: 0;
}
.tx-card__amount {
font-weight: 700;
white-space: nowrap;
}
.tx-card__meta {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
margin-top: 6px;
}
.tx-empty {
text-align: center;
padding: 24px 0;
}
.tx-pager {
margin-top: 4px;
}
</style>
@@ -1,24 +0,0 @@
<script setup lang="ts">
/**
* Обёртка: десктоп (>=1280) — слот #table (обычный v-data-table),
* телефон и планшет (<1280) — слот #card (список карточек по каждому item).
* Широкие таблицы (Сделки/Списания) не влезают в узкую колонку планшета-портрета
* рядом с rail-меню — правые колонки обрезались, поэтому на планшете тоже карточки.
* Данные прокидываются через слот-пропсы, чтобы вызывающий сам рисовал карточку.
*/
import { useLayoutTier } from '../../composables/useLayoutTier';
defineProps<{ items: unknown[] }>();
const { isDesktop } = useLayoutTier();
</script>
<template>
<div>
<template v-if="isDesktop">
<slot name="table" :items="items" />
</template>
<template v-else>
<slot name="card" :items="items" />
</template>
</div>
</template>
@@ -109,16 +109,4 @@ const avgCostLabel = computed(() => {
font-feature-settings: 'tnum';
font-weight: 500;
}
/* Телефон: переключатель периода — на всю ширину, кнопки поровну (без внутренней прокрутки). */
@media (max-width: 599.98px) {
.page-head :deep(.v-btn-toggle) {
width: 100%;
}
.page-head :deep(.v-btn-toggle) > .v-btn {
flex: 1 1 0;
min-width: 0;
padding-inline: 6px;
}
}
</style>
@@ -6,14 +6,9 @@
* Тело — общий DealDetailBody.vue.
*/
import { computed } from 'vue';
import { useDisplay } from 'vuetify';
import type { MockDeal } from '../../composables/mockDeals';
import DealDetailBody from './DealDetailBody.vue';
// Телефон: overlay-панель на весь экран (фикс. 480px был шире экрана).
const { width, xs } = useDisplay();
const drawerWidth = computed(() => (xs.value ? width.value : 480));
const props = withDefaults(
defineProps<{
open: boolean;
@@ -48,7 +43,7 @@ function close() {
@status-changed="(s: string) => emit('status-changed', s)"
/>
</aside>
<v-navigation-drawer v-else v-model="drawerOpen" location="right" temporary :width="drawerWidth" class="deal-drawer">
<v-navigation-drawer v-else v-model="drawerOpen" location="right" temporary :width="480" class="deal-drawer">
<DealDetailBody
:deal="deal"
:tenant-id="tenantId"
@@ -74,18 +69,4 @@ function close() {
position: sticky;
top: 16px;
}
/* Телефон: панель деталей — полноэкранный оверлей поверх списка
(фикс. 400px рядом со списком давал горизонтальный скролл и крошил карточки). */
@media (max-width: 599.98px) {
.deal-detail-inline {
position: fixed;
inset: 0;
z-index: 1200;
width: 100%;
max-height: none;
border: none;
border-radius: 0;
overflow-y: auto;
}
}
</style>
@@ -35,11 +35,11 @@ function formatRelative(minutes: number): string {
<header class="hero pa-5">
<div class="hero-eyebrow text-caption text-medium-emphasis">Сделка #{{ deal.id }}</div>
<div class="hero-row mt-1">
<h2 class="hero-name text-h5 ym-hide-content">{{ deal.name }}</h2>
<h2 class="hero-name text-h5">{{ deal.name }}</h2>
<v-btn icon="mdi-close" variant="text" size="small" aria-label="Закрыть панель" @click="$emit('close')" />
</div>
<div class="hero-meta mt-2">
<a :href="`tel:${deal.phone.replace(/[^+\d]/g, '')}`" class="phone-link ym-hide-content">{{ deal.phone }}</a>
<a :href="`tel:${deal.phone.replace(/[^+\d]/g, '')}`" class="phone-link">{{ deal.phone }}</a>
<span class="sep">·</span>
<span class="text-caption text-medium-emphasis">
<v-icon size="14" class="mr-1">mdi-clock-outline</v-icon>
@@ -37,7 +37,7 @@ const hasActiveFilter = () => props.filterStatus !== null || props.filterProject
density="compact"
hide-details
clearable
class="filters-search ym-disable-keys"
class="filters-search"
data-testid="filter-search-phone"
@update:model-value="(v: string) => $emit('update:searchPhone', v ?? '')"
/>
@@ -8,7 +8,6 @@ import type { MockDeal } from '../../composables/mockDeals';
import type { LeadStatus } from '../../composables/leadStatuses';
import { stripChannelPrefix } from '../../composables/projectName';
import StatusPill from '../ui/StatusPill.vue';
import ResponsiveTable from '../common/ResponsiveTable.vue';
const props = withDefaults(
defineProps<{
@@ -37,12 +36,6 @@ function sourceSignalLabel(deal: MockDeal): string {
return t ? (SIGNAL_LABELS[t] ?? '') : '';
}
// Есть ли у сделки источник автоподбора (для карточки телефона: показывать
// отдельную строку «Источник» только когда есть что показать).
function hasSource(deal: MockDeal): boolean {
return Boolean(deal.sourceIdentifier || deal.sourceProjectId);
}
// «Конкурент» — только бренд, без описания: имя конкурента хранится как
// «Бренд, описание…», в колонке показываем часть до первой запятой.
function competitorShort(name: string | null | undefined): string {
@@ -66,22 +59,10 @@ function formatDateTime(iso: string | null | undefined): string {
function rowProps(deal: MockDeal): Record<string, unknown> {
return { class: deal.id === props.activeDealId ? 'deals-row-active' : '' };
}
// Выбор лида на карточке (телефон) — тот же контракт, что чекбоксы таблицы.
function isSelected(id: number): boolean {
return props.selectedIds.includes(id);
}
function toggleSelect(id: number): void {
const next = isSelected(id) ? props.selectedIds.filter((x) => x !== id) : [...props.selectedIds, id];
emit('update:selectedIds', next);
}
</script>
<template>
<v-card variant="outlined" class="deals-table-card">
<ResponsiveTable :items="deals">
<template #table>
<v-data-table
:model-value="selectedIds"
:items="deals"
@@ -104,15 +85,15 @@ function toggleSelect(id: number): void {
@click:row="(_e: Event, { item }: { item: MockDeal }) => emit('row-click', item)"
>
<template #[`item.phone`]="{ item }: { item: MockDeal }">
<span class="num ld-mono ym-hide-content">{{ item.phone }}</span>
<span class="num ld-mono">{{ item.phone }}</span>
</template>
<template #[`item.competitor`]="{ item }: { item: MockDeal }">
<router-link
v-if="item.competitorId"
class="cell-link"
:title="item.competitorName ?? undefined"
:to="{ name: 'autopodbor', query: { competitor: String(item.competitorId) } }"
:title="item.competitorName ?? undefined"
@click.stop
>{{ competitorShort(item.competitorName) }}</router-link
>
@@ -149,11 +130,9 @@ function toggleSelect(id: number): void {
</template>
<template #[`item.comment`]="{ item }: { item: MockDeal }">
<span
class="cell-comment ym-hide-content"
:class="{ 'text-medium-emphasis': !item.comment }"
>{{ item.comment || '—' }}</span
>
<span class="cell-comment" :class="{ 'text-medium-emphasis': !item.comment }">{{
item.comment || '—'
}}</span>
</template>
<template #[`item.receivedAt`]="{ item }: { item: MockDeal }">
@@ -172,81 +151,11 @@ function toggleSelect(id: number): void {
<template #[`item.data-table-select`]="{ isSelected, toggleSelect, internalItem, item }">
<v-checkbox-btn
:model-value="isSelected(internalItem)"
:aria-label="`Выбрать сделку #${(item as MockDeal).id}`"
:aria-label="`Выбрать сделку «${(item as MockDeal).phone}»`"
@update:model-value="() => toggleSelect(internalItem)"
/>
</template>
</v-data-table>
</template>
<template #card>
<div class="deals-cards">
<article
v-for="deal in deals"
:key="deal.id"
class="deal-card"
:class="{ 'deal-card--active': deal.id === activeDealId }"
@click="emit('row-click', deal)"
>
<div class="deal-card__top">
<v-checkbox-btn
:model-value="isSelected(deal.id)"
density="compact"
hide-details
class="deal-card__check"
:aria-label="`Выбрать сделку #${deal.id}`"
@click.stop
@update:model-value="() => toggleSelect(deal.id)"
/>
<span class="deal-card__phone num ld-mono ym-hide-content">{{ deal.phone }}</span>
<StatusPill
:slug="deal.statusSlug"
:label="statusBySlug.get(deal.statusSlug)?.nameRu ?? deal.statusSlug"
/>
</div>
<div class="deal-card__meta">
<router-link
v-if="deal.competitorId"
class="deal-card__competitor"
:title="deal.competitorName ?? undefined"
:to="{ name: 'autopodbor', query: { competitor: String(deal.competitorId) } }"
@click.stop
>{{ competitorShort(deal.competitorName) }}</router-link
>
<span v-else class="deal-card__project">{{ stripChannelPrefix(deal.project) }}</span>
<span v-if="!hasSource(deal) && signalLabel(deal.signalType)" class="deal-card__signal">{{
signalLabel(deal.signalType)
}}</span>
<span class="deal-card__dot">·</span>
<span :class="{ 'text-medium-emphasis': !deal.city }">{{ deal.city || '—' }}</span>
</div>
<div v-if="hasSource(deal)" class="deal-card__source-row">
<router-link
v-if="deal.sourceProjectId"
class="deal-card__source-link"
:to="{
name: 'autopodbor',
query: {
competitor: deal.competitorId ? String(deal.competitorId) : undefined,
project: String(deal.sourceProjectId),
},
}"
@click.stop
>{{ deal.sourceIdentifier
}}<span v-if="deal.sourceExtraCount"> · +{{ deal.sourceExtraCount }}</span></router-link
>
<span v-else class="deal-card__source-plain">{{ deal.sourceIdentifier }}</span>
<template v-if="sourceSignalLabel(deal)">
<span class="deal-card__dot">·</span>
<span class="deal-card__signal">{{ sourceSignalLabel(deal) }}</span>
</template>
</div>
<div v-if="deal.comment" class="deal-card__comment ym-hide-content">{{ deal.comment }}</div>
<div class="deal-card__foot num ld-mono-s">{{ formatDateTime(deal.receivedAt) }}</div>
</article>
</div>
</template>
</ResponsiveTable>
<div v-if="deals.length === 0" class="empty-state pa-8 text-center text-medium-emphasis">
Нет сделок по выбранным фильтрам
@@ -295,100 +204,4 @@ function toggleSelect(id: number): void {
:deep(.deals-row-active) {
background: rgba(15, 110, 86, 0.07);
}
/* Карточки лидов (телефон) */
.deals-cards {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
}
.deal-card {
border: 1px solid rgba(1, 32, 25, 0.1);
border-radius: 14px;
padding: 14px 16px;
background: #fff;
cursor: pointer;
transition: border-color 150ms ease;
}
.deal-card:active {
border-color: rgba(15, 110, 86, 0.5);
}
.deal-card--active {
border-color: var(--liderra-teal);
box-shadow: 0 0 0 1px var(--liderra-teal);
}
.deal-card__top {
display: flex;
align-items: center;
gap: 8px;
}
.deal-card__check {
flex: 0 0 auto;
margin-left: -6px;
}
.deal-card__phone {
font-weight: 700;
flex: 1 1 auto;
min-width: 0;
}
.deal-card__meta {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 6px;
color: rgba(1, 32, 25, 0.6);
font-size: 13px;
margin-top: 8px;
}
.deal-card__project {
font-weight: 500;
color: #081319;
}
/* Конкурент — ссылка в автоподбор (как в колонке «Конкурент» на десктопе). */
.deal-card__competitor {
font-weight: 600;
color: rgb(var(--v-theme-primary));
text-decoration: none;
}
.deal-card__competitor:active {
text-decoration: underline;
}
.deal-card__signal {
font-size: 11px;
color: #6b6356;
}
/* Источник — отдельная строка под именем: домен/идентификатор + канал. */
.deal-card__source-row {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
font-size: 12px;
}
.deal-card__source-link {
color: rgb(var(--v-theme-primary));
text-decoration: none;
font-weight: 500;
}
.deal-card__source-link:active {
text-decoration: underline;
}
.deal-card__source-plain {
color: rgba(1, 32, 25, 0.7);
}
.deal-card__dot {
color: rgba(1, 32, 25, 0.3);
}
.deal-card__comment {
font-size: 13px;
margin-top: 6px;
color: rgba(1, 32, 25, 0.75);
}
.deal-card__foot {
font-size: 12px;
color: rgba(1, 32, 25, 0.5);
margin-top: 10px;
}
</style>
@@ -15,10 +15,6 @@ import { extractErrorMessage } from '../../api/client';
import { ref, watch } from 'vue';
import { LEAD_STATUSES } from '../../composables/leadStatuses';
import { type MockDeal, type MockManager } from '../../composables/mockDeals';
import { useLayoutTier } from '../../composables/useLayoutTier';
// Телефон: диалог на весь экран (узкий модал резал поля).
const { isPhone } = useLayoutTier();
/**
* Списки проектов и менеджеров грузятся с backend через GET /api/projects,
@@ -188,7 +184,7 @@ function close() {
</script>
<template>
<v-dialog v-model="dialogOpen" :max-width="560" :fullscreen="isPhone" scrollable data-testid="new-deal-dialog">
<v-dialog v-model="dialogOpen" :max-width="560" data-testid="new-deal-dialog">
<v-card>
<v-card-title>Новая сделка</v-card-title>
<v-card-text>
@@ -220,7 +216,6 @@ function close() {
variant="outlined"
density="comfortable"
autofocus
class="ym-disable-keys"
:error-messages="errors.name ? [errors.name] : []"
data-testid="field-name"
/>
@@ -233,7 +228,6 @@ function close() {
variant="outlined"
density="comfortable"
inputmode="tel"
class="ym-disable-keys"
:error-messages="errors.phone ? [errors.phone] : []"
data-testid="field-phone"
/>
@@ -7,10 +7,6 @@
*/
import { computed, reactive, ref } from 'vue';
import { resolveUnknownStatuses, type StatusMapping, type UnknownStatus } from '../../api/imports';
import { useLayoutTier } from '../../composables/useLayoutTier';
// Телефон: на весь экран (таблица сопоставления статусов широкая).
const { isPhone } = useLayoutTier();
const props = defineProps<{
modelValue: boolean;
@@ -66,7 +62,7 @@ defineExpose({ selection, save });
</script>
<template>
<v-dialog v-model="dialogOpen" max-width="640" :fullscreen="isPhone" scrollable>
<v-dialog v-model="dialogOpen" max-width="640">
<v-card>
<v-card-title class="text-h6">Маппинг неизвестных статусов</v-card-title>
<v-card-text>
@@ -25,8 +25,8 @@ function formatCost(cost: number): string {
density="compact"
@click="emit('open', deal.id)"
>
<div class="card-name ym-hide-content">{{ deal.name }}</div>
<div class="card-phone text-caption text-medium-emphasis ym-hide-content">{{ deal.phone }}</div>
<div class="card-name">{{ deal.name }}</div>
<div class="card-phone text-caption text-medium-emphasis">{{ deal.phone }}</div>
<div class="card-meta mt-2">
<span class="card-project text-caption">{{ stripChannelPrefix(deal.project) }}</span>
<span class="card-cost num">{{ deal.costKopecks != null ? formatCost(deal.costKopecks / 100) : '—' }}</span>
@@ -1,59 +0,0 @@
<script setup lang="ts">
/** Нижняя панель-вкладки для телефона. 5 главных разделов; остальное — в «Ещё». */
import { RouterLink, useRoute } from 'vue-router';
import { computed } from 'vue';
interface Tab { title: string; to: string; }
const tabs: Tab[] = [
{ title: 'Поле', to: '/autopodbor' },
{ title: 'Проекты', to: '/projects' },
{ title: 'Сделки', to: '/deals' },
{ title: 'Биллинг', to: '/billing' },
{ title: 'Канбан', to: '/kanban' },
];
const route = useRoute();
const activeTo = computed(() => tabs.find((t) => route.path === t.to)?.to ?? '');
</script>
<template>
<nav class="ld-bottomnav" aria-label="Основная навигация">
<RouterLink
v-for="t in tabs"
:key="t.to"
:to="t.to"
data-testid="bottomnav-item"
class="ld-bottomnav__item"
:class="{ 'ld-bottomnav__item--active': activeTo === t.to }"
>
<span class="ld-bottomnav__label">{{ t.title }}</span>
</RouterLink>
</nav>
</template>
<style scoped>
.ld-bottomnav {
position: fixed;
left: 0; right: 0; bottom: 0;
z-index: 1010;
display: flex;
height: 64px;
padding-bottom: env(safe-area-inset-bottom);
background: #ffffff;
border-top: 1px solid rgba(1, 32, 25, 0.1);
}
.ld-bottomnav__item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
min-height: 56px;
text-decoration: none;
color: rgba(1, 32, 25, 0.55);
font-size: 11px;
font-weight: 600;
}
.ld-bottomnav__item--active { color: var(--liderra-teal); }
.ld-bottomnav__label { line-height: 1; }
</style>
@@ -1,35 +0,0 @@
<script setup lang="ts">
/**
* Выезжающее «Ещё» для телефона: разделы вне 5 нижних вкладок.
* Ни один экран не выкидываем — Настройки (реквизиты), Отчёты, Импорт, Помощь, Дашборд
* доступны отсюда. Полнота функций — на десктопе; на телефоне вкладка работает, но урезана.
*/
const open = defineModel<boolean>('open', { default: false });
interface Item {
title: string;
to: string;
}
const items: Item[] = [
{ title: 'Дашборд', to: '/dashboard' },
{ title: 'Отчёты', to: '/reports' },
{ title: 'Настройки', to: '/settings' },
{ title: 'Импорт данных', to: '/import' },
{ title: 'Помощь', to: '/help' },
];
</script>
<template>
<v-navigation-drawer v-model="open" temporary location="right" width="280">
<v-list nav>
<v-list-item
v-for="i in items"
:key="i.to"
:to="i.to"
:title="i.title"
data-testid="more-item"
@click="open = false"
/>
</v-list>
</v-navigation-drawer>
</template>
@@ -30,9 +30,6 @@ interface NavGroup {
const drawerOpen = defineModel<boolean>('drawerOpen', { default: true });
// Ярус экрана из AppLayout: на телефоне сайдбар скрыт (навигацию берёт нижняя панель-вкладки).
defineProps<{ tier?: 'phone' | 'tablet' | 'desktop' }>();
const route = useRoute();
const auth = useAuthStore();
const dealsCount = useDealsCountStore();
@@ -84,7 +81,7 @@ defineExpose({ navGroups });
</script>
<template>
<aside class="ld-sidebar" :data-open="drawerOpen" :data-tier="tier">
<aside class="ld-sidebar" :data-open="drawerOpen">
<div class="ld-sidebar__brand">
<span class="ld-sidebar__brand-name">Лидерра<span class="ld-sidebar__brand-dot">.</span></span>
</div>
@@ -113,7 +110,6 @@ defineExpose({ navGroups });
:class="{ 'ld-nav-item--active': route.path === item.to }"
:data-tour="`nav-${item.to.replace('/', '')}`"
>
<span class="ld-nav-item__icon"><v-icon :icon="item.icon" size="20" /></span>
<span class="ld-nav-item__title">{{ item.title }}</span>
<span v-if="item.badge" class="ld-nav-item__new">{{ item.badge }}</span>
<span
@@ -144,44 +140,6 @@ defineExpose({ navGroups });
overflow-y: auto;
}
/* Телефон: сайдбара нет — навигацию берёт нижняя панель-вкладки (AppBottomNav). */
.ld-sidebar[data-tier='phone'] {
display: none;
}
/* Иконка пункта — по умолчанию скрыта (десктоп остаётся текстовым, как был). */
.ld-nav-item__icon {
display: none;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
/* Планшет: узкая колонка-иконки (rail). Прячем подписи/поиск/бренд-текст, показываем иконки. */
.ld-sidebar[data-tier='tablet'] {
width: 64px;
padding: 20px 8px;
}
.ld-sidebar[data-tier='tablet'] .ld-sidebar__brand-name,
.ld-sidebar[data-tier='tablet'] .ld-cmdk-stub,
.ld-sidebar[data-tier='tablet'] .ld-nav-group__eyebrow,
.ld-sidebar[data-tier='tablet'] .ld-nav-item__title,
.ld-sidebar[data-tier='tablet'] .ld-nav-item__badge,
.ld-sidebar[data-tier='tablet'] .ld-nav-item__new {
display: none;
}
.ld-sidebar[data-tier='tablet'] .ld-nav-item__icon {
display: inline-flex;
}
.ld-sidebar[data-tier='tablet'] .ld-nav-item {
justify-content: center;
padding: 10px 0;
gap: 0;
}
.ld-sidebar[data-tier='tablet'] .ld-sidebar__brand {
height: 22px;
}
.ld-sidebar__brand {
font-size: 15px;
font-weight: 600;
@@ -89,7 +89,7 @@ async function handleLogout(): Promise<void> {
<template>
<v-app-bar :elevation="0" color="surface" class="app-topbar" :height="56">
<v-app-bar-nav-icon class="d-sm-none" aria-label="Открыть меню навигации" @click="emit('toggle-drawer')" />
<v-app-bar-nav-icon class="d-md-none" aria-label="Открыть меню навигации" @click="emit('toggle-drawer')" />
<div class="crumb">
<strong>{{ pageTitle }}</strong>
@@ -203,21 +203,9 @@ async function handleLogout(): Promise<void> {
color: #e8e2d4 !important;
}
.app-topbar :deep(.v-toolbar__content) {
padding-left: 240px; /* десктоп — clear под сайдбар 232px */
padding-left: 240px;
color: #e8e2d4;
}
/* Планшет — clear под rail 64px */
@media (max-width: 1279.98px) {
.app-topbar :deep(.v-toolbar__content) {
padding-left: 72px;
}
}
/* Телефон — гамбургер слева, без отступа под сайдбар */
@media (max-width: 599.98px) {
.app-topbar :deep(.v-toolbar__content) {
padding-left: 4px;
}
}
.app-topbar :deep(.v-icon) {
color: #b8b0a0;
}
@@ -238,12 +226,6 @@ async function handleLogout(): Promise<void> {
color: #b8b0a0 !important;
border-color: rgba(255, 255, 255, 0.12) !important;
}
/* Телефон: длинный заголовок вплотную к «Поиску» — небольшой зазор слева. */
@media (max-width: 599.98px) {
.searchbar {
margin-left: 10px;
}
}
.search-kbd {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 10px;
@@ -1,11 +1,11 @@
<template>
<v-dialog v-model="open" max-width="560" :fullscreen="isPhone" scrollable>
<v-dialog v-model="open" max-width="560">
<v-card>
<v-card-title>Дни сбора лидов для {{ count }} проектов</v-card-title>
<v-card-text>
<div class="mb-4">
<div class="text-caption text-success font-weight-medium mb-2"> Добавить дни</div>
<div class="d-flex ga-2 day-row">
<div class="d-flex ga-2">
<v-btn
v-for="d in WEEKDAYS"
:key="`add-${d.bit}`"
@@ -20,7 +20,7 @@
</div>
<div>
<div class="text-caption text-error font-weight-medium mb-2"> Убрать дни</div>
<div class="d-flex ga-2 day-row">
<div class="d-flex ga-2">
<v-btn
v-for="d in WEEKDAYS"
:key="`remove-${d.bit}`"
@@ -48,10 +48,6 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { WEEKDAYS } from '../../constants/weekdays';
import { useLayoutTier } from '../../composables/useLayoutTier';
// Телефон: на весь экран (ряд кнопок дней недели не влезал в узкий модал).
const { isPhone } = useLayoutTier();
const props = defineProps<{ modelValue: boolean; count: number }>();
const emit = defineEmits<{
@@ -95,18 +91,3 @@ function apply() {
open.value = false;
}
</script>
<style scoped>
/* Телефон: 7 кнопок дней (ПН…ВС) в d-flex не влезали — СБ/ВС уезжали за край.
Делим ряд поровну между кнопками, чтобы все семь помещались в ширину. */
@media (max-width: 599.98px) {
.day-row {
width: 100%;
}
.day-row > .v-btn {
flex: 1 1 0;
min-width: 0;
padding-inline: 0;
}
}
</style>
@@ -1,5 +1,5 @@
<template>
<v-dialog v-model="open" max-width="480" :fullscreen="isPhone" scrollable>
<v-dialog v-model="open" max-width="480">
<v-card>
<v-card-title>Лимит лидов для {{ count }} проектов</v-card-title>
<v-card-text>
@@ -57,10 +57,6 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { useLayoutTier } from '../../composables/useLayoutTier';
// Телефон: на весь экран (единообразно с «Дни/Регионы оптом»).
const { isPhone } = useLayoutTier();
const props = defineProps<{ modelValue: boolean; count: number }>();
const emit = defineEmits<{
@@ -1,5 +1,5 @@
<template>
<v-dialog v-model="open" max-width="560" :fullscreen="isPhone" scrollable>
<v-dialog v-model="open" max-width="560">
<v-card>
<v-card-title>Регионы для {{ count }} проектов</v-card-title>
<v-card-text>
@@ -76,10 +76,6 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { REGIONS_ALPHA, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
import { useLayoutTier } from '../../composables/useLayoutTier';
// Телефон: на весь экран (сетка регионов шире узкого модала).
const { isPhone } = useLayoutTier();
const props = defineProps<{ modelValue: boolean; count: number }>();
const emit = defineEmits<{
@@ -1,31 +0,0 @@
import { computed, type ComputedRef } from 'vue';
import { useDisplay } from 'vuetify';
export type LayoutTier = 'phone' | 'tablet' | 'desktop';
/**
* Единый источник «яруса» экрана для адаптива портала.
* phone < 600px (нижнее меню-вкладки)
* tablet 600..1279px (узкая колонка-иконки / rail)
* desktop >= 1280px (полный сайдбар 232px, как сейчас)
*/
export function useLayoutTier(): {
tier: ComputedRef<LayoutTier>;
isPhone: ComputedRef<boolean>;
isTablet: ComputedRef<boolean>;
isDesktop: ComputedRef<boolean>;
} {
const display = useDisplay();
const tier = computed<LayoutTier>(() => {
const w = display.width.value;
if (w < 600) return 'phone';
if (w < 1280) return 'tablet';
return 'desktop';
});
return {
tier,
isPhone: computed(() => tier.value === 'phone'),
isTablet: computed(() => tier.value === 'tablet'),
isDesktop: computed(() => tier.value === 'desktop'),
};
}
-1
View File
@@ -104,7 +104,6 @@ export const LEGAL_DOCS: Record<'offer' | 'privacy' | 'refund', LegalDoc> = {
heading: '5. Хранение и защита',
paragraphs: [
'Персональные данные хранятся на территории Российской Федерации. Применяются разграничение доступа, шифрование, маскирование данных в выгрузках и журналирование действий.',
'Для улучшения сервиса используется Яндекс.Метрика, включая запись пользовательских сессий (Вебвизор). Персональные данные контактов (телефоны, имена) в таких записях маскируются и не передаются. Обработка выполняется на серверах в Российской Федерации.',
],
},
{
+3 -28
View File
@@ -18,9 +18,6 @@ import { usePolling } from '../composables/usePolling';
import { POLLING_INTERVAL_MS, POLLING_REMINDERS_INTERVAL_MS } from '../constants/polling';
import AppSidebar from '../components/layout/AppSidebar.vue';
import AppTopbar from '../components/layout/AppTopbar.vue';
import AppBottomNav from '../components/layout/AppBottomNav.vue';
import AppMoreDrawer from '../components/layout/AppMoreDrawer.vue';
import { useLayoutTier } from '../composables/useLayoutTier';
import DevIndexBadge from '../components/DevIndexBadge.vue';
import CommandPalette from '../components/layout/CommandPalette.vue';
import JivoWidget from '../components/support/JivoWidget.vue';
@@ -33,10 +30,7 @@ const notifications = useNotificationsStore();
const tenant = useTenantStore();
const route = useRoute();
const { tier } = useLayoutTier();
const drawerOpen = ref(true);
// «Ещё» на телефоне (разделы вне 5 нижних вкладок)
const moreOpen = ref(false);
// Тот же навигационный pool что в AppSidebar — для crumb-resolution в topbar
// (sidebar и topbar — независимые, но navGroups совпадают по контракту).
@@ -79,13 +73,10 @@ usePolling(loadBalanceStatus, { intervalMs: POLLING_REMINDERS_INTERVAL_MS, enabl
<template>
<v-app>
<ImpersonationSessionBanner />
<AppSidebar v-model:drawer-open="drawerOpen" :tier="tier" />
<AppTopbar
:page-title="currentPageTitle"
@toggle-drawer="tier === 'phone' ? (moreOpen = !moreOpen) : (drawerOpen = !drawerOpen)"
/>
<AppSidebar v-model:drawer-open="drawerOpen" />
<AppTopbar :page-title="currentPageTitle" @toggle-drawer="drawerOpen = !drawerOpen" />
<v-main class="app-main" :data-tier="tier">
<v-main class="app-main">
<BalanceFrozenBanner
:frozen="tenant.frozen"
:deficit-rub="tenant.deficitRub"
@@ -101,28 +92,12 @@ usePolling(loadBalanceStatus, { intervalMs: POLLING_REMINDERS_INTERVAL_MS, enabl
<CommandPalette />
<JivoWidget />
<WelcomeTour />
<!-- Мобильная навигация: нижние вкладки + «Ещё» (только телефон) -->
<AppBottomNav v-if="tier === 'phone'" />
<AppMoreDrawer v-if="tier === 'phone'" v-model:open="moreOpen" />
</v-app>
</template>
<style scoped>
.app-main {
background: #f6f3ec;
}
/* Отступ под навигацию по ярусу.
Десктоп — полный сайдбар 232px; планшет — узкая колонка-иконки (rail) 64px;
телефон — сайдбара нет, снизу панель-вкладки (запас под неё padding-bottom). */
.app-main[data-tier='desktop'] {
padding-left: 232px;
}
.app-main[data-tier='tablet'] {
padding-left: 64px;
}
.app-main[data-tier='phone'] {
padding-left: 0;
padding-bottom: 64px;
}
</style>
-59
View File
@@ -1,59 +0,0 @@
/**
* Ленивый загрузчик Яндекс.Метрики для портала.
* Грузится ТОЛЬКО из роутера при входе в кабинет (meta.layout==='app') — см. router/index.ts.
* id счётчика читается из <meta name="metrika-id"> (проставляется welcome.blade.php по config).
* Вебвизор пишет с маскировкой ПДн (классы ym-hide-content в компонентах кабинета).
*/
declare global {
interface Window {
ym?: (...args: unknown[]) => void;
}
}
let loaded = false;
function counterId(): number | null {
const meta = document.querySelector('meta[name="metrika-id"]');
const raw = meta?.getAttribute('content');
const id = raw ? Number(raw) : NaN;
return Number.isFinite(id) && id > 0 ? id : null;
}
/** Один раз инжектит tag.js и инициализирует Вебвизор. Идемпотентно. No-op без id. */
export function ensureLoaded(): void {
if (loaded) return;
const id = counterId();
if (id === null) return;
loaded = true;
// стандартный bootstrap-стаб ym до загрузки tag.js
const w = window as Window & { ym?: { (...a: unknown[]): void; a?: unknown[]; l?: number } };
w.ym =
w.ym ||
function (...args: unknown[]) {
(w.ym as { a?: unknown[] }).a = (w.ym as { a?: unknown[] }).a || [];
(w.ym as { a: unknown[] }).a.push(args);
};
(w.ym as { l?: number }).l = Number(new Date());
const s = document.createElement('script');
s.async = true;
s.src = `https://mc.yandex.ru/metrika/tag.js?id=${id}`;
document.head.appendChild(s);
// defer:true — без авто-первого-хита; первый хит шлёт роутер вручную
w.ym(id, 'init', {
webvisor: true,
clickmap: true,
trackLinks: true,
accurateTrackBounce: true,
defer: true,
});
}
/** Отправить заход (SPA-переход). No-op без id/ym. */
export function hit(url: string): void {
const id = counterId();
if (id === null || typeof window.ym !== 'function') return;
window.ym(id, 'hit', url);
}
-11
View File
@@ -1,6 +1,5 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
import { useAuthStore } from '../stores/auth';
import { ensureLoaded, hit } from '../plugins/metrika';
/**
* Vue Router (фаза 2). История — `createWebHistory` (HTML5 history API);
@@ -414,13 +413,3 @@ router.beforeEach(async (to) => {
return true;
});
// Метрика/Вебвизор — только в кабинете клиента (layout==='app').
// Загружаем лениво при первом входе, затем шлём hit на каждом переходе внутри кабинета.
// Вход (auth), публичные (public), админка (admin), портал продаж (salesAuth) — БЕЗ Метрики.
router.afterEach((to) => {
if (to.meta.layout === 'app') {
ensureLoaded();
hit(to.fullPath);
}
});
-33
View File
@@ -1,33 +0,0 @@
/**
* Единый источник истины для показа объёмных ступеней тарифа.
*
* В БД у каждой ступени хранится ШИРИНА (`leads_in_tier`) — сколько лидов
* помещается именно в эту ступень, а НЕ «до какого лида она действует».
* Живой расчёт списаний (PricingTierResolver / BalanceToLeadsConverter)
* накапливает эти ширины: tier 1 = 1..w1, tier 2 = w1+1..w1+w2, и т.д.
*
* Эта функция переводит ширину в тот накопительный диапазон, который видит
* клиент в кабинете. Держим её в ОДНОМ месте, чтобы админка и кабинет никогда
* не разошлись в цифрах.
*/
export interface TierWidth {
leads_in_tier: number | null;
}
/**
* Диапазон номеров лидов для ступени с индексом `idx` (0-based) в массиве
* ступеней, отсортированном по возрастанию tier_no.
*
* Пример (ширины 250/500/…): idx=0 → «1250», idx=1 → «251750».
* Последняя ступень (`leads_in_tier === null`) → «19251+».
*/
export function tierRangeText(tiers: ReadonlyArray<TierWidth>, idx: number): string {
let start = 1;
for (let i = 0; i < idx; i++) {
const cap = tiers[i].leads_in_tier;
if (cap !== null) start += cap;
}
const cap = tiers[idx].leads_in_tier;
if (cap === null) return `${start}+`;
return `${start}${start + cap - 1}`;
}
+2 -101
View File
@@ -32,18 +32,11 @@ import DealDetailDrawer from '../components/deals/DealDetailDrawer.vue';
import NewDealDialog from '../components/deals/NewDealDialog.vue';
import { useAuthStore } from '../stores/auth';
import { useLeadStatusesStore } from '../stores/leadStatuses';
import { useLayoutTier } from '../composables/useLayoutTier';
const auth = useAuthStore();
const leadStatusesStore = useLeadStatusesStore();
const leadStatuses = computed(() => leadStatusesStore.statuses);
// Телефон: одна колонка + переключатель этапов (drag вбок неудобен на узком экране;
// смена этапа сделки — тапом по карточке через drawer).
const { isPhone } = useLayoutTier();
const activeSlug = ref<string>(LEAD_STATUSES[0]?.slug ?? '');
const activeStatus = computed(() => leadStatuses.value.find((s) => s.slug === activeSlug.value) ?? leadStatuses.value[0] ?? null);
interface DraggableChangeEvent {
added?: { element: MockDeal; newIndex: number };
removed?: { element: MockDeal; oldIndex: number };
@@ -229,9 +222,7 @@ defineExpose({
><span class="num">{{ totalDeals }}</span> сделок</span
>
<span class="sep">·</span>
<span class="text-caption text-medium-emphasis">{{
isPhone ? 'Выберите этап' : 'Перетаскивание между колонками'
}}</span>
<span class="text-caption text-medium-emphasis"> Перетаскивание между колонками </span>
</div>
</div>
<div class="d-flex ga-2">
@@ -253,37 +244,7 @@ defineExpose({
Не удалось загрузить сделки. Попробуйте обновить.
</v-alert>
<!-- Телефон: переключатель этапов + одна колонка на всю ширину -->
<div v-if="isPhone" class="kanban-mobile mt-4">
<div class="stage-switcher" role="tablist" aria-label="Этапы воронки">
<button
v-for="status in leadStatuses"
:key="status.slug"
type="button"
role="tab"
:aria-selected="status.slug === activeSlug"
class="stage-chip"
:class="{ 'stage-chip--active': status.slug === activeSlug }"
:style="{ '--accent': status.colorHex }"
@click="activeSlug = status.slug"
>
<span class="stage-chip__name">{{ status.nameRu }}</span>
<span class="stage-chip__count">{{ (dealsByStatus[status.slug] || []).length }}</span>
</button>
</div>
<KanbanColumn
v-if="activeStatus"
:key="activeStatus.slug"
:status="activeStatus"
:deals="dealsByStatus[activeSlug] || []"
@update:deals="(list) => (dealsByStatus[activeSlug] = list)"
@change="(e) => onColumnChange(activeSlug as MockDeal['statusSlug'], e)"
@open-deal="onOpenDeal"
/>
</div>
<!-- Планшет/десктоп: все колонки с горизонтальной прокруткой -->
<div v-else class="kanban-board mt-4" tabindex="0" role="region" aria-label="Канбан-доска воронки продаж">
<div class="kanban-board mt-4" tabindex="0" role="region" aria-label="Канбан-доска воронки продаж">
<KanbanColumn
v-for="status in leadStatuses"
:key="status.slug"
@@ -364,66 +325,6 @@ defineExpose({
padding-bottom: 12px;
}
/* Телефон: переключатель этапов (чипсы) + одна колонка на всю ширину */
.kanban-mobile {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
}
.stage-switcher {
display: flex;
gap: 8px;
flex-wrap: wrap;
padding-bottom: 10px;
margin-bottom: 4px;
}
.stage-chip {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 8px 14px;
border-radius: 999px;
border: 1px solid #d9d5cd;
background: #fff;
color: #3a3a33;
font-size: 13px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition:
border-color 150ms ease,
background 150ms ease;
}
.stage-chip__count {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
font-size: 12px;
color: #66635c;
background: #f0ede4;
padding: 0 7px;
border-radius: 10px;
}
.stage-chip--active {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, #fff);
color: #081319;
box-shadow: inset 0 -2px 0 var(--accent);
}
.stage-chip--active .stage-chip__count {
background: #fff;
}
/* Колонка на телефоне — во всю ширину (перебиваем фикс 280px из KanbanColumn) */
.kanban-mobile :deep(.kanban-column) {
flex: 1 1 auto;
width: 100%;
max-height: none;
}
.kanban-mobile :deep(.column-body) {
max-height: calc(100vh - 340px);
}
.kanban-board::-webkit-scrollbar {
height: 8px;
}
+2 -21
View File
@@ -1,6 +1,6 @@
<template>
<v-container fluid class="projects-view" :class="{ 'has-drawer': singleSelectedProject !== null }">
<div class="d-flex justify-space-between align-center mb-4 ga-3 projects-head">
<div class="d-flex justify-space-between align-center mb-4">
<h1 class="text-h4">Проекты</h1>
<v-btn color="primary" prepend-icon="mdi-plus" @click="openCreate">Создать проект</v-btn>
</div>
@@ -29,7 +29,7 @@
</div>
</v-alert>
<div v-if="!isEmptyAccount" class="d-flex flex-wrap ga-3 mb-4 filters-bar">
<div v-if="!isEmptyAccount" class="d-flex flex-wrap ga-3 mb-4">
<v-select
v-model="store.filters.signal_type"
:items="typeFilters"
@@ -372,25 +372,6 @@ onUnmounted(() => store.stopPolling());
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
/* Телефон: фильтры — во всю ширину в столбик (перебиваем inline max-width),
чтобы подписи не обрезались («Р…», «Д…»). */
@media (max-width: 599.98px) {
.filters-bar > * {
max-width: none !important;
flex: 1 1 100%;
}
/* Телефон: заголовок «Проекты» и кнопка «Создать проект» жались вплотную —
ставим кнопку под заголовок во всю ширину, с воздухом между ними. */
.projects-head {
flex-direction: column;
}
/* align-self перебивает utility-класс .align-center (у него !important на
align-items) — заголовок и кнопка прижаты к левому краю, как на десктопе. */
.projects-head > h1,
.projects-head > .v-btn {
align-self: flex-start;
}
}
.toolbar-check {
display: inline-flex;
align-items: center;
@@ -23,7 +23,7 @@
</span>
</v-card-title>
<v-data-table
:headers="activeHeaders"
:headers="tierHeaders"
:items="active"
:items-per-page="7"
density="comfortable"
@@ -33,17 +33,10 @@
<span v-if="item.leads_in_tier !== null">{{ item.leads_in_tier }}</span>
<span v-else class="text-medium-emphasis">все свыше</span>
</template>
<template #[`item.client_range`]="{ item }">
<span :data-testid="`active-range-${item.tier_no}`">{{ activeRangeText(item) }} лидов</span>
</template>
<template #[`item.price_rub`]="{ item }">
{{ fmtRub(item.price_per_lead_kopecks) }}
</template>
</v-data-table>
<div class="text-caption text-medium-emphasis px-4 pb-3">
«Лидов в ступени» сколько заявок помещается именно в эту ступень (ширина). «Диапазон»
как эти ступени видит клиент в кабинете: с какого по какой лид действует цена.
</div>
</v-card>
<v-card v-if="hasScheduled" class="mb-6" elevation="1">
@@ -75,16 +68,11 @@
style="max-width: 240px"
data-testid="effective-from-input"
/>
<p class="text-caption text-medium-emphasis mb-2">
В поле «Лидов в ступени» вводится <strong>ширина</strong> ступени (сколько заявок в неё
помещается). Справа сразу видно, <strong>что увидит клиент</strong> накопительный диапазон.
</p>
<table class="editor-table">
<thead>
<tr>
<th>Ступень</th>
<th>Лидов в ступени</th>
<th>Клиент увидит</th>
<th>Цена за лид ()</th>
</tr>
</thead>
@@ -102,9 +90,6 @@
/>
<span v-else class="text-medium-emphasis">все свыше</span>
</td>
<td :data-testid="`editor-range-${t.tier_no}`" class="editor-range num">
{{ editorRangeText(t) }} лидов
</td>
<td>
<v-text-field
v-model="t.price_rub"
@@ -163,7 +148,6 @@ import {
type PricingTierEditorRow,
} from '../../api/admin';
import { extractErrorMessage } from '../../api/client';
import { tierRangeText } from '../../utils/pricingTiers';
/**
* SaaS-admin → Тарифная сетка (Plan 4 Task 9, Sprint 1 G1 error handling).
@@ -214,28 +198,6 @@ const tierHeaders = [
{ title: 'Цена за лид', key: 'price_rub', sortable: false },
];
// Активная сетка показывает ещё и накопительный диапазон — ровно то, что видит
// клиент в кабинете (устраняет путаницу «в админке одни числа, у клиента другие»).
const activeHeaders = [
{ title: '№', key: 'tier_no', sortable: false, width: 80 },
{ title: 'Лидов в ступени', key: 'leads_in_tier', sortable: false },
{ title: 'Диапазон (у клиента)', key: 'client_range', sortable: false },
{ title: 'Цена за лид', key: 'price_rub', sortable: false },
];
// Диапазон для строки активной сетки: находим позицию ступени по tier_no
// и переводим ширины в накопительный диапазон (общий помощник — как в кабинете).
function activeRangeText(item: AdminPricingTier): string {
const idx = active.value.findIndex((t) => t.tier_no === item.tier_no);
return tierRangeText(active.value, idx);
}
// Живой предпросмотр диапазона в редакторе — пересчитывается при правке ширины.
function editorRangeText(row: PricingTierEditorRow): string {
const idx = editor.value.findIndex((r) => r.tier_no === row.tier_no);
return tierRangeText(editor.value, idx);
}
const nextMonthStart = computed(() => {
const d = new Date();
d.setDate(1);
@@ -345,12 +307,4 @@ defineExpose({
padding: 8px 12px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.num {
font-family: 'JetBrains Mono', monospace;
font-feature-settings: 'tnum';
}
.editor-range {
color: #6b6456;
white-space: nowrap;
}
</style>
@@ -74,9 +74,7 @@ onMounted(async () => {
await store.loadState();
// Deep-link из «Сделок»: ?competitor=<id>[&project=<id>] — открыть экран
// конкурента (и, если задан project, — модалку «Настройки проекта»).
// route?. — на случай монтирования без роутера (юнит-тесты): в приложении
// роутер всегда есть, поведение не меняется.
const q = route?.query ?? {};
const q = route.query;
const compId = Number(Array.isArray(q.competitor) ? q.competitor[0] : q.competitor);
if (Number.isInteger(compId) && compId > 0) {
ctx.competitorId = compId;
@@ -633,19 +633,6 @@ defineExpose({ list, selected, toggle, toggleAll, moveToField, moveSelected, ope
display: flex;
gap: 8px;
}
/* Телефон: 3 кнопки (Добавить/Заменить/Отказаться) не влезали в карточку и
заезжали за правый край — раскладываем на всю ширину поровну. */
@media (max-width: 599.98px) {
.ld-actbtns {
width: 100%;
}
.ld-actbtns .ld-btn {
flex: 1 1 0;
min-width: 0;
padding-left: 6px;
padding-right: 6px;
}
}
.ld-alert-inline {
font-size: 12.5px;
color: #8a1c1c;
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, inject, onMounted, reactive, ref } from 'vue';
import { useAutopodborStore } from '../../../stores/autopodborStore';
import { autopodborErrorMessage, startStudyBatch, type FieldCompetitorDto, type DuplicateGroupDto, type CompetitorElement, type MergeEventDto } from '../../../api/autopodbor';
import { autopodborErrorMessage, type FieldCompetitorDto, type DuplicateGroupDto, type CompetitorElement, type MergeEventDto } from '../../../api/autopodbor';
import { REGIONS_ALPHA } from '../../../constants/regions';
import { useOverlayDismiss } from '../../../composables/useOverlayDismiss';
import CompetitorElementsEditor from './CompetitorElementsEditor.vue';
@@ -101,43 +101,6 @@ async function bulkProjects(active: boolean) {
}
}
// ——— Пакетный сбор источников по выбранным конкурентам (шаг 2 разом) ———
// Яркий алерт нехватки баланса (422): показываем topup_rub и max_affordable, ссылку в биллинг.
const balanceAlert = ref<{ topup_rub: string | number; max_affordable: number } | null>(null);
function isBatch422(err: unknown): err is { response: { status: number; data: { topup_rub: string | number; max_affordable: number } } } {
const e = err as { response?: { status?: number; data?: { topup_rub?: unknown; max_affordable?: unknown } } };
const d = e?.response?.data;
// Балансовый 422 — ТОЛЬКО когда бэкенд реально прислал числа для алерта. Прочие 422
// (валидация, «нечего ставить») не имеют topup_rub/max_affordable → обычный тост с message,
// иначе рисуем «Пополните на ₽» с пустыми числами.
return e?.response?.status === 422
&& d != null
&& typeof d.topup_rub !== 'undefined'
&& typeof d.max_affordable !== 'undefined';
}
async function collectSources() {
if (busy.value || selected.value.length === 0) return;
busy.value = true;
balanceAlert.value = null;
try {
const res = await startStudyBatch([...selected.value]);
flash(`Поставлено в очередь: ${res.queued}. Сбор идёт сам — можно закрыть страницу`);
clearSel();
await store.loadField();
} catch (e) {
if (isBatch422(e)) {
const d = e.response.data;
balanceAlert.value = { topup_rub: d.topup_rub, max_affordable: d.max_affordable };
} else {
flash(autopodborErrorMessage(e, 'Не удалось запустить сбор. Попробуйте ещё раз.'));
}
} finally {
busy.value = false;
}
}
// ——— Финальный проход «Почистить поле»: склейка дублей с подтверждением клиента ———
const { onOverlayMousedown, onOverlayMouseup } = useOverlayDismiss();
// sel — id выбранных к слиянию в группе. По умолчанию выбраны ВСЕ (группа из 2 сливается как раньше).
@@ -395,7 +358,7 @@ onMounted(async () => {
}
});
defineExpose({ competitors, selected, toggle, toggleAll, bulkProjects, collectSources, balanceAlert, openCollect, openAdd, dupes, dupCount, openDupes, mergeGroup, dismissGroup, history, openHistory, restoreEvent });
defineExpose({ competitors, selected, toggle, toggleAll, bulkProjects, openCollect, openAdd, dupes, dupCount, openDupes, mergeGroup, dismissGroup, history, openHistory, restoreEvent });
</script>
<template>
@@ -487,23 +450,10 @@ defineExpose({ competitors, selected, toggle, toggleAll, bulkProjects, collectSo
<button class="ld-btn gray sm" :disabled="busy" @click="clearSel">Снять выбор</button>
<button v-if="someSelectedHaveProjects" class="ld-btn warn sm" :disabled="busy" @click="bulkProjects(false)"> Выключить все проекты</button>
<button class="ld-btn primary sm" :disabled="busy" @click="bulkProjects(true)"> Включить все созданные проекты</button>
<button class="ld-btn primary sm" data-testid="batch-collect-sources" :disabled="busy || selected.length === 0" @click="collectSources">🔎 Собрать источники ({{ selected.length }})</button>
</div>
</div>
</Transition>
<!-- Яркий алерт нехватки баланса при пакетном сборе (422) -->
<Transition name="ld-bar">
<div v-if="balanceAlert" class="ld-balert" data-testid="batch-balance-alert" role="alert">
<button class="ld-balert__x" aria-label="Закрыть" @click="balanceAlert = null"></button>
<div class="ld-balert__body">
<b>Не хватает баланса.</b>
Пополните на {{ balanceAlert.topup_rub }} или уменьшите выборку до {{ balanceAlert.max_affordable }} конкурентов.
</div>
<a class="ld-btn primary sm" href="/billing">Пополнить</a>
</div>
</Transition>
<!-- ====== Окно: Собрать конкурентов ====== -->
<!-- ===== Окно: Найти и объединить дубли (финальный проход) ===== -->
<div v-if="dupes.open" class="ld-ovl" @mousedown="onOverlayMousedown" @mouseup="(e) => onOverlayMouseup(e, () => { dupes.open = false; })">
@@ -724,38 +674,4 @@ defineExpose({ competitors, selected, toggle, toggleAll, bulkProjects, collectSo
color: #8a8578;
font-weight: 400;
}
.ld-balert {
position: fixed;
left: 50%;
bottom: 84px;
transform: translateX(-50%);
z-index: 60;
display: flex;
align-items: center;
gap: 14px;
max-width: 720px;
width: calc(100% - 32px);
padding: 14px 44px 14px 18px;
border: 2px solid #c26a00;
border-radius: 12px;
background: #fff4e2;
color: #5b3200;
box-shadow: 0 10px 30px rgba(120, 70, 0, 0.28);
font-size: 14px;
line-height: 1.4;
}
.ld-balert__body {
flex: 1;
}
.ld-balert__x {
position: absolute;
top: 8px;
right: 10px;
border: 0;
background: transparent;
color: #8a5a1a;
font-size: 15px;
cursor: pointer;
line-height: 1;
}
</style>
@@ -116,36 +116,6 @@
.ld-banner--amber .ld-banner__txt b {
color: #8a6a12;
}
/* Телефон: текст и ссылка не влезают в строку (текст крошился по слову,
«Разобрать предложения » вылезала за край) раскладываем в столбик. */
@media (max-width: 599.98px) {
.ld-banner {
flex-direction: column;
align-items: flex-start;
gap: 8px;
padding: 14px 16px;
}
.ld-banner__link {
white-space: normal;
}
/* «Выбрать всех» + подсказка — не жать в узкую колонку, подсказка на новую строку. */
.ld-selrow {
flex-wrap: wrap;
gap: 4px 12px;
}
/* Карточка источника: кнопки (Создать проект/Приостановить/Настройки/В источники)
стояли справа фикс. блоком и уезжали за край переносим их на строку ниже
во всю ширину карточки. */
.ld-srccard {
flex-wrap: wrap;
}
.ld-srcctl {
flex-basis: 100%;
max-width: none;
justify-content: flex-start;
margin-top: 4px;
}
}
.ld-tabs {
display: flex;
@@ -1,11 +1,5 @@
<template>
<v-dialog
:model-value="modelValue"
max-width="720"
:fullscreen="isPhone"
scrollable
@update:model-value="$emit('update:modelValue', $event)"
>
<v-dialog :model-value="modelValue" max-width="720" @update:model-value="$emit('update:modelValue', $event)">
<v-card style="position: relative">
<DevIndexBadge
:index="mode === 'edit' ? 19 : 18"
@@ -282,7 +276,7 @@
<div class="mt-3">
<span class="text-caption">Дни недели приёма</span>
<v-btn-toggle v-model="selectedDays" multiple density="comfortable" class="mt-1 weekday-toggle">
<v-btn-toggle v-model="selectedDays" multiple density="comfortable" class="mt-1">
<v-btn v-for="(day, i) in dayLabels" :key="i" :value="i">{{ day }}</v-btn>
</v-btn-toggle>
<div class="mt-1">
@@ -336,7 +330,6 @@
<script setup lang="ts">
import { ref, reactive, watch, computed } from 'vue';
import { useLayoutTier } from '../../composables/useLayoutTier';
import { apiClient, ensureCsrfCookie, extractErrorMessage } from '../../api/client';
import { getRequisites, updateRequisites, type Requisites } from '../../api/requisites';
import { firstLeadDate, formatLeadDate } from '../../utils/leadDate';
@@ -415,9 +408,6 @@ const emit = defineEmits<{
saved: [appliesFrom: string | null];
}>();
// На телефоне диалог — на весь экран (иначе узкий модал режет ряды кнопок/дней).
const { isPhone } = useLayoutTier();
// Plan 6: regions = subject codes (1..89) — backend dual-writes region_mask/region_mode.
// Пустой массив = вся РФ.
const form = reactive({
@@ -733,20 +723,6 @@ defineExpose({
line-height: 1.4;
padding: 4px 2px;
}
/* Телефон: 7 кнопок дней недели (ПН…ВС) по 64px не влезали в 390px —
СБ и ВС уезжали за правый край и были не видны. Растягиваем группу на всю
ширину и делим её поровну между кнопками, чтобы все 7 помещались в строку. */
@media (max-width: 599.98px) {
.weekday-toggle {
display: flex;
width: 100%;
}
.weekday-toggle :deep(.v-btn) {
flex: 1 1 0;
min-width: 0;
padding-inline: 0;
}
}
.steps-head {
display: flex;
align-items: center;
+2 -10
View File
@@ -201,7 +201,7 @@ async function runWebhookTest(): Promise<void> {
/>
</template>
</v-text-field>
<div class="d-flex ga-2 mt-2 btn-row">
<div class="d-flex ga-2 mt-2">
<v-btn
variant="outlined"
size="small"
@@ -296,7 +296,7 @@ async function runWebhookTest(): Promise<void> {
/>
</template>
</v-text-field>
<div class="d-flex ga-2 mt-2 btn-row">
<div class="d-flex ga-2 mt-2">
<v-btn
color="primary"
variant="flat"
@@ -359,12 +359,4 @@ async function runWebhookTest(): Promise<void> {
font-variation-settings: 'opsz' 18;
letter-spacing: -0.005em;
}
/* Телефон: пары кнопок (Копировать+Перегенерировать, Сохранить+Тестовый webhook)
не влезали в строку — длинная вторая кнопка уезжала за край. Разрешаем перенос
на вторую строку. */
@media (max-width: 599.98px) {
.btn-row {
flex-wrap: wrap;
}
}
</style>
@@ -262,29 +262,4 @@ const canSave = computed(() => dirty.value && !saving.value && auth.isAuthentica
gap: 12px;
margin-top: 8px;
}
/* Телефон: колонки каналов по 110/130px не влезали — Email уезжал за край,
а название события сжималось в узкую колонку и текст крошился по одному слову.
Ужимаем колонки-галочки до ширины чекбокса и уменьшаем отступы, чтобы событию
осталась почти вся ширина. Заголовки каналов переносим по буквам. */
@media (max-width: 599.98px) {
.prefs-head,
.prefs-row {
grid-template-columns: 1fr 46px 46px;
}
.prefs-cell {
padding: 8px 6px;
}
.ch-col {
overflow-wrap: anywhere;
word-break: break-word;
line-height: 1.15;
}
/* Заголовки каналов — мельче, чтобы «EMAIL» помещался в строку, а не ломался. */
.prefs-head .ch-col {
font-size: 9px;
letter-spacing: 0.02em;
padding-inline: 3px;
}
}
</style>
@@ -196,7 +196,7 @@ async function save(): Promise<void> {
mandatory="force"
density="comfortable"
color="primary"
class="mt-1 d-block subject-type-toggle"
class="mt-1 d-block"
data-testid="requisites-subject-type"
>
<v-btn v-for="t in subjectTypes" :key="t.value" :value="t.value">{{ t.label }}</v-btn>
@@ -390,27 +390,4 @@ async function save(): Promise<void> {
font-variation-settings: 'opsz' 18;
letter-spacing: -0.005em;
}
/* Телефон: три длинных варианта «Тип лица» не влезают в строку —
раскладываем сегмент-контрол в столбик во всю ширину. */
@media (max-width: 599.98px) {
.subject-type-toggle {
display: flex;
flex-direction: column;
height: auto;
width: 100%;
}
.subject-type-toggle :deep(.v-btn) {
width: 100%;
border-radius: 0;
}
.subject-type-toggle :deep(.v-btn:first-child) {
border-top-left-radius: 6px;
border-top-right-radius: 6px;
}
.subject-type-toggle :deep(.v-btn:last-child) {
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
}
}
</style>
-3
View File
@@ -5,9 +5,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ config('app.name', 'Лидерра') }}</title>
<meta name="support-email" content="{{ config('services.support.email') }}">
@if(config('services.metrika.counter_id'))
<meta name="metrika-id" content="{{ config('services.metrika.counter_id') }}">
@endif
@vite(['resources/css/app.css', 'resources/js/app.ts'])
@if(config('services.jivosite.widget_id'))
<script src="https://code.jivo.ru/widget/{{ config('services.jivosite.widget_id') }}" async></script>
-17
View File
@@ -204,20 +204,3 @@ Schedule::command('scheduler:check-heartbeats')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('scheduler:check-heartbeats', true, null, null))
->onFailure(fn () => $hb->recordRunResult('scheduler:check-heartbeats', false, 'Command failed', null));
// Задача 6 (пакетный сбор источников): страховка распорядителя шага 2. Джоба
// RunAutopodborStudyJob уже зовёт AutopodborStudyScheduler::tick() в finally после
// КАЖДОГО завершения прогона — это основной канал продвижения очереди. Этот крон —
// подстраховка на случай, если дорожка освободилась не через завершение джобы
// (например воркер шага 2 не поднят/упал молча) — раз в минуту проверяет и,
// если дорожка свободна, а очередь не пуста, стартует следующего.
//
// НЕ используем ->withoutOverlapping(): требует таблицу cache_locks, которой у нас
// нет в schema.sql (см. .env.example CACHE_STORE=database + комментарий выше в этом
// файле у Hole #2). Overlap и так не страшен — pickNextRunId()/tick() уже защищены
// собственным Cache::lock('autopodbor:study:tick', 10) внутри AutopodborStudyScheduler,
// а сам pickNextRunId() проверяет status='running' атомарно на момент чтения.
Schedule::command('autopodbor:study-tick')
->everyMinute()
->onSuccess(fn () => $hb->recordRunResult('autopodbor:study-tick', true, null, null))
->onFailure(fn () => $hb->recordRunResult('autopodbor:study-tick', false, 'Command failed', null));
-1
View File
@@ -357,7 +357,6 @@ Route::middleware(['auth:sanctum,impersonation', 'tenant'])->prefix('/api/autopo
Route::get('/competitors/{competitor}', 'App\Http\Controllers\Api\AutopodborController@competitor')->where('competitor', '[0-9]+');
Route::post('/search', 'App\Http\Controllers\Api\AutopodborController@search');
Route::post('/study', 'App\Http\Controllers\Api\AutopodborController@study');
Route::post('/study/batch', 'App\Http\Controllers\Api\AutopodborController@studyBatch');
Route::post('/resolve', 'App\Http\Controllers\Api\AutopodborController@resolve');
Route::post('/projects', 'App\Http\Controllers\Api\AutopodborController@createProjects');
Route::post('/manual-study', 'App\Http\Controllers\Api\AutopodborController@manualStudy');
+2 -2
View File
@@ -106,7 +106,7 @@ test('register требует accept_offer, accept_pdn, captcha_token', function
->assertStatus(422)->assertJsonValidationErrors(['captcha_token']);
});
test('confirm верным кодом активирует тенанта/владельца, баланс 1000, выполняет вход', function () {
test('confirm верным кодом активирует тенанта/владельца, баланс 300, выполняет вход', function () {
$reg = $this->postJson('/api/auth/register', registerPayload())->assertStatus(201);
$code = $reg->json('_dev_plain_code');
@@ -124,7 +124,7 @@ test('confirm верным кодом активирует тенанта/вла
$tenant = Tenant::find($user->tenant_id);
expect($tenant->status)->toBe('active');
expect((float) $tenant->balance_rub)->toBe(1000.0);
expect((float) $tenant->balance_rub)->toBe(300.0);
});
test('confirm неверным кодом → 422 + failed_attempts++', function () {
@@ -1,70 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborRun;
use App\Models\PricingTier;
use App\Models\Project;
use App\Models\SystemSetting;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
beforeEach(fn () => Queue::fake());
function studyBatchApiCompetitor(int $tenantId, string $name): AutopodborCompetitor
{
return AutopodborCompetitor::create([
'tenant_id' => $tenantId,
'name' => $name,
'origin' => 'manual',
'dedup_key' => Str::slug($name).'-'.Str::random(6),
'box' => 'field',
]);
}
it('POST /api/autopodbor/study/batch — достаточный баланс → 202 с batch_id и queued', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
DB::statement('SET app.current_tenant_id = '.$tenant->id);
SystemSetting::updateOrCreate(['key' => 'autopodbor_price_study_rub'], ['value' => '300', 'type' => 'decimal']);
$c1 = studyBatchApiCompetitor($tenant->id, 'Окна А');
$c2 = studyBatchApiCompetitor($tenant->id, 'Окна Б');
$response = $this->actingAs($user)->postJson('/api/autopodbor/study/batch', [
'competitor_ids' => [$c1->id, $c2->id],
])->assertStatus(202)
->assertJsonStructure(['batch_id', 'queued'])
->assertJsonPath('queued', 2);
expect(AutopodborRun::where('batch_id', $response->json('batch_id'))->count())->toBe(2);
});
it('POST /api/autopodbor/study/batch — маленький баланс + committed проекты → 422 с topup_rub и max_affordable', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '100.00', 'delivered_in_month' => 0]);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
DB::statement('SET app.current_tenant_id = '.$tenant->id);
SystemSetting::updateOrCreate(['key' => 'autopodbor_price_study_rub'], ['value' => '300', 'type' => 'decimal']);
// Активный проект-обязательство + сетка тарифов — гейт учитывает резерв проектов (паритет со StudyBatchEnqueueTest).
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true, 'daily_limit_target' => 10]);
PricingTier::factory()->create([
'tier_no' => 1, 'leads_in_tier' => null, 'price_per_lead_kopecks' => 3000,
'is_active' => true, 'effective_from' => now()->toDateString(),
]);
$c1 = studyBatchApiCompetitor($tenant->id, 'Окна А');
$c2 = studyBatchApiCompetitor($tenant->id, 'Окна Б');
$this->actingAs($user)->postJson('/api/autopodbor/study/batch', [
'competitor_ids' => [$c1->id, $c2->id],
])->assertStatus(422)
->assertJsonStructure(['message', 'topup_rub', 'max_affordable']);
});
@@ -1,108 +0,0 @@
<?php
declare(strict_types=1);
use App\Exceptions\Autopodbor\BatchBudgetException;
use App\Jobs\Autopodbor\RunAutopodborStudyJob;
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborRun;
use App\Models\PricingTier;
use App\Models\Project;
use App\Models\SystemSetting;
use App\Models\Tenant;
use App\Services\Autopodbor\AutopodborRunService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
function batchCompetitor(int $tenantId, string $name): AutopodborCompetitor
{
return AutopodborCompetitor::create([
'tenant_id' => $tenantId,
'name' => $name,
'origin' => 'manual',
'dedup_key' => Str::slug($name).'-'.Str::random(6),
'box' => 'field',
]);
}
it('ставит пакет: N queued-прогонов с общим batch_id, распорядитель стартует ровно первого', function () {
Queue::fake();
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
DB::statement('SET app.current_tenant_id = '.$tenant->id);
SystemSetting::updateOrCreate(['key' => 'autopodbor_price_study_rub'], ['value' => '300', 'type' => 'decimal']);
$c1 = batchCompetitor($tenant->id, 'Окна А');
$c2 = batchCompetitor($tenant->id, 'Окна Б');
$c3 = batchCompetitor($tenant->id, 'Окна В');
$result = app(AutopodborRunService::class)->startStudyBatch($tenant->id, [$c1->id, $c2->id, $c3->id]);
expect($result->queued)->toBe(3)
->and($result->batch_id)->not->toBeNull();
$runs = AutopodborRun::where('batch_id', $result->batch_id)->get();
expect($runs)->toHaveCount(3)
->and($runs->pluck('status')->unique()->values()->all())->toBe(['queued'])
->and($runs->pluck('kind')->unique()->values()->all())->toBe(['study']);
// Джобы не диспатчатся напрямую из сервиса — распорядитель стартует РОВНО первого.
Queue::assertPushed(RunAutopodborStudyJob::class, 1);
});
it('нормализация: дубли, чужие конкуренты и уже-в-очереди отсеиваются', function () {
Queue::fake();
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
$other = Tenant::factory()->create(['balance_rub' => '100000.00']);
DB::statement('SET app.current_tenant_id = '.$tenant->id);
SystemSetting::updateOrCreate(['key' => 'autopodbor_price_study_rub'], ['value' => '300', 'type' => 'decimal']);
$c1 = batchCompetitor($tenant->id, 'Окна А');
$c2 = batchCompetitor($tenant->id, 'Окна Б');
$foreign = batchCompetitor($other->id, 'Чужой');
$busy = batchCompetitor($tenant->id, 'Занятый');
// У «занятого» уже есть queued study-прогон → должен быть отсеян.
AutopodborRun::create([
'tenant_id' => $tenant->id, 'kind' => 'study', 'status' => 'queued',
'competitor_id' => $busy->id, 'region_code' => 1, 'params' => [],
]);
$result = app(AutopodborRunService::class)->startStudyBatch(
$tenant->id,
[$c1->id, $c1->id, $c2->id, $foreign->id, $busy->id],
);
expect($result->queued)->toBe(2);
$runs = AutopodborRun::where('batch_id', $result->batch_id)->get();
expect($runs)->toHaveCount(2)
->and($runs->pluck('competitor_id')->sort()->values()->all())
->toBe(collect([$c1->id, $c2->id])->sort()->values()->all());
});
it('нет баланса на весь пакет → BatchBudgetException, ни одного прогона не создано', function () {
Queue::fake();
$tenant = Tenant::factory()->create(['balance_rub' => '100.00', 'delivered_in_month' => 0]);
DB::statement('SET app.current_tenant_id = '.$tenant->id);
SystemSetting::updateOrCreate(['key' => 'autopodbor_price_study_rub'], ['value' => '300', 'type' => 'decimal']);
// Активный проект-обязательство + сетка тарифов — гейт учитывает резерв проектов.
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true, 'daily_limit_target' => 10]);
PricingTier::factory()->create([
'tier_no' => 1, 'leads_in_tier' => null, 'price_per_lead_kopecks' => 3000,
'is_active' => true, 'effective_from' => now()->toDateString(),
]);
$c1 = batchCompetitor($tenant->id, 'Окна А');
$c2 = batchCompetitor($tenant->id, 'Окна Б');
expect(fn () => app(AutopodborRunService::class)->startStudyBatch($tenant->id, [$c1->id, $c2->id]))
->toThrow(BatchBudgetException::class);
expect(AutopodborRun::where('tenant_id', $tenant->id)->where('kind', 'study')->count())->toBe(0);
Queue::assertNotPushed(RunAutopodborStudyJob::class);
});
@@ -1,61 +0,0 @@
<?php
declare(strict_types=1);
use App\Jobs\Autopodbor\RunAutopodborStudyJob;
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborRun;
use App\Models\SystemSetting;
use App\Models\Tenant;
use App\Services\Autopodbor\Agent\CompetitorAgent;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Tests\Concerns\SharesSupplierPdo;
use Tests\Doubles\EmptyCompetitorAgent;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
it('после завершения прогона джоба сама зовёт распорядителя, и тот диспатчит следующий queued', function () {
Queue::fake();
// Детерминированная заглушка агента (без реальной сети) — прогон уйдёт в `empty` быстро.
app()->bind(CompetitorAgent::class, EmptyCompetitorAgent::class);
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
DB::statement('SET app.current_tenant_id = '.$tenant->id);
SystemSetting::updateOrCreate(['key' => 'autopodbor_price_study_rub'], ['value' => '300', 'type' => 'decimal']);
$comp1 = AutopodborCompetitor::create([
'tenant_id' => $tenant->id, 'name' => 'Окна А', 'origin' => 'manual',
'dedup_key' => Str::slug('Окна А').'-'.Str::random(6), 'box' => 'field',
]);
$comp2 = AutopodborCompetitor::create([
'tenant_id' => $tenant->id, 'name' => 'Окна Б', 'origin' => 'manual',
'dedup_key' => Str::slug('Окна Б').'-'.Str::random(6), 'box' => 'field',
]);
$run1 = AutopodborRun::create([
'tenant_id' => $tenant->id, 'kind' => 'study', 'status' => 'queued',
'competitor_id' => $comp1->id, 'region_code' => 1, 'params' => [],
]);
AutopodborRun::whereKey($run1->id)->update(['created_at' => now()->subMinutes(5)]);
$run2 = AutopodborRun::create([
'tenant_id' => $tenant->id, 'kind' => 'study', 'status' => 'queued',
'competitor_id' => $comp2->id, 'region_code' => 1, 'params' => [],
]);
AutopodborRun::whereKey($run2->id)->update(['created_at' => now()]);
// Класс должен инстанцироваться с новой зависимостью в handle() без ошибок конструктора/резолва.
expect(new RunAutopodborStudyJob($run1->id, $tenant->id))->toBeInstanceOf(RunAutopodborStudyJob::class);
// Прогоняем ПЕРВЫЙ прогон РЕАЛЬНО (через handle(), не через Queue) — Queue::fake() гарантирует,
// что единственный dispatch RunAutopodborStudyJob, который мы увидим, придёт ИЗНУТРИ handle()
// (от распорядителя), а не от нашего собственного вызова.
app()->call([new RunAutopodborStudyJob($run1->id, $tenant->id), 'handle']);
expect($run1->fresh()->status)->toBe('empty');
Queue::assertPushed(RunAutopodborStudyJob::class, fn ($job) => $job->runId === $run2->id);
});
@@ -2,13 +2,9 @@
declare(strict_types=1);
use App\Mail\IncidentDetectedMail;
use App\Models\Project;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
@@ -111,63 +107,3 @@ test('creates separate incidents for different exception signatures', function (
expect(DB::table('incidents_log')->count())->toBe(2);
});
// ===== Блок 6: supplier-manual-queue spike =====
// Helper: seed a project + N supplier_manual_sync_queue rows within the window
function seedManualQueue(int $n, ?DateTimeInterface $at = null): int
{
$project = Project::factory()->for(Tenant::factory())->create();
for ($i = 0; $i < $n; $i++) {
DB::table('supplier_manual_sync_queue')->insert([
'project_id' => $project->id,
'platform' => 'B1',
'operation' => 'create',
'external_id' => null,
'payload_snapshot' => '{}',
'failure_reason' => 'portal_unreachable',
'status' => 'pending',
'created_at' => $at ?? now(),
]);
}
return $project->id;
}
test('manual-queue spike below threshold: no incident, no mail', function () {
Mail::fake();
seedManualQueue(3, now()); // < default threshold 10
$this->artisan('incidents:watch-failures')->assertSuccessful();
expect(DB::table('incidents_log')->where('root_cause', 'supplier-manual-queue-spike')->count())->toBe(0);
Mail::assertNothingSent();
});
test('manual-queue spike at/above threshold: high incident + mail to both addresses', function () {
Mail::fake();
seedManualQueue(10, now()); // == default threshold 10
$this->artisan('incidents:watch-failures')->assertSuccessful();
$incident = DB::table('incidents_log')->where('root_cause', 'supplier-manual-queue-spike')->first();
expect($incident)->not->toBeNull();
expect($incident->severity)->toBe('high');
expect($incident->summary)->toContain('10');
Mail::assertSent(IncidentDetectedMail::class, function ($mail) {
return $mail->hasTo('kdv1@bk.ru') && $mail->hasTo('ops@liderra.ru');
});
});
test('manual-queue spike dedup: second run in window makes no new incident BUT re-sends mail', function () {
Mail::fake();
seedManualQueue(12, now());
$this->artisan('incidents:watch-failures')->assertSuccessful();
$this->artisan('incidents:watch-failures')->assertSuccessful();
// Инцидент один (дедуп 60 мин), а письмо ушло на каждом прогоне (громкость).
expect(DB::table('incidents_log')->where('root_cause', 'supplier-manual-queue-spike')->count())->toBe(1);
Mail::assertSent(IncidentDetectedMail::class, 2);
});
-14
View File
@@ -100,17 +100,3 @@ test('POST /api/deals/export нейтрализует CSV-формулы в св
expect($body)->not->toContain('"=HYPERLINK(');
expect($body)->not->toContain(';@SUM');
});
test('POST /api/deals/export срезает канальный префикс B<N>_ из источника (не палим поставщика)', function () {
$bgProject = Project::factory()->for($this->tenant)->create(['name' => 'B6_okna.ru [12]']);
Deal::factory()->for($this->tenant)->for($bgProject)->create([
'phone' => '+7 999 333-33-33', 'received_at' => '2026-05-15 10:00:00',
]);
$body = $this->post('/api/deals/export', [
'received_from' => '2026-05-14', 'received_to' => '2026-05-16', 'format' => 'csv',
])->streamedContent();
expect($body)->toContain('okna.ru [12]');
expect($body)->not->toContain('B6_');
});
-10
View File
@@ -493,13 +493,3 @@ test('GET /api/deals: источник чужого tenant не подтягив
$res = $this->getJson('/api/deals')->assertOk()->json('deals.0');
expect($res['competitor_name'])->toBeNull();
});
test('GET /api/deals срезает канальный префикс B<N>_ из project_name (не палим поставщика)', function () {
$bgProject = Project::factory()->for($this->tenant)->create(['name' => 'B6_okna.ru']);
Deal::factory()->for($this->tenant)->for($bgProject)->create(['status' => 'new']);
$r = $this->getJson('/api/deals');
$r->assertStatus(200);
expect($r->json('deals.0.project_name'))->toBe('okna.ru');
});
@@ -5,6 +5,7 @@ declare(strict_types=1);
use App\Exceptions\Supplier\SupplierAuthException;
use App\Exceptions\Supplier\SupplierClientException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Mail\SupplierCriticalAlertMail;
use App\Models\Project;
use App\Models\SupplierManualSyncQueue;
use App\Models\Tenant;
@@ -13,6 +14,7 @@ use App\Services\Supplier\Channel\Exceptions\WindowDeferredException;
use App\Services\Supplier\Channel\FailoverProjectChannel;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use App\Services\Supplier\Dto\SupplierProjectDto;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Mail;
@@ -44,6 +46,7 @@ function makeFailover(SupplierProjectChannel $tier1, ?SupplierProjectChannel $ti
return [];
}
},
app(Mailer::class),
);
}
@@ -122,7 +125,7 @@ it('createProject — Tier 1 transient-exhausted: skips Tier 2, jumps to Tier 3
expect($tier2Called)->toBeFalse();
expect(SupplierManualSyncQueue::where('project_id', $project->id)->where('failure_reason', 'portal_unreachable')->count())->toBe(1);
Mail::assertNothingQueued();
Mail::assertQueued(SupplierCriticalAlertMail::class);
});
it('createProject — Tier 1 client-exc → Tier 2 success: no queue', function (): void {
@@ -162,7 +165,7 @@ it('createProject — Tier 1 client-exc → Tier 2 success: no queue', function
expect($id)->toBe(800001);
expect(SupplierManualSyncQueue::count())->toBe(0);
Mail::assertNothingQueued(); // failover через форму — успех, не тревога
Mail::assertQueued(SupplierCriticalAlertMail::class); // failover_to_form alert
});
it('createProject — Tier 1 client-exc + Tier 2 fail: Tier 3 queue, manual_required alert', function (): void {
@@ -202,7 +205,7 @@ it('createProject — Tier 1 client-exc + Tier 2 fail: Tier 3 queue, manual_requ
->toThrow(TierEscalatedException::class);
expect(SupplierManualSyncQueue::where('project_id', $project->id)->where('status', 'pending')->count())->toBe(1);
Mail::assertNothingQueued();
Mail::assertQueued(SupplierCriticalAlertMail::class);
});
it('createProject — Tier 1 auth-exc → Tier 2 success', function (): void {
@@ -4,6 +4,7 @@ declare(strict_types=1);
use App\Exceptions\Supplier\SupplierClientException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Mail\SupplierCriticalAlertMail;
use App\Models\Project;
use App\Models\SupplierManualSyncQueue;
use App\Models\Tenant;
@@ -11,12 +12,13 @@ use App\Services\Supplier\Channel\Exceptions\TierEscalatedException;
use App\Services\Supplier\Channel\FailoverProjectChannel;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use App\Services\Supplier\Dto\SupplierProjectDto;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
uses(RefreshDatabase::class);
test('Tier-1 fail + Tier-2 fail → Tier-3 escalation creates manual queue row, no per-project alert mail', function (): void {
test('Tier-1 fail + Tier-2 fail → Tier-3 escalation creates manual queue row + queues alert mail', function (): void {
Mail::fake();
config(['services.supplier.alert_email' => 'ops@liderra.local']);
@@ -30,7 +32,7 @@ test('Tier-1 fail + Tier-2 fail → Tier-3 escalation creates manual queue row,
$tier2 = mock(SupplierProjectChannel::class);
$tier2->shouldReceive('createProject')->andThrow(new RuntimeException('Tier-2 manage-project.js selector break'));
$channel = new FailoverProjectChannel($tier1, $tier2);
$channel = new FailoverProjectChannel($tier1, $tier2, app(Mailer::class));
$dto = new SupplierProjectDto(
platform: 'B1', signalType: 'site', uniqueKey: 'failover-smoke.example',
@@ -42,7 +44,7 @@ test('Tier-1 fail + Tier-2 fail → Tier-3 escalation creates manual queue row,
expect(SupplierManualSyncQueue::where('project_id', $project->id)->count())->toBe(1);
Mail::assertNothingQueued(); // per-project тревога убрана — запись в ручной очереди достаточна
Mail::assertQueued(SupplierCriticalAlertMail::class, fn ($m) => $m->alertType === 'manual_required');
});
test('Tier-1 transient fail (portal unreachable) bypasses Tier-2 and goes straight to Tier-3', function (): void {
@@ -59,7 +61,7 @@ test('Tier-1 transient fail (portal unreachable) bypasses Tier-2 and goes straig
$tier2 = mock(SupplierProjectChannel::class);
$tier2->shouldNotReceive('createProject'); // КЛЮЧЕВОЕ — transient НЕ должен попасть в tier-2
$channel = new FailoverProjectChannel($tier1, $tier2);
$channel = new FailoverProjectChannel($tier1, $tier2, app(Mailer::class));
$dto = new SupplierProjectDto(
platform: 'B1', signalType: 'site', uniqueKey: 'transient-smoke.example',
@@ -577,113 +577,3 @@ test('nightly: re-creates donor on portal when its external_id no longer exists
expect($sps)->toHaveCount(3);
expect($sps->pluck('supplier_external_id')->all())->toBe(['8001', '8002', '8003']);
});
// ---------------------------------------------------------------------------
// Deactivation (18:00): turn OFF orders whose source has no active project left.
// Money-critical (owner-reported 2026-07-08, verified on prod live DB): prod runs
// in BATCH mode. The supplier fixes tomorrow's delivery by its 21:00 snapshot, so the
// ONLY window to STOP an order is the 18:00 batch sync — before that snapshot. This
// nightly job only processed ACTIVE projects and never turned OFF a source whose
// projects are all now paused → the paused order stayed active at the supplier → next
// day leads (and charges) kept coming. The 18:00 job must push status=paused for such
// orphaned orders before 21:00.
// ---------------------------------------------------------------------------
test('nightly 18:00: a source with no active project left is turned OFF (status=paused) at the supplier', function (): void {
$tenant = Tenant::factory()->create();
$common = '79997778899';
// Project is PAUSED → excluded from tomorrow's snapshot (SnapshotProjectRoutingJob
// filters is_active=true). Deliberately NO insertSnapshotForTomorrow — nothing active
// references this source tonight, so its supplier order is orphaned.
Project::factory()->create([
'tenant_id' => $tenant->id, 'is_active' => false, 'signal_type' => 'call',
'signal_identifier' => $common, 'daily_limit_target' => 6, 'delivery_days_mask' => 127, 'regions' => [],
]);
// Pre-seed the supplier orders created earlier when the project was live (numeric
// external ids so updateProject's (int) cast keeps id != 0).
foreach (['B1' => '90001', 'B2' => '90002', 'B3' => '90003'] as $platform => $ext) {
SupplierProject::on('pgsql_supplier')->create([
'platform' => $platform, 'signal_type' => 'call', 'unique_key' => $common,
'subject_code' => null, 'supplier_external_id' => $ext, 'current_limit' => 2,
'current_workdays' => [1, 2, 3, 4, 5, 6, 7], 'current_regions' => [], 'sync_status' => 'ok',
'last_synced_at' => now()->subDay(),
]);
}
$savePayloads = [];
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => function ($request) use (&$savePayloads) {
$savePayloads[] = $request->data();
return Http::response(['status' => 'OK', 'message' => '', 'id' => (string) ($request->data()['id'] ?? 0)], 200);
},
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => []], 200),
]);
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
// The 3 orphaned orders were turned OFF: update (id != 0) with status=false.
$offCalls = array_values(array_filter(
$savePayloads,
fn ($p) => (int) ($p['id'] ?? 0) !== 0 && ($p['status'] ?? null) === false
));
expect($offCalls)->toHaveCount(3);
// Non-zero limit kept (portal rejects limit=0 even when paused).
foreach ($offCalls as $p) {
expect((int) $p['limit'])->toBeGreaterThan(0);
}
// Local rows marked inactive_since so cleanup/UI reflect the pause.
expect(SupplierProject::on('pgsql_supplier')->where('unique_key', $common)->whereNotNull('inactive_since')->count())->toBe(3);
});
test('nightly 18:00: a deleted project leftover order is turned OFF but KEPT in DB while its snapshot tail still routes', function (): void {
// Composition of the new deactivation pass with delete-defer (DeleteSupplierProjectTailGuardTest):
// when a project is deleted but its donor is deferred (so already-ordered tail leads still
// route via the snapshot), the 18:00 pass must still turn the order OFF at the supplier
// (stop FUTURE ordering) — WITHOUT deleting the local donor (the tail still needs it).
$tenant = Tenant::factory()->create();
$common = '79996665544';
// Donor exists (created when the project was live). Project already hard-deleted → no pivot
// links, and NOT in tomorrow's active snapshot.
$sp = SupplierProject::on('pgsql_supplier')->create([
'platform' => 'B1', 'signal_type' => 'call', 'unique_key' => $common,
'subject_code' => null, 'supplier_external_id' => '95001', 'current_limit' => 3,
'current_workdays' => [1, 2, 3, 4, 5, 6, 7], 'current_regions' => [], 'sync_status' => 'ok',
'last_synced_at' => now()->subDay(),
]);
// Tail snapshot for the source (fake project_id → not an active project, mirrors the
// delete tail-guard fixture). LeadRouter still needs the donor for tail matching.
DB::table('project_routing_snapshots')->insert([
'snapshot_date' => Carbon::tomorrow('Europe/Moscow')->toDateString(),
'project_id' => 987654, 'tenant_id' => $tenant->id, 'daily_limit' => 5,
'delivery_days_mask' => 127, 'regions' => '{}', 'signal_type' => 'call',
'signal_identifier' => $common, 'sms_senders' => null, 'sms_keyword' => null,
'expected_volume' => 5, 'delivered_count' => 0, 'created_at' => now(),
]);
$savePayloads = [];
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => function ($request) use (&$savePayloads) {
$savePayloads[] = $request->data();
return Http::response(['status' => 'OK', 'message' => '', 'id' => (string) ($request->data()['id'] ?? 0)], 200);
},
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['projects' => []], 200),
]);
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
// Order turned OFF at the supplier (update id != 0, status=false) — stops future ordering.
$off = array_values(array_filter(
$savePayloads,
fn ($p) => (int) ($p['id'] ?? 0) !== 0 && ($p['status'] ?? null) === false
));
expect($off)->toHaveCount(1);
// The local donor record is KEPT (not deleted) so the snapshot tail still routes; marked inactive.
$fresh = SupplierProject::on('pgsql_supplier')->find($sp->id);
expect($fresh)->not->toBeNull();
expect($fresh->inactive_since)->not->toBeNull();
});
@@ -143,62 +143,6 @@ describe('AdminPricingTiersView', () => {
});
});
describe('AdminPricingTiersView — клиентский диапазон (устранение путаницы admin↔кабинет)', () => {
beforeEach(() => {
vi.mocked(adminApi.getPricingTiers).mockResolvedValue({ active: mockTiers, scheduled: {} });
vi.mocked(adminApi.createPricingTiers).mockResolvedValue({ effective_from: '2026-06-01' });
});
it('в активной сетке рядом с шириной показывает накопительный диапазон, как у клиента', async () => {
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
// ширины 100,200 → ступень 2 у клиента = 101–300 (а не «200»)
const cell = wrapper.find('[data-testid="active-range-2"]');
expect(cell.exists()).toBe(true);
expect(cell.text()).toContain('101300');
});
it('ступень 7 в активной сетке — открытый диапазон со знаком +', async () => {
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
// 100+200+400+800+1500+3000 = 6000 → ступень 7 = 6001+
expect(wrapper.find('[data-testid="active-range-7"]').text()).toContain('6001+');
});
it('редактор показывает живой клиентский диапазон рядом с полем ширины', async () => {
const wrapper = mount(AdminPricingTiersView, {
global: {
plugins: [vuetify],
stubs: { VDialog: { template: '<div><slot /></div>' } },
},
});
await new Promise((r) => setTimeout(r, 50));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(wrapper.vm as any).editorOpen = true;
await wrapper.vm.$nextTick();
// defaultEditor: ширины 100,200 → ступень 2 = 101300
const cell = wrapper.find('[data-testid="editor-range-2"]');
expect(cell.exists()).toBe(true);
expect(cell.text()).toContain('101300');
});
it('предпросмотр в редакторе пересчитывается при изменении ширины', async () => {
const wrapper = mount(AdminPricingTiersView, {
global: {
plugins: [vuetify],
stubs: { VDialog: { template: '<div><slot /></div>' } },
},
});
await new Promise((r) => setTimeout(r, 50));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
vm.editorOpen = true;
vm.editor[0].leads_in_tier = 250; // была 100 → ступень 2 сдвигается на 251–450
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="editor-range-2"]').text()).toContain('251450');
});
});
describe('AdminPricingTiersView error handling (Sprint 1 G1)', () => {
afterEach(() => {
vi.clearAllMocks();
-27
View File
@@ -1,27 +0,0 @@
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { createRouter, createMemoryHistory } from 'vue-router';
import AppBottomNav from '../../resources/js/components/layout/AppBottomNav.vue';
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/autopodbor', component: { template: '<div/>' } },
{ path: '/projects', component: { template: '<div/>' } },
{ path: '/deals', component: { template: '<div/>' } },
{ path: '/billing', component: { template: '<div/>' } },
{ path: '/kanban', component: { template: '<div/>' } },
],
});
describe('AppBottomNav', () => {
it('рендерит ровно 5 вкладок Поле/Проекты/Сделки/Биллинг/Канбан по порядку', async () => {
const vuetify = createVuetify();
router.push('/deals');
await router.isReady();
const wrapper = mount(AppBottomNav, { global: { plugins: [vuetify, router] } });
const labels = wrapper.findAll('[data-testid="bottomnav-item"]').map((n) => n.text());
expect(labels).toEqual(['Поле', 'Проекты', 'Сделки', 'Биллинг', 'Канбан']);
});
});
+10 -14
View File
@@ -4,10 +4,6 @@ import { createVuetify } from 'vuetify';
import DealsTable from '../../resources/js/components/deals/DealsTable.vue';
import type { MockDeal } from '../../resources/js/composables/mockDeals';
// ResponsiveTable показывает таблицу только на десктопе (>=1280); эти тесты
// проверяют табличный слой — фиксируем десктоп-ширину.
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1440 });
const vuetify = createVuetify();
const sampleDeals: MockDeal[] = [
@@ -132,27 +128,27 @@ describe('DealsTable', () => {
expect(links[1].props('to')).toMatchObject({ name: 'autopodbor', query: { competitor: '3', project: '5' } });
});
it('«Конкурент» только бренд до первой запятой, без описания', () => {
it('«Конкурент» показывает только бренд, без описания (до первой запятой)', () => {
const deal: MockDeal = {
id: 12,
name: '79000000002',
phone: '79000000002',
id: 13,
name: 'z',
phone: 'z',
statusSlug: 'new',
project: 'X',
manager: { initials: '—', name: '—' },
cost: 0,
receivedMinutesAgo: 1,
competitorId: 4,
competitorName: 'AutoMoney, микрокредитная компания',
sourceIdentifier: '79135396546',
competitorId: 7,
competitorName: 'Сфера займа24, микрокредитная компания',
sourceIdentifier: '79029826282',
sourceSignalType: 'call',
sourceProjectId: 5,
sourceProjectId: 9,
sourceIsActive: true,
sourceExtraCount: 0,
};
const w = mountTable([deal]);
expect(w.text()).toContain('AutoMoney');
expect(w.text()).not.toContain('микрокредитная');
expect(w.text()).toContain('Сфера займа24');
expect(w.text()).not.toContain('микрокредитная компания');
});
it('показывает « · +N» при нескольких источниках проекта', () => {
@@ -1,110 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
import { reactive, ref } from 'vue';
vi.mock('../../resources/js/api/autopodbor');
import FieldWorkspaceScreen from '../../resources/js/views/autopodbor/screens/FieldWorkspaceScreen.vue';
import { useAutopodborStore } from '../../resources/js/stores/autopodborStore';
import { startStudyBatch, autopodborErrorMessage, type FieldCompetitorDto } from '../../resources/js/api/autopodbor';
const vuetify = createVuetify();
function makeNav() {
return { go: vi.fn(), ctx: reactive({ competitorId: null as number | null }), screen: ref('field') };
}
function field(over: Partial<FieldCompetitorDto> = {}): FieldCompetitorDto {
return {
id: 1,
name: 'Окна Комфорт',
description: 'd',
is_federal: false,
relevance_pct: 90,
origin: 'auto',
box: 'field',
site_url: 'okna.ru',
directory_urls: [],
studied_at: null,
study_run_id: null,
search_run_id: 5,
counters: { sources: 2, projects_created: 1, projects_in_work: 1 },
sources: [],
...over,
};
}
function mountWs(nav: ReturnType<typeof makeNav>) {
return mount(FieldWorkspaceScreen, { global: { plugins: [vuetify], provide: { autopodborNav: nav } } });
}
async function mountWithTwo() {
const store = useAutopodborStore();
vi.spyOn(store, 'loadField').mockImplementation(async () => {
store.field = [field({ id: 1, name: 'Первый' }), field({ id: 2, name: 'Второй' })];
});
vi.spyOn(store, 'loadProposalGroups').mockResolvedValue();
const w = mountWs(makeNav());
await new Promise((r) => setTimeout(r, 0));
// Отмечаем обоих конкурентов галочками → появляется плавающий подвал.
const boxes = w.findAll('.ld-pick');
await boxes[0].trigger('change');
await boxes[1].trigger('change');
return { w, store };
}
describe('Конкурентное поле — пакетный сбор источников', () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
});
it('кнопка «Собрать источники (N)» в подвале показывает число выбранных', async () => {
const { w } = await mountWithTwo();
const btn = w.find('[data-testid="batch-collect-sources"]');
expect(btn.exists()).toBe(true);
expect(btn.text()).toContain('2');
expect(btn.text()).toContain('Собрать источники');
});
it('при 422 показывается яркий алерт с topup и max_affordable', async () => {
vi.mocked(startStudyBatch).mockRejectedValue({
response: { status: 422, data: { message: 'no money', topup_rub: '150.00', max_affordable: 5 } },
});
const { w } = await mountWithTwo();
const btn = w.find('[data-testid="batch-collect-sources"]');
await btn.trigger('click');
await new Promise((r) => setTimeout(r, 0));
await w.vm.$nextTick();
const alert = w.find('[data-testid="batch-balance-alert"]');
expect(alert.exists()).toBe(true);
expect(alert.text()).toContain('150');
expect(alert.text()).toContain('5');
});
it('422 без topup_rub («нечего ставить») → балансовый алерт НЕ показывается, тост с message', async () => {
// Ошибка-axios без topup_rub/max_affordable: это не нехватка баланса, а бизнес-правило.
// autopodborErrorMessage авто-замокан всем модулем — вернём конкретный message бэкенда.
vi.mocked(autopodborErrorMessage).mockImplementation(
(e: unknown, fallback: string) => (e as { response?: { data?: { message?: string } } })?.response?.data?.message ?? fallback,
);
vi.mocked(startStudyBatch).mockRejectedValue({
isAxiosError: true,
response: { status: 422, data: { message: 'Нечего ставить в очередь' } },
});
const { w } = await mountWithTwo();
const btn = w.find('[data-testid="batch-collect-sources"]');
await btn.trigger('click');
await new Promise((r) => setTimeout(r, 0));
await w.vm.$nextTick();
// Балансовый алерт НЕ показан.
expect(w.find('[data-testid="batch-balance-alert"]').exists()).toBe(false);
// Показан обычный тост с конкретным message от бэкенда.
const toast = w.find('.ld-toast');
expect(toast.exists()).toBe(true);
expect(toast.text()).toContain('Нечего ставить в очередь');
});
});
@@ -1,36 +0,0 @@
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import ResponsiveTable from '../../resources/js/components/common/ResponsiveTable.vue';
function mountAt(width: number) {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: width });
window.dispatchEvent(new Event('resize'));
const vuetify = createVuetify();
return mount(ResponsiveTable, {
global: { plugins: [vuetify] },
props: { items: [{ id: 1, phone: '+7 900 000' }] },
slots: {
table: '<div data-testid="as-table">TABLE</div>',
card: '<div data-testid="as-card">CARD</div>',
},
});
}
describe('ResponsiveTable', () => {
it('на телефоне (<600) показывает слот card', () => {
const w = mountAt(390);
expect(w.find('[data-testid="as-card"]').exists()).toBe(true);
expect(w.find('[data-testid="as-table"]').exists()).toBe(false);
});
it('на планшете-портрете (768) показывает слот card, а не обрезанную таблицу', () => {
const w = mountAt(768);
expect(w.find('[data-testid="as-card"]').exists()).toBe(true);
expect(w.find('[data-testid="as-table"]').exists()).toBe(false);
});
it('на десктопе (>=1280) показывает слот table', () => {
const w = mountAt(1440);
expect(w.find('[data-testid="as-table"]').exists()).toBe(true);
expect(w.find('[data-testid="as-card"]').exists()).toBe(false);
});
});
@@ -7,10 +7,6 @@ import type { BillingTransaction, TransactionsPage } from '../../resources/js/ap
vi.mock('../../resources/js/api/billing');
// ResponsiveTable показывает таблицу только на десктопе (>=1280); эти тесты
// проверяют табличный слой — фиксируем десктоп-ширину.
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1440 });
const vuetify = createVuetify();
function txn(over: Partial<BillingTransaction> = {}): BillingTransaction {
-47
View File
@@ -1,47 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
/**
* Тесты ленивого загрузчика Яндекс.Метрики (resources/js/plugins/metrika.ts).
* Загрузчик грузится только из роутера при входе в кабинет (см. router/index.ts).
*/
describe('metrika loader', () => {
beforeEach(() => {
document.head.innerHTML = '';
document.body.innerHTML = '';
delete window.ym;
vi.resetModules();
});
it('без meta-тега ничего не грузит и не падает', async () => {
const { ensureLoaded } = await import('../../resources/js/plugins/metrika');
ensureLoaded();
expect(document.querySelector('script[src*="mc.yandex.ru"]')).toBeNull();
});
it('с meta-тегом инжектит tag.js один раз (идемпотентно)', async () => {
const meta = document.createElement('meta');
meta.name = 'metrika-id';
meta.content = '99999999';
document.head.appendChild(meta);
const { ensureLoaded } = await import('../../resources/js/plugins/metrika');
ensureLoaded();
ensureLoaded();
const scripts = document.querySelectorAll('script[src*="mc.yandex.ru/metrika/tag.js"]');
expect(scripts.length).toBe(1);
});
it('hit() вызывает window.ym с id из meta и переданным url', async () => {
const meta = document.createElement('meta');
meta.name = 'metrika-id';
meta.content = '99999999';
document.head.appendChild(meta);
const ym = vi.fn();
window.ym = ym;
const { hit } = await import('../../resources/js/plugins/metrika');
hit('/dashboard');
expect(ym).toHaveBeenCalledWith(99999999, 'hit', '/dashboard');
});
});
-42
View File
@@ -1,42 +0,0 @@
import { describe, expect, it } from 'vitest';
import { tierRangeText } from '../../resources/js/utils/pricingTiers';
/**
* Единый источник истины для «ширина ступени накопительный диапазон».
* Числа ровно те, что клиент видит в кабинете (боевая сетка 08.07.2026):
* ширины 250/500/1000/2500/5000/10000/ 1250, 251750, , 19251+.
*/
const tiers = [
{ leads_in_tier: 250 },
{ leads_in_tier: 500 },
{ leads_in_tier: 1000 },
{ leads_in_tier: 2500 },
{ leads_in_tier: 5000 },
{ leads_in_tier: 10000 },
{ leads_in_tier: null },
];
describe('tierRangeText', () => {
it('первая ступень начинается с 1', () => {
expect(tierRangeText(tiers, 0)).toBe('1250');
});
it('вторая ступень — накопительный диапазон, а не «сама ширина»', () => {
expect(tierRangeText(tiers, 1)).toBe('251750');
});
it('третья ступень продолжает накопление', () => {
expect(tierRangeText(tiers, 2)).toBe('7511750');
});
it('шестая ступень', () => {
// 250+500+1000+2500 = 4250 → пятая 42519250 → шестая 925119250
expect(tierRangeText(tiers, 5)).toBe('925119250');
});
it('последняя ступень (leads_in_tier=null) — открытый диапазон со знаком +', () => {
// 250+500+1000+2500+5000+10000 = 19250 → tier 7 стартует с 19251
expect(tierRangeText(tiers, 6)).toBe('19251+');
});
});
-27
View File
@@ -1,27 +0,0 @@
import { describe, it, expect } from 'vitest';
import { defineComponent } from 'vue';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { useLayoutTier } from '../../resources/js/composables/useLayoutTier';
function tierAtWidth(width: number): string {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: width });
window.dispatchEvent(new Event('resize'));
const vuetify = createVuetify();
let captured = '';
const Harness = defineComponent({
setup() {
const { tier } = useLayoutTier();
captured = tier.value;
return () => null;
},
});
mount(Harness, { global: { plugins: [vuetify] } });
return captured;
}
describe('useLayoutTier', () => {
it('телефон при ширине < 600', () => { expect(tierAtWidth(390)).toBe('phone'); });
it('планшет при 600..1279', () => { expect(tierAtWidth(820)).toBe('tablet'); });
it('десктоп при >= 1280', () => { expect(tierAtWidth(1440)).toBe('desktop'); });
});
@@ -1,49 +0,0 @@
<?php
use App\Models\PricingTier;
use App\Services\Autopodbor\AutopodborBudgetGate;
use Illuminate\Database\Eloquent\Collection;
function tiersFixture(): Collection
{
return new Collection([
new PricingTier([
'tier_no' => 1,
'leads_in_tier' => null,
'price_per_lead_kopecks' => 6000,
'is_active' => true,
]),
]);
}
it('разрешает пакет, когда денег хватает и пакету, и резерву проектов', function () {
$gate = new AutopodborBudgetGate;
$d = $gate->evaluateBatch('1000.00', 0, 10, tiersFixture(), 20, '10.00');
expect($d->allowed)->toBeTrue();
expect($d->maxAffordable)->toBeGreaterThanOrEqual(20);
});
it('запрещает пакет и считает M, когда пакет роняет проекты', function () {
$gate = new AutopodborBudgetGate;
// баланс 650; проектам нужно 10*60=600; свободно 50 → влезает 5 по 10.
$d = $gate->evaluateBatch('650.00', 0, 10, tiersFixture(), 20, '10.00');
expect($d->allowed)->toBeFalse();
expect($d->maxAffordable)->toBe(5);
expect((float) $d->topupRub)->toBeGreaterThan(0.0);
});
it('committed=0 (нет активных проектов) — весь баланс доступен пакету', function () {
$gate = new AutopodborBudgetGate;
$d = $gate->evaluateBatch('100.00', 0, 0, tiersFixture(), 10, '10.00');
expect($d->allowed)->toBeTrue();
expect($d->maxAffordable)->toBe(10);
});
it('price=0 (бесплатный сбор) — пакет разрешён независимо от баланса и резерва', function () {
$gate = new AutopodborBudgetGate;
// Нулевой баланс и активные проекты в резерве — но цена сбора 0, списывать нечего.
$d = $gate->evaluateBatch('0.00', 0, 10, tiersFixture(), 25, '0.00');
expect($d->allowed)->toBeTrue();
expect($d->maxAffordable)->toBe(25);
expect($d->topupRub)->toBe('0.00');
});
@@ -1,65 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\AutopodborRun;
use App\Models\Tenant;
use App\Services\Autopodbor\AutopodborStudyScheduler;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
// Логический номер тенанта (1,2,…) → реальный tenants.id (FK autopodbor_runs.tenant_id).
// Сбрасываем перед каждым тестом: DatabaseTransactions откатывает строки, поэтому
// карта не должна протекать между тестами.
beforeEach(function () {
$GLOBALS['__sched_tenant_map'] = [];
});
function studyRun(int $tenant, string $status, ?string $finishedAt = null, ?string $createdAt = null): AutopodborRun {
if (! isset($GLOBALS['__sched_tenant_map'][$tenant])) {
$GLOBALS['__sched_tenant_map'][$tenant] = Tenant::factory()->create()->id;
}
$tenantId = $GLOBALS['__sched_tenant_map'][$tenant];
$run = AutopodborRun::create([
'tenant_id' => $tenantId, 'kind' => 'study', 'status' => $status,
'competitor_id' => null, 'region_code' => 1, 'params' => [],
'finished_at' => $finishedAt,
]);
if ($createdAt !== null) {
// created_at не в timestamps — задаём явно через query builder (мимо fillable),
// чтобы порядок был детерминирован
AutopodborRun::whereKey($run->id)->update(['created_at' => $createdAt]);
$run->refresh();
}
return $run;
}
it('дорожка занята: есть running → pickNext возвращает null', function () {
studyRun(1, 'running');
studyRun(2, 'queued');
expect(app(AutopodborStudyScheduler::class)->pickNextRunId())->toBeNull();
});
it('чередует тенантов: наименее недавно обслуженный вперёд', function () {
studyRun(1, 'done', finishedAt: now()->toDateTimeString());
studyRun(1, 'queued');
studyRun(2, 'done', finishedAt: now()->subHour()->toDateTimeString());
$t2q = studyRun(2, 'queued');
expect(app(AutopodborStudyScheduler::class)->pickNextRunId())->toBe($t2q->id);
});
it('внутри тенанта берёт самый старый queued', function () {
$old = studyRun(1, 'queued', createdAt: now()->subMinutes(10)->toDateTimeString());
studyRun(1, 'queued', createdAt: now()->toDateTimeString());
expect(app(AutopodborStudyScheduler::class)->pickNextRunId())->toBe($old->id);
});
it('никогда не обслуженный тенант имеет приоритет', function () {
studyRun(1, 'done', finishedAt: now()->subDay()->toDateTimeString());
studyRun(1, 'queued');
$t2q = studyRun(2, 'queued');
expect(app(AutopodborStudyScheduler::class)->pickNextRunId())->toBe($t2q->id);
});
-41
View File
@@ -4,47 +4,6 @@
**Файл схемы:** `schema.sql` (текущая версия — v8.63, консолидированная — разворачивает БД с нуля).
## ⏸ ХВОСТ (2026-07-08) — Автоподбор: RLS-политика межтенантного распорядителя шага 2
Ветка `feat/autopodbor-step2-batch` (worktree `wt-autopodbor-batch`, НЕ на проде). Ревью нашло риск:
`AutopodborStudyScheduler` читает `autopodbor_runs` межтенантно через соединение `pgsql_supplier`
(роль `crm_supplier_worker`), полагаясь на BYPASSRLS. На боевом Managed PG эта привилегия кастомным
ролям **не гарантирована** → на сессии без tenant-контекста политика `tenant_isolation` вернёт 0 строк
и распорядитель зависнет.
**Применено миграцией (`2026_07_08_130000_autopodbor_runs_supplier_read_policy`), тело `schema.sql` — ОТЛОЖЕННЫЙ canon-sync:**
- +RLS policy `autopodbor_runs_supplier_read` ON `autopodbor_runs` `FOR SELECT TO crm_supplier_worker USING (true)`
— явная permissive-политика межтенантного чтения очереди, работает независимо от атрибута BYPASSRLS роли.
Причина: межтенантный распорядитель шага 2 (fair-queue). Запись по-прежнему идёт под tenant-контекстом —
изоляция не ослабляется.
**Структурно:** `autopodbor_runs` +1 RLS-политика. Таблицы/колонки/индексы/функции/триггеры/партиции
БЕЗ изменений. Миграция идемпотентна (`DROP POLICY IF EXISTS` + создание только если роль
`crm_supplier_worker` существует — на dev/тесте её создаёт `db/00_create_roles.sql`, а не миграции,
поэтому на тест-БД политика не создаётся и migrate не падает). Воркстри-фича, НЕ на проде.
## ⏸ ХВОСТ (2026-07-08) — Автоподбор: batch_id + индексы очереди «пакетный сбор источников» шага 2
Ветка `feat/autopodbor-step2-batch` (worktree `wt-autopodbor-batch`, НЕ на проде). Готовим пакетный
сбор источников шага 2 — см. spec
`docs/superpowers/specs/2026-07-08-autopodbor-step2-batch-fair-queue-design.md`. Прогоны одного пакета
(«отправили N сайтов разом») группируются общим `batch_id`, плюс индекс под честную очередь по
`status`+`kind`. Статус `canceled` подготовлен на уровне соглашения (колонка `status` — свободный
`VARCHAR(16)` без CHECK-ограничения в `schema.sql`, поэтому дополнительных ALTER не потребовалось).
**Применено миграцией (в тест-БД `liderra_testing_apk2batch`), тело `schema.sql` — ОТЛОЖЕННЫЙ canon-sync:**
- `autopodbor_runs` +`batch_id UUID NULL` (после `kind`) — миграция
`2026_07_08_120000_add_batch_id_to_autopodbor_runs`.
- +индекс `autopodbor_runs_tenant_batch_idx (tenant_id, batch_id)`.
- +индекс `autopodbor_runs_status_kind_idx (status, kind)` — под честную очередь пакетного сбора.
**Структурно (для будущего canon-sync в тело):** `autopodbor_runs` +1 колонка +2 индекса. Функций/
триггеров/партиций/RLS без изменений. Версия `schema.sql` остаётся v8.63 до отдельного canon-sync
(этот файл — журнал изменений *миграций*, тело `schema.sql` синкает контроллер отдельно, см. §5 п.6/п.10
CLAUDE.md — прямые правки `schema.sql` только с записью сюда).
## v8.63 (2026-07-07) — Автоподбор: колонка dismissed_actualize_keys (мягкое «Отказаться» на актуализации)
Ветка `worktree-avtopodbor`. Кнопка «Отказаться» на карточках «На актуализацию»: клиент отклоняет
-114
View File
@@ -1,114 +0,0 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# Лидерра — ЗАЩИЩЁННЫЙ выкат фронт-сборки портала (public/build).
#
# ЗАЧЕМ. Портал выкатывается ПОЛНОЙ заменой папки public/build. Если собрать
# из УСТАРЕВШЕЙ ветки (где нет чужих свежих фич) и залить — чужая работа
# МОЛЧА затирается. Реальный инцидент 08.07.2026: выкат Яндекс.Метрики,
# собранный из ветки без колонок «Сделок», снёс колонки «Конкурент/Источник».
#
# ЧТО ДЕЛАЕТ. Перед подменой сверяет: новая сборка НЕ должна ПОТЕРЯТЬ ни одной
# критичной фичи, которая есть в живой сборке. Потеряла — ОТКАЗ, боевой не тронут.
# Осознанный откат (когда фичу убираем намеренно) — только с FORCE=1.
#
# ВАЖНО. Маркеры ищем ТОЛЬКО в чанках, на которые ссылается manifest.json
# (активная сборка), а не по всей assets/ — там копятся «осколки» старых
# overlay-выкатов, из-за которых наивная проверка промахивалась.
#
# ИСПОЛЬЗОВАНИЕ (с dev-машины):
# tar czf build.tgz -C app/public/build .
# scp build.tgz liderra:/tmp/build.tgz
# ssh liderra 'sudo bash /var/www/liderra/deploy-build.sh /tmp/build.tgz "ветка/sha/что выкатываем"'
# # только проверить, не выкатывая:
# ssh liderra 'sudo DRY_RUN=1 bash /var/www/liderra/deploy-build.sh /tmp/build.tgz "проверка"'
# # осознанный откат/удаление фичи:
# ssh liderra 'sudo FORCE=1 bash /var/www/liderra/deploy-build.sh /tmp/build.tgz "rollback: ..."'
#
# Откат в любой момент: mv build.bak-guard-<ts> build (см. вывод скрипта).
# ─────────────────────────────────────────────────────────────────────────────
set -euo pipefail
TGZ="${1:?укажи путь к tar.gz со сборкой (содержимое папки build)}"
INFO="${2:-}"
FORCE="${FORCE:-}"
DRY_RUN="${DRY_RUN:-}"
BASE=/var/www/liderra/app/public
LIVE="$BASE/build"
# Критичные маркеры фич: если строка есть в ЖИВОЙ сборке — обязана быть и в новой.
# ➕ Добавляй строку сюда, когда выкатываешь новую пользовательскую фичу,
# чтобы её тоже нельзя было случайно затереть следующим выкатом.
MARKERS=(
"Конкурент" # колонки «Сделок»: Конкурент / Источник
"mc.yandex" # Яндекс.Метрика (счётчик + Вебвизор)
"ym-hide-content" # маскировка ПДн лидов для Метрики (152-ФЗ) — критично
"deal-card" # мобильные/планшетные карточки сделок
"autopodbor" # автоподбор конкурентов
)
echo "▶ Защищённый выкат сборки портала. Инфо: ${INFO:-}"
[ -f "$TGZ" ] || { echo "❌ Нет файла: $TGZ"; exit 2; }
STAGING="$BASE/.build-staging.$$"
rm -rf "$STAGING"; mkdir -p "$STAGING"
tar xzf "$TGZ" -C "$STAGING"
[ -f "$STAGING/manifest.json" ] || { echo "❌ В сборке нет manifest.json — это не билд портала."; rm -rf "$STAGING"; exit 2; }
# Список АКТИВНЫХ чанков (js/css) по manifest — без orphan-осколков прошлых выкатов.
active_files() { # $1 = dir сборки → печатает полные пути к активным чанкам
local dir="$1"
grep -oE 'assets/[A-Za-z0-9_.-]+\.(js|css)' "$dir/manifest.json" | sort -u | sed "s|^|$dir/|"
}
mapfile -t LIVE_FILES < <(active_files "$LIVE" 2>/dev/null || true)
mapfile -t NEW_FILES < <(active_files "$STAGING" 2>/dev/null || true)
hits() { # $1 = маркер, далее список файлов → сколько файлов содержат маркер
local m="$1"; shift
[ "$#" -eq 0 ] && { echo 0; return 0; }
# grep возвращает 1 при «не найдено»; при pipefail+set -e это рвёт скрипт —
# поэтому глушим код через "|| true", а число берём из вывода wc.
local c
c=$(grep -lF -- "$m" "$@" 2>/dev/null | wc -l) || true
echo "${c:-0}"
}
# ── ГЛАВНАЯ ЗАЩИТА: новая сборка не должна терять фичи живой ──────────────────
missing=()
for m in "${MARKERS[@]}"; do
live_n=$(hits "$m" "${LIVE_FILES[@]}")
new_n=$(hits "$m" "${NEW_FILES[@]}")
if [ "$live_n" -gt 0 ] && [ "$new_n" -eq 0 ]; then
missing+=("$m")
fi
done
if [ "${#missing[@]}" -gt 0 ] && [ "$FORCE" != "1" ]; then
echo ""
echo "🛑 ОТКАЗ: новая сборка ТЕРЯЕТ фичи, которые есть на боевом:"
printf ' • %s\n' "${missing[@]}"
echo ""
echo " Похоже, собрано из УСТАРЕВШЕЙ ветки (без чужой работы)."
echo " Пересобери из ветки-надмножества — где есть ВСЁ живое."
echo " Если это ОСОЗНАННЫЙ откат/удаление фичи — повтори с FORCE=1."
rm -rf "$STAGING"
exit 3
fi
if [ "${#missing[@]}" -gt 0 ]; then
echo "⚠ FORCE=1: осознанно теряем фичи (${missing[*]}) — по прямому указанию."
fi
if [ "$DRY_RUN" = "1" ]; then
echo "✅ DRY_RUN: проверка пройдена, все живые фичи на месте. Выкат НЕ выполнялся."
rm -rf "$STAGING"
exit 0
fi
# ── Подмена с бэкапом и отметкой о выкате ────────────────────────────────────
ts="$(date -u +%Y%m%d-%H%M%S)"
mv "$LIVE" "$BASE/build.bak-guard-$ts"
mv "$STAGING" "$LIVE"
appjs=$(grep -oE 'assets/app-[a-zA-Z0-9_-]+\.js' "$LIVE/manifest.json" | head -1)
printf '{"deployed_at":"%s","info":"%s","bundle":"%s","guard_ok":true}\n' \
"$ts" "${INFO//\"/}" "$appjs" > "$LIVE/DEPLOYED.json"
chown -R www-data:www-data "$LIVE"
echo "✅ Выкат ок. Бандл: $appjs. Бэкап прежней сборки: build.bak-guard-$ts"