Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ddad894404 |
+7
-38
@@ -87,6 +87,11 @@ paths = [
|
||||
'''app/composer\.lock''',
|
||||
# Pest-тесты с фиктивными data-фикстурами (не реальные ПДн)
|
||||
'''app/tests/.*\.php''',
|
||||
# Тест-фикстуры (HTML/JSON/CSV) — снятые публичные страницы справочников и
|
||||
# синтетика для парсеров. Напр. карточка 2ГИС с ПУБЛИЧНЫМ бизнес-телефоном
|
||||
# конкурента (опубликован в открытом справочнике), не клиентские ПДн.
|
||||
# Та же категория, что app/tests/*.php выше.
|
||||
'''app/tests/fixtures/.*''',
|
||||
# Database seeders с демо-данными (admin@demo.local + +7916123XXXX демо-телефоны)
|
||||
'''app/database/seeders/.*\.php''',
|
||||
# Database factories — генераторы тестовых фикстур (фейковые телефоны/ИНН,
|
||||
@@ -119,37 +124,7 @@ paths = [
|
||||
'''tools/observer-pii-filter\.test\.mjs''',
|
||||
# Test fixture for the secret-scanner / read-path-deny (M5) — PEM-header marker +
|
||||
# AWS EXAMPLE key, used to verify detection. Not a real key; file deleted in brain split.
|
||||
'''tools/enforce-read-path-deny\.test\.mjs''',
|
||||
# Заглушка ИИ-агента автоподбора (Fake*CompetitorAgent) — синтетические демо-телефоны
|
||||
# конкурентов (Казань 8432…, 8-800), а не реальные ПДн. Та же категория, что
|
||||
# factories/doubles; заменяется реальным движком (binding в AutopodborServiceProvider).
|
||||
'''app/app/Services/Autopodbor/Agent/Fake.*Agent\.php''',
|
||||
# Кликабельные прототипы фичи (демо-телефоны для визуализации макета) — та же категория,
|
||||
# что docs/superpowers/{specs,plans,audits,runbooks}; не реальные ПДн.
|
||||
'''docs/superpowers/prototypes/.*\.html''',
|
||||
# «Косяки глазами пользователя» — audit-internal находки живого прохода портала
|
||||
# с синтетическим демо-телефоном 916-123-45-67 в разделе про нормализацию формата.
|
||||
# Не реальные ПДн; та же категория, что docs/superpowers/{specs,plans,audits}.
|
||||
'''косяки с точки зрения пользователя/.*\.md''',
|
||||
# Исследование ИИ-вебмастера (выгрузка Perplexity) — пример HTTP-заголовка
|
||||
# Idempotency-Key в доке про идемпотентность ловится как generic-api-key. Не ключ.
|
||||
'''вебмастер-исходники-perplexity/.*\.md''',
|
||||
# Тест-фикстуры — захваченные сторонние HTML/JSON (страницы 2ГИС с ИХ
|
||||
# публичными фронтенд-токенами) + синтетические телефоны. Не наши секреты/ПДн.
|
||||
# Та же категория, что app/tests/*.php (уже в allowlist).
|
||||
'''app/tests/fixtures/.*''',
|
||||
# bylo-stanet — демо «было/станет» лендинга (HTML-макеты с демо-телефонами).
|
||||
# Историческая директория; та же категория, что liderra_v8_handoff/concepts.
|
||||
'''bylo-stanet/.*''',
|
||||
# Findings-доки — internal audit-находки живого прохода портала (демо-телефоны
|
||||
# в примерах). Та же категория, что docs/superpowers/{specs,plans,audits,runbooks,prototypes}.
|
||||
'''docs/superpowers/findings/.*\.md''',
|
||||
# Autopodbor preview-экраны — демо-данные конкурентов для UI-разводки макета.
|
||||
# Та же категория, что mockDeals.ts / settings/*.vue.
|
||||
'''app/resources/js/views/autopodbor/screens/.*\.vue''',
|
||||
# .env.example — шаблон переменных окружения (плейсхолдеры/пустые значения, не
|
||||
# реальные секреты). Настоящие .env — в .gitignore и сканируются pre-commit.
|
||||
'''app/\.env\.example'''
|
||||
'''tools/enforce-read-path-deny\.test\.mjs'''
|
||||
]
|
||||
regexTarget = "match"
|
||||
regexes = [
|
||||
@@ -190,9 +165,6 @@ regexes = [
|
||||
'''79991234567''',
|
||||
'''74955551212''',
|
||||
'''89991234567''',
|
||||
# Демо-номер Красноярск (3912-71-33-33) из docblock-примера удалённого
|
||||
# HtmlPhoneScanner (Autopodbor Extract). Историческая находка, в текущем коде нет.
|
||||
'''73912713333''',
|
||||
# Plan 2: ABC-коды городов с тестовым tail "1234567" (PhonePrefixService docs/tests)
|
||||
'''7\d{3}1234567''',
|
||||
'''799912345678''',
|
||||
@@ -200,8 +172,5 @@ regexes = [
|
||||
'''\+79991234567''',
|
||||
'''7 999 123 45 67''',
|
||||
# 12-значные номера-маски для скриншотов и тестов
|
||||
'''[78]\(?[*X]{3}\)?\s?[*X]{3}[\s\-]?[*X]{2}[\s\-]?[*X0-9]{2}''',
|
||||
# Демо-плейсхолдер автоподбора (экран DetailScreen) — Казань 843 + «200-00-00», явный фейк
|
||||
'''7\s?843\s?200[\s\-]?00[\s\-]?00''',
|
||||
'''78432000000'''
|
||||
'''[78]\(?[*X]{3}\)?\s?[*X]{3}[\s\-]?[*X]{2}[\s\-]?[*X0-9]{2}'''
|
||||
]
|
||||
|
||||
@@ -54,10 +54,6 @@ exclude = [
|
||||
# Sample/примерные адреса
|
||||
"^https?://example\\.com",
|
||||
"^https?://example\\.org",
|
||||
# Email-адреса в тексте, склеенные с кавычкой-ёлочкой (напр. «support@liderra.ru»),
|
||||
# lychee ошибочно резолвит как относительный file://-путь. Это не ссылка на файл.
|
||||
"support@liderra\\.ru",
|
||||
"^file://.*@[a-z0-9.-]+\\.(ru|com|org)»?$",
|
||||
# Покойный GitHub-аккаунт CoralMinister (suspended) — все ссылки на него мертвы:
|
||||
# исторические compare/actions-runs в ПИЛОТ.md / handoffs / plans. Бэкап теперь Gitea.
|
||||
"^https?://github\\.com/CoralMinister/",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,16 +10,10 @@ use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Создаёт ежемесячные партиции для всех таблиц в MonthlyPartitionManager::PARTITIONED_TABLES
|
||||
* на N месяцев вперёд от текущей даты и на M месяцев назад.
|
||||
* на N месяцев вперёд от текущей даты.
|
||||
*
|
||||
* Hole #2 (23.05.2026): расширен с 2 бизнес-таблиц до 9 (+ 7 audit-таблиц).
|
||||
*
|
||||
* behind=1 (12.07.2026): нарезалось только «текущий + вперёд», поэтому на свежей базе
|
||||
* партиции за прошлый месяц не было никогда. Между тем строки с датой в прошлом месяце —
|
||||
* штатное явление: тридцатидневное окно списаний (runway) и вставки на стыке месяцев.
|
||||
* На проде не всплывало (партиции копятся с самого начала), но на свежей базе и в CI
|
||||
* INSERT падал «no partition found» в первой половине каждого месяца.
|
||||
*
|
||||
* Замена `pg_partman` на native Windows-стеке (расширение недоступно
|
||||
* без сборки из исходников). Запускается ежесуточно через Windows Task
|
||||
* Scheduler / cron — идемпотентна (проверяет pg_class перед CREATE).
|
||||
@@ -33,23 +27,20 @@ use Illuminate\Support\Carbon;
|
||||
class PartitionsCreateMonths extends Command
|
||||
{
|
||||
/** @var string */
|
||||
protected $signature = 'partitions:create-months
|
||||
{--ahead=2 : Сколько месяцев вперёд создать партиций}
|
||||
{--behind=1 : Сколько месяцев назад создать партиций (окно последних 30 дней, стык месяцев)}';
|
||||
protected $signature = 'partitions:create-months {--ahead=2 : Сколько месяцев вперёд создать партиций}';
|
||||
|
||||
/** @var string */
|
||||
protected $description = 'Создаёт ежемесячные партиции для всех партиционированных таблиц: N месяцев вперёд и M назад (idempotent)';
|
||||
protected $description = 'Создаёт ежемесячные партиции для всех партиционированных таблиц на N месяцев вперёд (idempotent)';
|
||||
|
||||
public function handle(MonthlyPartitionManager $manager): int
|
||||
{
|
||||
$ahead = max(1, (int) $this->option('ahead'));
|
||||
$behind = max(0, (int) $this->option('behind'));
|
||||
$now = Carbon::now()->startOfMonth();
|
||||
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
|
||||
for ($i = -$behind; $i <= $ahead; $i++) {
|
||||
for ($i = 0; $i <= $ahead; $i++) {
|
||||
$monthStart = $now->copy()->addMonths($i);
|
||||
|
||||
foreach (array_keys(MonthlyPartitionManager::PARTITIONED_TABLES) as $table) {
|
||||
@@ -66,7 +57,7 @@ class PartitionsCreateMonths extends Command
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Done: {$created} created, {$skipped} skipped (ahead={$ahead}, behind={$behind}).");
|
||||
$this->info("Done: {$created} created, {$skipped} skipped (ahead={$ahead}).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\SupplierProject;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Разовая починка после прод-инцидента 11–12.07.2026.
|
||||
*
|
||||
* Кабинет поставщика дописывает метку канала «B<n>_» к имени строки только при её
|
||||
* СОЗДАНИИ; при обновлении он сохраняет имя ровно как прислали. Наш ночной робот слал
|
||||
* голый ключ → каждый прогон стирал метку. Последствия: (1) итоговая проверка заказа
|
||||
* переставала узнавать строку и слала ложное «нет заказа»; (2) лид от такой строки
|
||||
* приходил с project без метки → webhook не мог определить канал и писал
|
||||
* platform=DIRECT вместо B1/B2/B3 (потеря атрибуции канала).
|
||||
*
|
||||
* Корень закрыт в SupplierPortalClient::toPayload (на update имя уходит с меткой) —
|
||||
* эта команда возвращает метку тем строкам, у которых её уже стёрли.
|
||||
*
|
||||
* По умолчанию — сухой прогон. Реальная запись в кабинет — только с --apply.
|
||||
*/
|
||||
final class RepairSupplierProjectNamesCommand extends Command
|
||||
{
|
||||
private const DB_CONNECTION = 'pgsql_supplier';
|
||||
|
||||
/** Канал у поставщика — служебное поле src (rt=Ростелеком, bl=Билайн, mt=МТС). */
|
||||
private const SRC_TO_PLATFORM = ['rt' => 'B1', 'bl' => 'B2', 'mt' => 'B3'];
|
||||
|
||||
protected $signature = 'supplier:repair-project-names
|
||||
{--apply : Записать исправленные имена в кабинет (без флага — только показать)}
|
||||
{--only-active : Чинить лишь строки, включённые у нас (выключенные лидов не носят)}';
|
||||
|
||||
protected $description = 'Вернуть метку канала B1_/B2_/B3_ в имена наших строк у поставщика';
|
||||
|
||||
public function handle(SupplierPortalClient $client): int
|
||||
{
|
||||
$apply = (bool) $this->option('apply');
|
||||
|
||||
$query = SupplierProject::on(self::DB_CONNECTION)
|
||||
->whereNotNull('supplier_external_id');
|
||||
|
||||
if ($this->option('only-active')) {
|
||||
$query->whereNull('inactive_since');
|
||||
}
|
||||
|
||||
/** @var array<string, true> $ourIds */
|
||||
$ourIds = $query->pluck('supplier_external_id')
|
||||
->mapWithKeys(fn ($id): array => [(string) $id => true])
|
||||
->all();
|
||||
|
||||
$repaired = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($client->listProjects() as $row) {
|
||||
$externalId = (string) ($row['id'] ?? '');
|
||||
if (! isset($ourIds[$externalId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$platform = self::SRC_TO_PLATFORM[(string) ($row['src'] ?? '')] ?? null;
|
||||
if ($platform === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = (string) ($row['name'] ?? '');
|
||||
if (preg_match('/^B[123]_/', $name) === 1) {
|
||||
continue; // Метка на месте — не трогаем.
|
||||
}
|
||||
|
||||
$newName = $platform.'_'.(string) ($row['content'] ?? '');
|
||||
$this->line(sprintf('%s %s «%s» → «%s»', $externalId, $platform, $name, $newName));
|
||||
|
||||
if (! $apply) {
|
||||
$repaired++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$client->renameProject($row, $newName);
|
||||
$repaired++;
|
||||
} catch (Throwable $e) {
|
||||
$failed++;
|
||||
$this->error(sprintf(' не удалось: %s', $e->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info($apply
|
||||
? sprintf('Починено строк: %d, ошибок: %d.', $repaired, $failed)
|
||||
: sprintf('Сухой прогон: строк с потерянной меткой — %d. Записать: --apply.', $repaired));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Mail\SupplierDeadlineWarningMail;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Сторож дедлайна поставщика: если робот SyncSupplierProjectsJob к порогу не закончил —
|
||||
* письмо. yellow=20:00, red=20:40 (МСК). Ловит и медленную работу, и зависание, и
|
||||
* несработавший планировщик (за сегодня нет завершённого запуска).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-07-09-supplier-order-verification-and-deadline-watch-design.md §5
|
||||
*/
|
||||
final class SupplierDeadlineWatchCommand extends Command
|
||||
{
|
||||
protected $signature = 'supplier:deadline-watch {level : yellow|red}';
|
||||
|
||||
protected $description = 'Проверить, успел ли робот-заказчик до дедлайна поставщика (21:00 МСК)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
/** @var 'yellow'|'red' $level */
|
||||
$level = $this->argument('level') === 'red' ? 'red' : 'yellow';
|
||||
$today = Carbon::today('Europe/Moscow')->toDateString();
|
||||
|
||||
$finished = DB::connection('pgsql_supplier')->table('supplier_sync_runs')
|
||||
->whereRaw("(started_at AT TIME ZONE 'Europe/Moscow')::date = ?", [$today])
|
||||
->whereNotNull('finished_at')
|
||||
->where('status', '!=', 'aborted')
|
||||
->exists();
|
||||
|
||||
if ($finished) {
|
||||
$this->info('OK: робот сегодня завершил заказ.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$reason = 'За сегодня нет завершённого (не aborted) запуска SyncSupplierProjectsJob с finished_at.';
|
||||
Mail::to((string) config('services.supplier.alert_email'))
|
||||
->queue(new SupplierDeadlineWarningMail($level, $reason));
|
||||
|
||||
$this->warn("{$level}: робот не закончил — письмо отправлено.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,15 +192,8 @@ 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 {
|
||||
$runId = $this->recordRunSummary(
|
||||
$this->recordRunSummary(
|
||||
startedAt: $startedAt,
|
||||
groupsTotal: count($groups),
|
||||
syncedOk: $syncedOk,
|
||||
@@ -213,111 +202,9 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
failed: $failed,
|
||||
aborted: $aborted,
|
||||
);
|
||||
|
||||
// Итоговая проверка: после «готово» перечитать живой кабинет и сверить.
|
||||
// Идёт всегда — и при штатном финише, и при обрыве. При auth-сбое (кабинет
|
||||
// недоступен) сам джоб зафиксирует unable_to_verify.
|
||||
VerifySupplierOrderJob::dispatch(1, $runId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -330,7 +217,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
int $deferred,
|
||||
int $failed,
|
||||
bool $aborted,
|
||||
): int {
|
||||
): void {
|
||||
if ($aborted) {
|
||||
$status = 'aborted';
|
||||
} elseif ($failed > 0 && $syncedOk === 0) {
|
||||
@@ -341,7 +228,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
$status = 'ok';
|
||||
}
|
||||
|
||||
$id = DB::connection(self::DB_CONNECTION)->table('supplier_sync_runs')->insertGetId([
|
||||
DB::connection(self::DB_CONNECTION)->table('supplier_sync_runs')->insert([
|
||||
'started_at' => $startedAt,
|
||||
'finished_at' => now(),
|
||||
'groups_total' => $groupsTotal,
|
||||
@@ -352,8 +239,6 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
'status' => $status,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\Supplier;
|
||||
|
||||
use App\Mail\SupplierOrderMismatchMail;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Services\Supplier\SupplierOrderPlan;
|
||||
use App\Services\Supplier\SupplierOrderVerifier;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Итоговая проверка заказа: живой кабинет поставщика vs задуманное по формуле.
|
||||
* Диспатчится из SyncSupplierProjectsJob в finally. При расхождении на attempt=1
|
||||
* переочередь через 90 сек (attempt=2) — гасит транзиентный лаг применения.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-07-09-supplier-order-verification-and-deadline-watch-design.md §4
|
||||
*/
|
||||
final class VerifySupplierOrderJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public const DB_CONNECTION = 'pgsql_supplier';
|
||||
|
||||
public const RECHECK_DELAY_SECONDS = 90;
|
||||
|
||||
public function __construct(
|
||||
public readonly int $attempt = 1,
|
||||
public readonly ?int $syncRunId = null,
|
||||
) {}
|
||||
|
||||
public function handle(?SupplierPortalClient $client = null): void
|
||||
{
|
||||
$client ??= app(SupplierPortalClient::class);
|
||||
$targetDate = Carbon::tomorrow('Europe/Moscow');
|
||||
|
||||
// 1. Задуманное по формуле.
|
||||
$eligible = (new SyncSupplierProjectsJob)->collectEligibleProjects();
|
||||
$plan = SupplierOrderPlan::build($eligible, $targetDate);
|
||||
$intended = $plan['intended'];
|
||||
|
||||
// Ключи активных по формуле — для (а) исключения из shouldBeOff, (б) ничего более.
|
||||
$intendedKeys = [];
|
||||
foreach ($intended as $r) {
|
||||
$intendedKeys[$r['signal_type'].'|'.$r['identifier'].'|'.$r['platform']] = true;
|
||||
}
|
||||
|
||||
// Наши external_id в кабинете — признак «наша строка» (tag НЕ годится: робот
|
||||
// шлёт tag=регион/«РФ», не маркер). Сверка живого идёт только по нашим строкам.
|
||||
$ourExternalIds = SupplierProject::on(self::DB_CONNECTION)
|
||||
->whereNotNull('supplier_external_id')
|
||||
->pluck('supplier_external_id')
|
||||
->map(fn ($id) => (string) $id)
|
||||
->all();
|
||||
|
||||
// 2. shouldBeOff — наши выключенные supplier_projects, КРОМЕ ключей,
|
||||
// которые сейчас активны по формуле (иначе ложный should_be_off на
|
||||
// реактивированной группе с зависшим inactive_since).
|
||||
$shouldBeOff = SupplierProject::on(self::DB_CONNECTION)
|
||||
->whereNotNull('inactive_since')
|
||||
->get(['signal_type', 'unique_key', 'platform'])
|
||||
->map(fn (SupplierProject $sp) => [
|
||||
'signal_type' => (string) $sp->signal_type,
|
||||
'identifier' => (string) $sp->unique_key,
|
||||
'platform' => (string) $sp->platform,
|
||||
])
|
||||
->reject(fn (array $r): bool => isset($intendedKeys[$r['signal_type'].'|'.$r['identifier'].'|'.$r['platform']]))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
// 3. Живой кабинет (только наши строки — по supplier_external_id).
|
||||
try {
|
||||
$live = $this->normalizeLive($client->listProjects(), $ourExternalIds);
|
||||
} catch (Throwable $e) {
|
||||
$this->record('unable_to_verify', count($intended), 0, 0, ['error' => $e->getMessage()]);
|
||||
Mail::to((string) config('services.supplier.alert_email'))
|
||||
->queue(new SupplierOrderMismatchMail([[
|
||||
'kind' => 'unable_to_verify', 'signal_type' => '-', 'identifier' => '-',
|
||||
'platform' => '-', 'expected' => 'чтение кабинета', 'actual' => $e->getMessage(),
|
||||
]]));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Сверка.
|
||||
$mismatches = SupplierOrderVerifier::diff($intended, $shouldBeOff, $live);
|
||||
|
||||
if ($mismatches !== [] && $this->attempt < 2) {
|
||||
// Транзиентный лаг применения — перепроверить через 90 сек.
|
||||
self::dispatch(2, $this->syncRunId)->delay(now()->addSeconds(self::RECHECK_DELAY_SECONDS));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$status = $mismatches === [] ? 'ok' : 'mismatch';
|
||||
$this->record($status, count($intended), count($live), count($mismatches), $mismatches);
|
||||
|
||||
if ($mismatches !== []) {
|
||||
Mail::to((string) config('services.supplier.alert_email'))
|
||||
->queue(new SupplierOrderMismatchMail($mismatches));
|
||||
}
|
||||
}
|
||||
|
||||
/** Канал у поставщика — служебное поле src, не имя. rt=Ростелеком, bl=Билайн, mt=МТС. */
|
||||
private const SRC_TO_PLATFORM = ['rt' => 'B1', 'bl' => 'B2', 'mt' => 'B3'];
|
||||
|
||||
/**
|
||||
* Портальные строки → формат SupplierOrderVerifier. «Наши» = те, чей портальный id
|
||||
* совпадает с нашим supplier_external_id (tag НЕ используется — робот шлёт регион).
|
||||
*
|
||||
* Площадка берётся из `src`, а НЕ из префикса имени: метку «B<n>_» портал ставит
|
||||
* только при создании строки и теряет при обновлении (прод-инцидент 11–12.07.2026 —
|
||||
* строки в кабинете были, но проверка объявляла их missing). `src` — то же поле,
|
||||
* по которому матчит saveProjectMultiFlag; оно от имени не зависит.
|
||||
*
|
||||
* @param array<int, array<string,mixed>> $rows
|
||||
* @param list<string> $ourExternalIds
|
||||
* @return list<array{signal_type:string,identifier:string,platform:string,limit:int,on:bool}>
|
||||
*/
|
||||
private function normalizeLive(array $rows, array $ourExternalIds): array
|
||||
{
|
||||
$ours = array_flip($ourExternalIds);
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
if (! isset($ours[(string) ($row['id'] ?? '')])) {
|
||||
continue;
|
||||
}
|
||||
$platform = self::SRC_TO_PLATFORM[(string) ($row['src'] ?? '')] ?? null;
|
||||
if ($platform === null) {
|
||||
continue;
|
||||
}
|
||||
$signalType = match ($row['type'] ?? null) {
|
||||
'hosts' => 'site', 'calls' => 'call', 'sms' => 'sms', default => null,
|
||||
};
|
||||
if ($signalType === null) {
|
||||
continue;
|
||||
}
|
||||
$out[] = [
|
||||
'signal_type' => $signalType,
|
||||
'identifier' => (string) ($row['content'] ?? ''),
|
||||
'platform' => $platform,
|
||||
'limit' => (int) ($row['lim'] ?? 0),
|
||||
'on' => (string) ($row['status'] ?? '') === '1',
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string,mixed>>|array{error:string} $details
|
||||
*/
|
||||
private function record(string $status, int $intendedRows, int $liveRows, int $mismatchCount, array $details): void
|
||||
{
|
||||
DB::connection(self::DB_CONNECTION)->table('supplier_order_checks')->insert([
|
||||
'checked_at' => now(),
|
||||
'sync_run_id' => $this->syncRunId,
|
||||
'intended_rows' => $intendedRows,
|
||||
'live_rows' => $liveRows,
|
||||
'mismatch_count' => $mismatchCount,
|
||||
'status' => $status,
|
||||
'details' => json_encode($details, JSON_UNESCAPED_UNICODE),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
|
||||
/**
|
||||
* Сторож времени: робот-заказчик не закончил к порогу (yellow=20:00 / red=20:40).
|
||||
*/
|
||||
final class SupplierDeadlineWarningMail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/** @param 'yellow'|'red' $level */
|
||||
public function __construct(
|
||||
public readonly string $level,
|
||||
public readonly string $reason,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$tag = $this->level === 'red' ? 'КРАСНЫЙ' : 'ЖЁЛТЫЙ';
|
||||
|
||||
return new Envelope(subject: "[Лидерра {$tag}] Робот-заказчик не успевает к 21:00");
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
text: 'emails.supplier_deadline_warning_text',
|
||||
with: [
|
||||
'level' => $this->level,
|
||||
'reason' => $this->reason,
|
||||
'now' => now()->timezone('Europe/Moscow')->toIso8601String(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
|
||||
/**
|
||||
* Итоговая проверка нашла расхождения заказа с живым кабинетом поставщика.
|
||||
*
|
||||
* @param list<array{kind:string,signal_type:string,identifier:string,platform:string,expected:mixed,actual:mixed}> $mismatches
|
||||
*/
|
||||
final class SupplierOrderMismatchMail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(public readonly array $mismatches) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$n = count($this->mismatches);
|
||||
|
||||
return new Envelope(subject: "[Лидерра] Заказ у поставщика: расхождений {$n}");
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
text: 'emails.supplier_order_mismatch_text',
|
||||
with: [
|
||||
'mismatches' => $this->mismatches,
|
||||
'now' => now()->timezone('Europe/Moscow')->toIso8601String(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ class AutopodborRun extends Model
|
||||
'region_code',
|
||||
'params',
|
||||
'competitor_id',
|
||||
'batch_id',
|
||||
'price_rub_charged',
|
||||
'balance_transaction_id',
|
||||
'error_code',
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ use App\Services\Autopodbor\Agent\CompetitorAgent;
|
||||
use App\Services\Autopodbor\Agent\CompetitorCardEnricher;
|
||||
use App\Services\Autopodbor\Agent\Extract\DirectoryParser;
|
||||
use App\Services\Autopodbor\Agent\Extract\HtmlTwoGisReader;
|
||||
use App\Services\Autopodbor\Agent\Extract\PhoneValidityGate;
|
||||
use App\Services\Autopodbor\Agent\Extract\TwoGisJsonReader;
|
||||
use App\Services\Autopodbor\Agent\Extract\TwoGisReader;
|
||||
use App\Services\Autopodbor\Agent\FakeCompetitorAgent;
|
||||
@@ -34,7 +33,6 @@ use App\Services\Autopodbor\Agent\Fetch\XfetchDirectoryFetcher;
|
||||
use App\Services\Autopodbor\Agent\Fetch\YandexListSource;
|
||||
use App\Services\Autopodbor\Agent\FindCompetitorsAssembler;
|
||||
use App\Services\Autopodbor\Agent\LiveFindCompetitors;
|
||||
use App\Services\Autopodbor\Agent\Phone\DaDataValidityLayer;
|
||||
use App\Services\Autopodbor\Agent\RealCompetitorAgent;
|
||||
use App\Services\Autopodbor\Agent\Similarity\AitunnelEmbedder;
|
||||
use App\Services\Autopodbor\Agent\Similarity\EmbeddingRelevance;
|
||||
@@ -50,7 +48,6 @@ use App\Services\Autopodbor\Agent\Study\YandexPhoneReverseExpander;
|
||||
use App\Services\Autopodbor\AutopodborDedup;
|
||||
use App\Services\Autopodbor\AutopodborNormalizer;
|
||||
use App\Services\Autopodbor\RunProgressChannel;
|
||||
use App\Services\RossvyazPrefixLookup;
|
||||
use Illuminate\Http\Client\Factory as HttpFactory;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
@@ -97,20 +94,10 @@ class AutopodborServiceProvider extends ServiceProvider
|
||||
directoryFetcher: new XfetchDirectoryFetcher($pages),
|
||||
);
|
||||
|
||||
// Сторож валидности телефонов: слой 1 Россвязь (всегда, бесплатно) + слой 2
|
||||
// ДаДата за флагом (платно, снимает подтверждённый мусор среди прошедших реестр).
|
||||
// Общий на оба пути шага 2 (обычный и глубокий через SiteOpener).
|
||||
$validityGate = new PhoneValidityGate(
|
||||
new RossvyazPrefixLookup,
|
||||
config('autopodbor.phone_dadata_enabled')
|
||||
? $app->make(DaDataValidityLayer::class)
|
||||
: null,
|
||||
);
|
||||
|
||||
$deep = config('autopodbor.deep_study')
|
||||
? new DeepStudyCollector(
|
||||
xfetch: $pages,
|
||||
opener: new SiteOpener($siteFetcher, validityGate: $validityGate),
|
||||
opener: new SiteOpener($siteFetcher),
|
||||
reverse: config('autopodbor.phone_reverse')
|
||||
? $this->buildPhoneReverse($pages, $reader)
|
||||
: null,
|
||||
@@ -128,7 +115,6 @@ class AutopodborServiceProvider extends ServiceProvider
|
||||
return new RealCompetitorAgent(
|
||||
$fetcher,
|
||||
new FakeCompetitorAgent,
|
||||
validityGate: $validityGate,
|
||||
liveFind: $liveFind,
|
||||
deep: $deep,
|
||||
progress: $app->make(RunProgressChannel::class),
|
||||
|
||||
@@ -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,90 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Extract;
|
||||
|
||||
use App\Services\RossvyazPrefixLookup;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Сторож валидности телефонов, пойманных при «вскрытии» кода страницы конкурента.
|
||||
*
|
||||
* Экстрактор ({@see HtmlPhoneScanner}) ловит любую последовательность из 11 цифр,
|
||||
* начинающуюся на 7/8 и сгруппированную «по-телефонному» — но НЕ проверяет, что это
|
||||
* реальный российский номер. Со страниц-агрегаторов (отзовики, каталоги) так пролезает
|
||||
* мусор: ID/коды/чужие блоки цифр с несуществующими кодами города (227, 303, 453, 613, 761…).
|
||||
* Этот гейт отсекает такой мусор ДО показа клиенту (spec §12.4-подбор).
|
||||
*
|
||||
* Слой 1 (здесь, бесплатно): реестр нумерации Россвязи (`phone_ranges`, 453k диапазонов на
|
||||
* проде). Полный номер 7XXXXXXXXXX, которого нет ни в одном диапазоне → выкидываем молча.
|
||||
* Слой 2 (ДаДата, платно, за флагом) — {@see DaDataValidityLayer}, добивает сомнительные.
|
||||
*
|
||||
* Защита: если реестр пуст (dev/тесты или сбой загрузки) — НЕ режем ничего (fail-open),
|
||||
* чтобы незагруженный справочник не выкосил все номера. Короткие/локальные номера
|
||||
* (kind=uncertain, код города не определён) не трогаем — их нельзя сверить с реестром.
|
||||
*/
|
||||
final class PhoneValidityGate
|
||||
{
|
||||
private ?bool $registryHasData = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly RossvyazPrefixLookup $rossvyaz = new RossvyazPrefixLookup,
|
||||
private readonly ?PhoneValidityLayer $dadata = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param list<array{number:string,kind:string,label:string,url:?string,office:?string,tracker:bool}> $candidates
|
||||
* @return list<array{number:string,kind:string,label:string,url:?string,office:?string,tracker:bool}>
|
||||
*/
|
||||
public function filter(array $candidates): array
|
||||
{
|
||||
// Реестр не загружен → fail-open: не режем ничего.
|
||||
if (! $this->registryHasData()) {
|
||||
return array_values($candidates);
|
||||
}
|
||||
|
||||
return array_values(array_filter($candidates, fn (array $c): bool => $this->keep((string) $c['number'])));
|
||||
}
|
||||
|
||||
private function keep(string $number): bool
|
||||
{
|
||||
// Сверяем только полные российские номера. Короткие/локальные (7-значные и т.п.)
|
||||
// нельзя проверить по реестру — не теряем.
|
||||
if (preg_match('/^7\d{10}$/', $number) !== 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Слой 1 — Россвязь (авторитетный полный план нумерации): номера нет ни в одном
|
||||
// диапазоне → это не настоящий российский номер → мусор, выкидываем молча (бесплатно).
|
||||
if ($this->rossvyaz->find($number) === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Слой 2 — ДаДата (платно, за флагом): перепроверяет уже прошедший реестр номер и
|
||||
// снимает подтверждённый мусор/иностранца (qc 2/7). Платим только за реальных выживших.
|
||||
if ($this->dadata !== null) {
|
||||
return $this->dadata->isValid($number);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function registryHasData(): bool
|
||||
{
|
||||
if ($this->registryHasData !== null) {
|
||||
return $this->registryHasData;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->registryHasData = DB::connection(RossvyazPrefixLookup::CONNECTION)
|
||||
->table('phone_ranges')
|
||||
->limit(1)
|
||||
->exists();
|
||||
} catch (\Throwable) {
|
||||
// Сбой БД/соединения при проверке реестра → fail-open (не фильтруем ничего).
|
||||
// Иначе внешний catch в SiteOpener::open() принял бы это за «нет номеров».
|
||||
return $this->registryHasData = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Extract;
|
||||
|
||||
use App\Services\Autopodbor\Agent\Phone\DaDataValidityLayer;
|
||||
|
||||
/**
|
||||
* Второй слой проверки валидности телефона (после реестра Россвязи).
|
||||
* Реализация — {@see DaDataValidityLayer} (платно, за флагом).
|
||||
*/
|
||||
interface PhoneValidityLayer
|
||||
{
|
||||
/** true — номер годен (оставляем); false — мусор/иностранец (выкидываем). */
|
||||
public function isValid(string $number): bool;
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Phone;
|
||||
|
||||
use App\Services\Autopodbor\Agent\Extract\PhoneValidityLayer;
|
||||
use App\Services\DaData\DaDataBudgetGuard;
|
||||
use App\Services\DaData\DaDataException;
|
||||
use App\Services\DaData\DaDataPhoneClient;
|
||||
use Illuminate\Contracts\Cache\Repository as CacheRepository;
|
||||
|
||||
/**
|
||||
* Второй слой валидности телефона (после реестра Россвязи) через DaData clean/phone.
|
||||
* Платный, поэтому подключается только за флагом (`autopodbor.phone_dadata_enabled`) и
|
||||
* под дневным бюджетом {@see DaDataBudgetGuard} — тот же кошелёк, что резолв региона лида.
|
||||
*
|
||||
* Снимает ТОЛЬКО подтверждённый мусор: qc 2 (не телефон) и qc 7 (иностранный номер).
|
||||
* Всё остальное (qc 0/3/1, исчерпанный бюджет, сбой сети) — оставляет: номер уже прошёл
|
||||
* авторитетный реестр Россвязи, наказывать его за нашу неуверенность/сбой нельзя.
|
||||
*
|
||||
* Вердикт кэшируется по sha256(номер) — не платим дважды за один номер между прогонами.
|
||||
* В кэше/ключе только хэш, без сырого номера (та же ПДн-конвенция, что LeadRegionResolver §7.3).
|
||||
*/
|
||||
final class DaDataValidityLayer implements PhoneValidityLayer
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DaDataPhoneClient $client,
|
||||
private readonly DaDataBudgetGuard $budget,
|
||||
private readonly CacheRepository $cache,
|
||||
) {}
|
||||
|
||||
public function isValid(string $number): bool
|
||||
{
|
||||
$key = 'autopodbor:phone-valid:'.hash('sha256', $number);
|
||||
|
||||
$cached = $this->cache->get($key);
|
||||
if (is_bool($cached)) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
// Бюджет исчерпан — не тратим и не режем (номер уже прошёл реестр).
|
||||
if (! $this->budget->canSpend()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->client->cleanPhone($number);
|
||||
$this->budget->recordSpend((int) config('services.dadata.call_cost_kopecks', 60));
|
||||
} catch (DaDataException) {
|
||||
return true; // сбой ДаДаты — не роняем и не режем
|
||||
}
|
||||
|
||||
$valid = ! in_array($response->qc, [2, 7], true);
|
||||
|
||||
$ttlDays = max(1, (int) config('services.dadata.cache_ttl_days', 30));
|
||||
$this->cache->put($key, $valid, now()->addDays($ttlDays));
|
||||
|
||||
return $valid;
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ use App\Services\Autopodbor\Agent\Dto\ResolveByNameResult;
|
||||
use App\Services\Autopodbor\Agent\Dto\StudyCompetitorRequest;
|
||||
use App\Services\Autopodbor\Agent\Dto\StudyCompetitorResult;
|
||||
use App\Services\Autopodbor\Agent\Extract\CandidateBuilder;
|
||||
use App\Services\Autopodbor\Agent\Extract\PhoneValidityGate;
|
||||
use App\Services\Autopodbor\Agent\Extract\SourceAggregator;
|
||||
use App\Services\Autopodbor\Agent\Fetch\Fetcher;
|
||||
use App\Services\Autopodbor\Agent\Study\CompetitorFingerprint;
|
||||
@@ -27,7 +26,6 @@ final class RealCompetitorAgent implements CompetitorAgent
|
||||
private Fetcher $fetcher,
|
||||
private CompetitorAgent $fallback, // для resolve, и для find пока не подключён живой
|
||||
private CandidateBuilder $builder = new CandidateBuilder,
|
||||
private PhoneValidityGate $validityGate = new PhoneValidityGate,
|
||||
private SourceAggregator $aggregator = new SourceAggregator,
|
||||
private AutopodborNormalizer $norm = new AutopodborNormalizer,
|
||||
private ?LiveFindCompetitors $liveFind = null, // живой поиск шага 1 (если подключён за флагом)
|
||||
@@ -110,8 +108,6 @@ final class RealCompetitorAgent implements CompetitorAgent
|
||||
// конкурента (только если на странице нет полных номеров; см. RegionAreaCode).
|
||||
$areaCode = RegionAreaCode::forSubject($r->regionCode);
|
||||
$candidates = $this->builder->build($sites, $cards, $areaCode);
|
||||
// Отсекаем мусор: пойманные «номера» с несуществующими кодами (не в реестре Россвязи).
|
||||
$candidates = $this->validityGate->filter($candidates);
|
||||
$collected = $this->aggregator->aggregate($candidates);
|
||||
|
||||
// 3. Маппинг в существующий контракт
|
||||
|
||||
@@ -5,20 +5,17 @@ declare(strict_types=1);
|
||||
namespace App\Services\Autopodbor\Agent\Study;
|
||||
|
||||
use App\Services\Autopodbor\Agent\Extract\CandidateBuilder;
|
||||
use App\Services\Autopodbor\Agent\Extract\PhoneValidityGate;
|
||||
use App\Services\Autopodbor\Agent\Fetch\Fetcher;
|
||||
|
||||
/**
|
||||
* Вскрытие одного сайта фирмы: Fetcher (curl+Playwright) → CandidateBuilder (kind номеров) →
|
||||
* сторож валидности (отсев мусора по реестру Россвязи) → список кандидатов, флаг коллтрекинга,
|
||||
* HTML в нижнем регистре и «код»-цифры (для якорей).
|
||||
* список кандидатов, флаг коллтрекинга, HTML в нижнем регистре и «код»-цифры (для якорей).
|
||||
*/
|
||||
final class SiteOpener
|
||||
{
|
||||
public function __construct(
|
||||
private Fetcher $fetcher,
|
||||
private CandidateBuilder $builder = new CandidateBuilder,
|
||||
private PhoneValidityGate $validityGate = new PhoneValidityGate,
|
||||
) {}
|
||||
|
||||
public function open(string $domain, ?string $areaCode): OpenedSite
|
||||
@@ -26,8 +23,7 @@ final class SiteOpener
|
||||
$url = 'https://'.$this->idn($domain).'/';
|
||||
try {
|
||||
$site = $this->fetcher->site($url);
|
||||
// Отсекаем мусор (номера с несуществующими кодами) ДО якорей/разворота по телефонам.
|
||||
$cands = $this->validityGate->filter($this->builder->build([$site], [], $areaCode));
|
||||
$cands = $this->builder->build([$site], [], $areaCode);
|
||||
$codeDigits = [];
|
||||
foreach ($cands as $c) {
|
||||
if ($c['kind'] === 'code' || $c['kind'] === 'contacts') {
|
||||
|
||||
@@ -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
@@ -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 найденного
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Supplier;
|
||||
|
||||
use App\Models\Project;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* «Задуманный заказ» на targetDate — та же группировка и формула, что у робота
|
||||
* SyncSupplierProjectsJob (DRY через SupplierProjectGrouping + SupplierQuotaAllocator).
|
||||
*
|
||||
* Возвращает ['intended' => list<row>], где row = ['signal_type','identifier','platform','limit'],
|
||||
* только доли ≥1 (как distributeForPlatform).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-07-09-supplier-order-verification-and-deadline-watch-design.md §3, §4.2
|
||||
*/
|
||||
final class SupplierOrderPlan
|
||||
{
|
||||
/**
|
||||
* @param Collection<int, Project> $eligibleProjects проекты со slepok-полями (daily_limit_target/delivery_days_mask/regions)
|
||||
* @return array{intended: list<array{signal_type:string,identifier:string,platform:string,limit:int}>}
|
||||
*/
|
||||
public static function build(Collection $eligibleProjects, Carbon $targetDate): array
|
||||
{
|
||||
$targetWeekday = $targetDate->copy()->timezone('Europe/Moscow')->isoWeekday();
|
||||
|
||||
// Группировка (signal_type|identifier) — как в SyncSupplierProjectsJob.
|
||||
/** @var array<string, array{signal_type:string, identifier:string, platforms:list<string>, limits:list<int>}> $groups */
|
||||
$groups = [];
|
||||
|
||||
foreach ($eligibleProjects as $project) {
|
||||
$platforms = SupplierProjectGrouping::resolvePlatforms($project);
|
||||
if ($platforms === []) {
|
||||
continue;
|
||||
}
|
||||
// eligible-today (маска дня на targetDate).
|
||||
if (((int) $project->delivery_days_mask & (1 << ($targetWeekday - 1))) === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$identifier = SupplierProjectGrouping::buildUniqueKeyAgnostic($project);
|
||||
$key = $project->signal_type.'|'.$identifier;
|
||||
|
||||
if (! isset($groups[$key])) {
|
||||
$groups[$key] = [
|
||||
'signal_type' => (string) $project->signal_type,
|
||||
'identifier' => $identifier,
|
||||
'platforms' => $platforms,
|
||||
'limits' => [],
|
||||
];
|
||||
}
|
||||
$groups[$key]['limits'][] = (int) $project->daily_limit_target;
|
||||
}
|
||||
|
||||
$intended = [];
|
||||
foreach ($groups as $group) {
|
||||
$order = SupplierQuotaAllocator::computeOrder($group['limits']);
|
||||
$shares = SupplierQuotaAllocator::distributeForPlatform($order, $group['platforms']);
|
||||
foreach ($shares as $platform => $limit) {
|
||||
$intended[] = [
|
||||
'signal_type' => $group['signal_type'],
|
||||
'identifier' => $group['identifier'],
|
||||
'platform' => $platform,
|
||||
'limit' => $limit,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return ['intended' => $intended];
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Supplier;
|
||||
|
||||
/**
|
||||
* Чистая сверка «задуманный заказ» vs «живой кабинет поставщика».
|
||||
*
|
||||
* Возвращает список расхождений; каждый — ['kind','signal_type','identifier','platform','expected','actual'].
|
||||
* kind ∈ missing | limit_drift | should_be_off | should_be_on | orphan_extra.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-07-09-supplier-order-verification-and-deadline-watch-design.md §4.2
|
||||
*/
|
||||
final class SupplierOrderVerifier
|
||||
{
|
||||
/**
|
||||
* @param list<array{signal_type:string,identifier:string,platform:string,limit:int}> $intended
|
||||
* @param list<array{signal_type:string,identifier:string,platform:string}> $shouldBeOff
|
||||
* @param list<array{signal_type:string,identifier:string,platform:string,limit:int,on:bool}> $live
|
||||
* @return list<array{kind:string,signal_type:string,identifier:string,platform:string,expected:int|string|bool|null,actual:int|string|bool|null}>
|
||||
*/
|
||||
public static function diff(array $intended, array $shouldBeOff, array $live): array
|
||||
{
|
||||
$key = static fn (array $r): string => $r['signal_type'].'|'.$r['identifier'].'|'.$r['platform'];
|
||||
|
||||
$liveByKey = [];
|
||||
foreach ($live as $row) {
|
||||
$liveByKey[$key($row)] = $row;
|
||||
}
|
||||
|
||||
$mismatches = [];
|
||||
$accountedLive = [];
|
||||
|
||||
// 1. Активные по формуле.
|
||||
foreach ($intended as $row) {
|
||||
$k = $key($row);
|
||||
$accountedLive[$k] = true;
|
||||
$liveRow = $liveByKey[$k] ?? null;
|
||||
|
||||
if ($liveRow === null) {
|
||||
$mismatches[] = self::m('missing', $row, $row['limit'], null);
|
||||
|
||||
continue;
|
||||
}
|
||||
if ($liveRow['on'] !== true) {
|
||||
$mismatches[] = self::m('should_be_on', $row, true, false);
|
||||
|
||||
continue;
|
||||
}
|
||||
if ((int) $liveRow['limit'] !== (int) $row['limit']) {
|
||||
$mismatches[] = self::m('limit_drift', $row, (int) $row['limit'], (int) $liveRow['limit']);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Должны быть выключены.
|
||||
foreach ($shouldBeOff as $row) {
|
||||
$k = $key($row);
|
||||
$accountedLive[$k] = true;
|
||||
$liveRow = $liveByKey[$k] ?? null;
|
||||
if ($liveRow !== null && $liveRow['on'] === true) {
|
||||
$mismatches[] = self::m('should_be_off', $row, false, true);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Лишние наши строки у поставщика, которых нет ни в intended, ни в shouldBeOff.
|
||||
foreach ($live as $row) {
|
||||
if (! isset($accountedLive[$key($row)]) && $row['on'] === true) {
|
||||
$mismatches[] = self::m('orphan_extra', $row, null, (int) $row['limit']);
|
||||
}
|
||||
}
|
||||
|
||||
return $mismatches;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{signal_type:string,identifier:string,platform:string} $row
|
||||
* @return array{kind:string,signal_type:string,identifier:string,platform:string,expected:int|string|bool|null,actual:int|string|bool|null}
|
||||
*/
|
||||
private static function m(string $kind, array $row, int|string|bool|null $expected, int|string|bool|null $actual): array
|
||||
{
|
||||
return [
|
||||
'kind' => $kind,
|
||||
'signal_type' => $row['signal_type'],
|
||||
'identifier' => $row['identifier'],
|
||||
'platform' => $row['platform'],
|
||||
'expected' => $expected,
|
||||
'actual' => $actual,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
@@ -204,51 +129,6 @@ class SupplierPortalClient
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Разовая починка имени строки в кабинете (прод-инцидент 11–12.07.2026): отдаём живую
|
||||
* строку обратно с исправленным `name`. Кроме имени не меняем НИЧЕГО — лимит, дни,
|
||||
* регионы и вкл/выкл берём из самого кабинета. Регионы там уже в кодах поставщика,
|
||||
* второй раз переводить их через SupplierRegions нельзя.
|
||||
*
|
||||
* @param array<string,mixed> $liveRow строка из listProjects()
|
||||
*/
|
||||
public function renameProject(array $liveRow, string $newName): void
|
||||
{
|
||||
$src = (string) ($liveRow['src'] ?? '');
|
||||
|
||||
$regions = array_values(array_filter(array_map(
|
||||
static fn (string $code): int => (int) trim($code),
|
||||
preg_split('/[;,]/', (string) ($liveRow['regions'] ?? '')) ?: [],
|
||||
)));
|
||||
|
||||
$response = $this->request('POST', '/admin/visit/rt-project-save', [
|
||||
'id' => (int) ($liveRow['id'] ?? 0),
|
||||
'tag' => (string) ($liveRow['tag'] ?? ''),
|
||||
'name' => $newName,
|
||||
'type' => (string) ($liveRow['type'] ?? ''),
|
||||
'content' => (string) ($liveRow['content'] ?? ''),
|
||||
'srcrt' => $src === 'rt',
|
||||
'srcbl' => $src === 'bl',
|
||||
'srcmt' => $src === 'mt',
|
||||
'srcmg' => false,
|
||||
'srclal' => false,
|
||||
'srcdop' => false,
|
||||
'srcwz' => false,
|
||||
'srcseg' => false,
|
||||
'limit' => (int) ($liveRow['lim'] ?? 0),
|
||||
'workdays' => array_map(static fn ($day): string => (string) $day, (array) ($liveRow['workdays'] ?? [])),
|
||||
'regions' => $regions,
|
||||
'regions_reverse' => (bool) ($liveRow['regions_reverse'] ?? false),
|
||||
'status' => (bool) ($liveRow['status'] ?? false),
|
||||
'show' => true,
|
||||
'multisignals' => false,
|
||||
'multigroup' => false,
|
||||
'depth' => (int) ($liveRow['depth'] ?? 1),
|
||||
], asJson: true);
|
||||
|
||||
$this->assertStatusOk($response, '/admin/visit/rt-project-save');
|
||||
}
|
||||
|
||||
public function deleteProject(int $externalId): void
|
||||
{
|
||||
$response = $this->request(
|
||||
@@ -552,17 +432,9 @@ class SupplierPortalClient
|
||||
* один rt-проект (множественные флаги создают N проектов, мы привязываемся
|
||||
* к одному external_id).
|
||||
* - signalType: site → type:"hosts"; call → type:"calls"; sms → type:"sms".
|
||||
* - uniqueKey → одновременно `name` (label проекта на портале) и `content`
|
||||
* (домен/телефон в полях сбора).
|
||||
* - `name` и метка канала «B<n>_» (прод-инцидент 11–12.07.2026): портал
|
||||
* дописывает метку САМ, но только при СОЗДАНИИ (id=0); при обновлении он
|
||||
* сохраняет имя ровно как прислали. Раньше мы и на update слали голый
|
||||
* uniqueKey — каждый ночной прогон стирал метку. Последствия: итоговая
|
||||
* проверка переставала узнавать строку (ложное «нет заказа»), а лид от
|
||||
* такой строки приходил с project без метки → webhook не мог определить
|
||||
* канал и писал platform=DIRECT вместо B1/B2/B3. Поэтому на update имя
|
||||
* уходит уже с меткой; на create — голым (там метку ставит портал, и один
|
||||
* save с тремя флагами рождает три строки — общего префикса у них нет).
|
||||
* - uniqueKey → одновременно `name` (label проекта на портале — портал
|
||||
* префиксует "B<n>_" автоматически) и `content` (домен/телефон в полях
|
||||
* сбора).
|
||||
* - workdays: int[1..7] → string["1".."7"] (portal принимает строки).
|
||||
* - regions: int[]; regions_reverse: bool.
|
||||
* - status: "active" → true; "paused" → false.
|
||||
@@ -587,18 +459,13 @@ class SupplierPortalClient
|
||||
$srcbl = in_array('B2', $platforms, true);
|
||||
$srcmt = in_array('B3', $platforms, true);
|
||||
|
||||
// Update (id != 0) одной площадки — имя с меткой канала, иначе портал её сотрёт.
|
||||
$name = $externalId !== 0 && count($platforms) === 1
|
||||
? $platforms[array_key_first($platforms)].'_'.$dto->uniqueKey
|
||||
: $dto->uniqueKey;
|
||||
|
||||
// workdays: int → string (portal: ["1","2",...,"7"]).
|
||||
$workdays = array_map(static fn (int $d): string => (string) $d, $dto->workdays);
|
||||
|
||||
return [
|
||||
'id' => $externalId,
|
||||
'tag' => $dto->tag,
|
||||
'name' => $name,
|
||||
'name' => $dto->uniqueKey,
|
||||
'type' => $type,
|
||||
'content' => $dto->uniqueKey,
|
||||
'srcrt' => $srcrt,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -122,20 +122,4 @@ return [
|
||||
*/
|
||||
'render_cache' => env('AUTOPODBOR_RENDER_CACHE', false),
|
||||
'render_cache_ttl_days' => (int) env('AUTOPODBOR_RENDER_CACHE_TTL_DAYS', 5),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Второй слой отсева телефонов через ДаДату (после реестра Россвязи)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Пойманные при «вскрытии» кода страницы номера всегда чистятся реестром Россвязи
|
||||
| (бесплатно): номера с несуществующим кодом → выкидываются. Этот флаг добавляет
|
||||
| ВТОРОЙ, ПЛАТНЫЙ слой — ДаДата clean/phone (~0,6 ₽/номер) перепроверяет уже прошедшие
|
||||
| реестр номера и снимает подтверждённый мусор/иностранцев (qc 2/7). Платим только за
|
||||
| реальных выживших (мусор отсекается бесплатно ДО ДаДаты), под дневным бюджетом
|
||||
| services.dadata.daily_cap_rub, с кэшем вердикта. По умолчанию ВЫКЛ — включается
|
||||
| осознанно (тратит деньги). Требует services.dadata.api_key/secret.
|
||||
|
|
||||
*/
|
||||
'phone_dadata_enabled' => env('AUTOPODBOR_PHONE_DADATA_ENABLED', false),
|
||||
];
|
||||
|
||||
@@ -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,63 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* supplier_order_checks — история итоговой проверки заказа у поставщика.
|
||||
*
|
||||
* VerifySupplierOrderJob после ночного прогона робота перечитывает живой кабинет
|
||||
* поставщика и сверяет каждую нашу строку с формулой; результат
|
||||
* (ok / mismatch / unable_to_verify + список расхождений) пишется сюда одной строкой.
|
||||
*
|
||||
* Системная таблица (как supplier_sync_runs / supplier_deferred_sync) — без RLS/
|
||||
* tenant_id, пишет supplier-flow джоб под crm_supplier_worker (BYPASSRLS), читает SaaS-admin.
|
||||
* Создаётся через pgsql_supplier + инлайн-GRANT (guard IF EXISTS) — на проде миграции
|
||||
* катит crm_migrator, поэтому дефолтные привилегии crm_supplier_worker не наследуются.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-07-09-supplier-order-verification-and-deadline-watch-design.md §6.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$supplier = DB::connection('pgsql_supplier');
|
||||
|
||||
$supplier->statement(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS supplier_order_checks (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
checked_at TIMESTAMPTZ NOT NULL,
|
||||
sync_run_id BIGINT,
|
||||
intended_rows INTEGER NOT NULL DEFAULT 0,
|
||||
live_rows INTEGER NOT NULL DEFAULT 0,
|
||||
mismatch_count INTEGER NOT NULL DEFAULT 0,
|
||||
status VARCHAR(32) NOT NULL
|
||||
CHECK (status IN ('ok','mismatch','unable_to_verify')),
|
||||
details JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
SQL);
|
||||
$supplier->statement('CREATE INDEX IF NOT EXISTS idx_supplier_order_checks_checked ON supplier_order_checks (checked_at DESC)');
|
||||
$supplier->statement('CREATE INDEX IF NOT EXISTS idx_supplier_order_checks_status ON supplier_order_checks (status)');
|
||||
|
||||
foreach (['crm_supplier_worker'] as $role) {
|
||||
$supplier->statement(<<<SQL
|
||||
DO \$\$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{$role}') THEN
|
||||
GRANT SELECT, INSERT ON supplier_order_checks TO {$role};
|
||||
GRANT USAGE, SELECT ON SEQUENCE supplier_order_checks_id_seq TO {$role};
|
||||
END IF;
|
||||
END
|
||||
\$\$
|
||||
SQL);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::connection('pgsql_supplier')->statement('DROP TABLE IF EXISTS supplier_order_checks CASCADE');
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
|
||||
-
|
||||
@@ -2958,12 +2958,6 @@ parameters:
|
||||
count: 1
|
||||
path: tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Supplier/SupplierDeadlineWatchCommandTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -3006,12 +3000,6 @@ parameters:
|
||||
count: 6
|
||||
path: tests/Feature/Supplier/SyncSupplierProjectJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:mock\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Supplier/VerifySupplierOrderJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
@@ -104,7 +104,6 @@ export const LEGAL_DOCS: Record<'offer' | 'privacy' | 'refund', LegalDoc> = {
|
||||
heading: '5. Хранение и защита',
|
||||
paragraphs: [
|
||||
'Персональные данные хранятся на территории Российской Федерации. Применяются разграничение доступа, шифрование, маскирование данных в выгрузках и журналирование действий.',
|
||||
'Для улучшения сервиса используется Яндекс.Метрика, включая запись пользовательских сессий (Вебвизор). Персональные данные контактов (телефоны, имена) в таких записях маскируются и не передаются. Обработка выполняется на серверах в Российской Федерации.',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 → «1–250», idx=1 → «251–750».
|
||||
* Последняя ступень (`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}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
@if ($level === 'red')
|
||||
🔴 КРАСНЫЙ. Робот-заказчик к 20:40 МСК ещё не закончил. До дедлайна поставщика (21:00) ~20 минут — есть риск не успеть заказать на завтра.
|
||||
@else
|
||||
🟡 ЖЁЛТЫЙ. Робот-заказчик к 20:00 МСК ещё не закончил. До дедлайна поставщика (21:00) меньше часа — следите.
|
||||
@endif
|
||||
|
||||
Причина: {{ $reason }}
|
||||
Время письма: {{ $now }} МСК.
|
||||
@@ -1,12 +0,0 @@
|
||||
Итоговая проверка заказа у поставщика ({{ $now }} МСК) нашла расхождения: {{ count($mismatches) }}.
|
||||
|
||||
@foreach ($mismatches as $m)
|
||||
- [{{ $m['kind'] }}] {{ $m['signal_type'] }} {{ $m['identifier'] }} / {{ $m['platform'] }}: задумано «{{ var_export($m['expected'], true) }}», у поставщика «{{ var_export($m['actual'], true) }}»
|
||||
@endforeach
|
||||
|
||||
Расшифровка:
|
||||
- missing — задумали строку, у поставщика её нет.
|
||||
- limit_drift — лимит не совпал.
|
||||
- should_be_off — должно быть выключено, а у поставщика включено (деньги!).
|
||||
- should_be_on — должно быть включено, а выключено.
|
||||
- orphan_extra — у поставщика лишняя наша строка.
|
||||
@@ -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>
|
||||
|
||||
@@ -161,20 +161,6 @@ Schedule::job(new SyncSupplierProjectsJob)
|
||||
->timezone('Europe/Moscow')
|
||||
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\SyncSupplierProjectsJob', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\SyncSupplierProjectsJob', false, 'Job failed', null));
|
||||
|
||||
// Сторож дедлайна поставщика (21:00 МСК). yellow=20:00, red=20:40 — письмо,
|
||||
// если робот к порогу не закончил. Spec 2026-07-09-supplier-order-verification §5.
|
||||
Schedule::command('supplier:deadline-watch yellow')
|
||||
->dailyAt('20:00')
|
||||
->timezone('Europe/Moscow')
|
||||
->onSuccess(fn () => $hb->recordRunResult('supplier:deadline-watch yellow', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('supplier:deadline-watch yellow', false, 'Command failed', null));
|
||||
Schedule::command('supplier:deadline-watch red')
|
||||
->dailyAt('20:40')
|
||||
->timezone('Europe/Moscow')
|
||||
->onSuccess(fn () => $hb->recordRunResult('supplier:deadline-watch red', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('supplier:deadline-watch red', false, 'Command failed', null));
|
||||
|
||||
Schedule::job(new CleanupInactiveSupplierProjectsJob)
|
||||
->dailyAt('02:00')
|
||||
->timezone('Europe/Moscow')
|
||||
@@ -218,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));
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -232,7 +232,7 @@ it('audit:rebuild-chain rejects unknown partition names', function (): void {
|
||||
// Column list for auth_log (must match AuditChainConfig::TABLES['auth_log']).
|
||||
const AUTH_LOG_ROW_EXPR = 'ROW(t.id, t.actor_type, t.tenant_id, t.user_id, t.saas_admin_user_id, t.email, t.event, t.ip_address, t.user_agent, t.failure_reason, NULL::bytea, t.created_at)';
|
||||
|
||||
it('audit:rebuild-chain produces global chain matching trigger semantics в activity_log (ADR-021)', function (): void {
|
||||
it('audit:rebuild-chain produces per-tenant chain matching trigger semantics в activity_log', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
$tenantB = Tenant::factory()->create();
|
||||
|
||||
@@ -253,10 +253,15 @@ it('audit:rebuild-chain produces global chain matching trigger semantics в acti
|
||||
$partition = 'activity_log_y'.now()->format('Y').'_m'.now()->format('m');
|
||||
$firstId = (int) DB::connection('pgsql_supplier')->table($partition)->min('id');
|
||||
|
||||
// ADR-021 (supersedes ADR-018): канон — global-within-partition для всех таблиц.
|
||||
// На проде (проверено 2026-07-09) и в test env (postgres superuser) trigger пишет
|
||||
// GLOBAL chain. rebuild читает partition='' из AuditChainConfig → тоже global →
|
||||
// verify под global scope match → GREEN.
|
||||
// NB: pre-rebuild sanity-check на trigger output опущен намеренно — в test env
|
||||
// `SharesSupplierPdo` trait + postgres superuser обходят RLS, и trigger пишет
|
||||
// global chain, а не per-tenant. На prod RLS активен и trigger пишет per-tenant
|
||||
// (валидация — live `audit:verify-chains` на проде, не в этом тесте).
|
||||
//
|
||||
// Что тестируется здесь: AFTER rebuild чейн должен match семантике своего
|
||||
// partition_clause (self-consistency). Pre-Task-4 rebuild делает global LAG →
|
||||
// verify с PARTITION BY tenant_id обнаруживает mismatch → RED. Post-Task-4
|
||||
// rebuild делает per-tenant LAG → verify с PARTITION BY tenant_id match → GREEN.
|
||||
|
||||
$exit = Artisan::call('audit:rebuild-chain', [
|
||||
'--partition' => $partition,
|
||||
@@ -265,8 +270,8 @@ it('audit:rebuild-chain produces global chain matching trigger semantics в acti
|
||||
]);
|
||||
expect($exit)->toBe(0);
|
||||
|
||||
$postMismatches = checkPartitionIntegrity($partition, '', ACTIVITY_LOG_ROW_EXPR);
|
||||
expect($postMismatches)->toBe(0, 'Rebuild должен produce global chain (ADR-021, supersedes ADR-018 per-tenant)');
|
||||
$postMismatches = checkPartitionIntegrity($partition, 'PARTITION BY tenant_id', ACTIVITY_LOG_ROW_EXPR);
|
||||
expect($postMismatches)->toBe(0, 'Rebuild должен produce per-tenant chain matching PARTITION BY tenant_id semantics (ADR-018)');
|
||||
});
|
||||
|
||||
it('audit:rebuild-chain produces global chain for BYPASSRLS auth_log', function (): void {
|
||||
|
||||
@@ -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,153 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Autopodbor\Agent\Extract\PhoneValidityGate;
|
||||
use App\Services\Autopodbor\Agent\Extract\PhoneValidityLayer;
|
||||
use App\Services\RossvyazPrefixLookup;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
/**
|
||||
* Засеваем один диапазон Россвязи → реестр становится непустым.
|
||||
* По умолчанию: def_code=921, абоненты 5550000..5559999 (МегаФон, СПб).
|
||||
*
|
||||
* @param array<string, mixed> $overrides
|
||||
*/
|
||||
function seedGateRange(array $overrides = []): void
|
||||
{
|
||||
$importId = (int) DB::table('phone_ranges_imports')->insertGetId([
|
||||
'source_url' => 'https://rossvyaz.gov.ru/test',
|
||||
'checksum_sha256' => str_repeat('b', 64),
|
||||
'status' => 'completed',
|
||||
'imported_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('phone_ranges')->insert(array_merge([
|
||||
'def_code' => 921,
|
||||
'from_num' => 5550000,
|
||||
'to_num' => 5559999,
|
||||
'operator' => 'МегаФон',
|
||||
'region' => 'Санкт-Петербург',
|
||||
'subject_code' => 83,
|
||||
'imported_at' => now(),
|
||||
'import_id' => $importId,
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
* @return array{number:string,kind:string,label:string,url:?string,office:?string,tracker:bool}
|
||||
*/
|
||||
function gateCandidate(string $number, array $overrides = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'number' => $number,
|
||||
'kind' => 'text',
|
||||
'label' => 'в тексте сайта — проверьте',
|
||||
'url' => 'https://review-group.ru/',
|
||||
'office' => null,
|
||||
'tracker' => false,
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
it('выкидывает номер, чей код города отсутствует в реестре Россвязи', function (): void {
|
||||
seedGateRange(); // реестр непустой (есть 921)
|
||||
|
||||
// 76132306130 — код «613», такого в реестре нет (мусор со страницы-агрегатора).
|
||||
$out = app(PhoneValidityGate::class)->filter([gateCandidate('76132306130')]);
|
||||
|
||||
expect($out)->toBe([]);
|
||||
});
|
||||
|
||||
it('оставляет номер, который есть в реестре Россвязи', function (): void {
|
||||
seedGateRange(); // def_code=921, 5550000..5559999
|
||||
|
||||
$out = app(PhoneValidityGate::class)->filter([
|
||||
gateCandidate('79215555123', ['kind' => 'code', 'label' => 'в коде сайта']),
|
||||
]);
|
||||
|
||||
expect($out)->toHaveCount(1)
|
||||
->and($out[0]['number'])->toBe('79215555123');
|
||||
});
|
||||
|
||||
it('фильтрует только мусор, реальные соседи остаются', function (): void {
|
||||
seedGateRange();
|
||||
|
||||
$out = app(PhoneValidityGate::class)->filter([
|
||||
gateCandidate('76132306130'), // мусор (код 613)
|
||||
gateCandidate('79215555123', ['kind' => 'code']), // реальный (921)
|
||||
gateCandidate('72279347817'), // мусор (код 227 — вообще <300, запрещён реестром)
|
||||
]);
|
||||
|
||||
expect($out)->toHaveCount(1)
|
||||
->and($out[0]['number'])->toBe('79215555123');
|
||||
});
|
||||
|
||||
it('при пустом реестре не режет ничего (fail-open)', function (): void {
|
||||
// seedGateRange НЕ вызван → phone_ranges пуст (как на dev). Фильтр не должен
|
||||
// выкосить всё из-за незагруженного реестра.
|
||||
$out = app(PhoneValidityGate::class)->filter([
|
||||
gateCandidate('76132306130'),
|
||||
gateCandidate('79215555123'),
|
||||
]);
|
||||
|
||||
expect($out)->toHaveCount(2);
|
||||
});
|
||||
|
||||
it('никогда не трогает короткие номера «требует проверки» (их нельзя сверить)', function (): void {
|
||||
seedGateRange();
|
||||
|
||||
// 2713333 — локальный 7-значный, код города не определён (kind=uncertain).
|
||||
// Сверить с реестром нельзя → не теряем.
|
||||
$out = app(PhoneValidityGate::class)->filter([
|
||||
gateCandidate('2713333', ['kind' => 'uncertain']),
|
||||
]);
|
||||
|
||||
expect($out)->toHaveCount(1)
|
||||
->and($out[0]['number'])->toBe('2713333');
|
||||
});
|
||||
|
||||
it('второй слой (ДаДата) снимает мусор, прошедший реестр', function (): void {
|
||||
seedGateRange(); // 79215555123 есть в реестре
|
||||
|
||||
$dropAll = new class implements PhoneValidityLayer
|
||||
{
|
||||
public function isValid(string $number): bool
|
||||
{
|
||||
return false; // ДаДата сказала «мусор»
|
||||
}
|
||||
};
|
||||
$gate = new PhoneValidityGate(new RossvyazPrefixLookup, $dropAll);
|
||||
|
||||
$out = $gate->filter([gateCandidate('79215555123', ['kind' => 'code'])]);
|
||||
|
||||
expect($out)->toBe([]);
|
||||
});
|
||||
|
||||
it('второй слой НЕ вызывается для номеров вне реестра (мусор отсёк слой 1 бесплатно)', function (): void {
|
||||
seedGateRange();
|
||||
|
||||
$spy = new class implements PhoneValidityLayer
|
||||
{
|
||||
public bool $called = false;
|
||||
|
||||
public function isValid(string $number): bool
|
||||
{
|
||||
$this->called = true;
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
$gate = new PhoneValidityGate(new RossvyazPrefixLookup, $spy);
|
||||
|
||||
// 76132306130 — код 613, вне реестра → слой 1 выкидывает бесплатно, ДаДата не дёргается.
|
||||
$out = $gate->filter([gateCandidate('76132306130')]);
|
||||
|
||||
expect($out)->toBe([])
|
||||
->and($spy->called)->toBeFalse();
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Autopodbor\Agent\Fetch\FetchedSite;
|
||||
use App\Services\Autopodbor\Agent\Fetch\Fetcher;
|
||||
use App\Services\Autopodbor\Agent\Study\SiteOpener;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
function seedOpenerRange(): void
|
||||
{
|
||||
$importId = (int) DB::table('phone_ranges_imports')->insertGetId([
|
||||
'source_url' => 'https://rossvyaz.gov.ru/test',
|
||||
'checksum_sha256' => str_repeat('c', 64),
|
||||
'status' => 'completed',
|
||||
'imported_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('phone_ranges')->insert([
|
||||
'def_code' => 391,
|
||||
'from_num' => 2000000,
|
||||
'to_num' => 2999999,
|
||||
'operator' => 'Ростелеком',
|
||||
'region' => 'Красноярский край',
|
||||
'subject_code' => 24,
|
||||
'imported_at' => now(),
|
||||
'import_id' => $importId,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Fetcher, отдающий фиксированный HTML. */
|
||||
function openerFetcher(string $html): Fetcher
|
||||
{
|
||||
return new class($html) implements Fetcher
|
||||
{
|
||||
public function __construct(private string $html) {}
|
||||
|
||||
public function site(string $url): FetchedSite
|
||||
{
|
||||
return new FetchedSite($url, $this->html);
|
||||
}
|
||||
|
||||
public function directory(string $url): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
it('глубокий путь: мусорный номер (код вне реестра) не попадает в numbers', function (): void {
|
||||
seedOpenerRange(); // есть только 391 2000000..2999999
|
||||
|
||||
// Настоящий 73912920000 (391) + мусор 76132306130 (код 613, вне реестра).
|
||||
$html = '<a href="tel:+73912920000">звонок</a>'
|
||||
.' <span>+7 613 230 61 30</span>';
|
||||
|
||||
$r = (new SiteOpener(openerFetcher($html)))->open('kraslombard24.ru', '391');
|
||||
|
||||
$numbers = collect($r->numbers)->pluck('number');
|
||||
expect($numbers)->toContain('73912920000')
|
||||
->and($numbers)->not->toContain('76132306130');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -151,18 +151,117 @@ test('multiple rows in auth_log all pass intact', function () {
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// MULTI-TENANT REGRESSION — SUPERSEDED by ADR-021.
|
||||
// MULTI-TENANT REGRESSION: per-scope validator must not false-positive on
|
||||
// data with multiple tenants.
|
||||
//
|
||||
// The former test here ('per-tenant chained tenant_operations_log validates
|
||||
// intact') manually seeded PER-TENANT-chained hashes and asserted the per-tenant
|
||||
// validator accepted them. Its premise — that the trigger produces a per-tenant
|
||||
// chain on prod — was empirically disproven 2026-07-09: the trigger produces a
|
||||
// GLOBAL chain for all tables (incl. balance_transactions). ADR-021 makes global
|
||||
// the canonical scope. The real, prod-accurate scenario (mixed-tenant rows
|
||||
// chained BY THE TRIGGER = global) is covered by
|
||||
// 'cross-tenant rows chained by the trigger (global) verify intact — ADR-021'.
|
||||
// Problem reproduced on prod: the old global validator computed LAG OVER
|
||||
// (ORDER BY id) across ALL tenants. When tenant B's first row followed
|
||||
// tenant A's last row, its stored_hash was SHA256(A_last.log_hash || B_row),
|
||||
// but the global recompute gave the same thing — so it was fine globally.
|
||||
// However, on PROD (crm_app_user, NOT BYPASSRLS) the trigger's SELECT only
|
||||
// sees the current tenant's rows, so B_row[0] is chained off '' (no prev),
|
||||
// not off A_last. The global validator then reported a false breach at every
|
||||
// tenant boundary.
|
||||
//
|
||||
// This test replicates PROD conditions by bypassing the trigger and manually
|
||||
// inserting rows with CORRECTLY per-tenant-chained hashes (what the trigger
|
||||
// would produce under RLS on prod). The per-scope validator must then report
|
||||
// INTACT, confirming the partition logic is correct.
|
||||
//
|
||||
// Why bypass the trigger for this test:
|
||||
// On dev (postgres = superuser), the trigger's SELECT has no RLS filter and
|
||||
// sees ALL rows globally. Inserting via the trigger would produce a GLOBAL
|
||||
// chain regardless of the app.current_tenant_id GUC. To test the validator's
|
||||
// per-partition recompute, we need rows whose stored hashes were computed
|
||||
// with per-tenant prev_hash — exactly what the trigger produces on prod.
|
||||
// We reproduce that by disabling triggers and computing the hashes ourselves
|
||||
// using the same digest(COALESCE(prev,'')||row::text,'sha256') formula with
|
||||
// per-partition LAG, matching the validator's own recompute SQL.
|
||||
// ===========================================================================
|
||||
|
||||
test('per-tenant chained tenant_operations_log validates intact (prod-behaviour regression)', function () {
|
||||
$tid1 = ensureTenant(1);
|
||||
$tid2 = ensureTenant(2);
|
||||
|
||||
// Disable triggers so we can insert rows with manually computed hashes
|
||||
// that simulate per-tenant chaining (what the trigger produces on prod).
|
||||
DB::statement('ALTER TABLE tenant_operations_log DISABLE TRIGGER USER');
|
||||
|
||||
try {
|
||||
// Insert 3 rows for tenant 1 and 2 rows for tenant 2, interleaved by id.
|
||||
// We insert WITHOUT log_hash first, then update with per-tenant-correct hashes.
|
||||
// (INSERT with log_hash=NULL, then fill via SQL digest to avoid PHP bytea handling.)
|
||||
|
||||
$rows = [
|
||||
['tenant_id' => $tid1, 'entity_type' => 'project', 'entity_id' => 10, 'event' => 'project.created', 'created_at' => now()],
|
||||
['tenant_id' => $tid2, 'entity_type' => 'api_key', 'entity_id' => 20, 'event' => 'api_key.regenerated', 'created_at' => now()],
|
||||
['tenant_id' => $tid1, 'entity_type' => 'project', 'entity_id' => 11, 'event' => 'project.updated', 'created_at' => now()],
|
||||
['tenant_id' => $tid2, 'entity_type' => 'project', 'entity_id' => 21, 'event' => 'project.created', 'created_at' => now()],
|
||||
['tenant_id' => $tid1, 'entity_type' => 'project', 'entity_id' => 12, 'event' => 'project.deleted', 'created_at' => now()],
|
||||
];
|
||||
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
// log_hash left NULL; we will fill it below via SQL
|
||||
$ids[] = (int) DB::table('tenant_operations_log')->insertGetId($row);
|
||||
}
|
||||
|
||||
// Now fill log_hash for each inserted row using per-tenant-partitioned prev_hash,
|
||||
// mirroring exactly what the trigger produces under RLS on prod:
|
||||
// log_hash = digest(COALESCE(prev_tenant_hash, ''::bytea) || ROW(...)::text::bytea, 'sha256')
|
||||
// where prev_tenant_hash = last log_hash of the SAME tenant ordered by id.
|
||||
//
|
||||
// We do this in id-order one row at a time so each hash feeds the next.
|
||||
$idList = implode(',', $ids);
|
||||
|
||||
DB::statement(<<<SQL
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
id,
|
||||
tenant_id,
|
||||
ROW(id, tenant_id, user_id, entity_type, entity_id, event,
|
||||
payload_before, payload_after, ip_address, user_agent,
|
||||
NULL::bytea, created_at) AS row_val,
|
||||
ROW_NUMBER() OVER (PARTITION BY tenant_id ORDER BY id) AS rn
|
||||
FROM tenant_operations_log
|
||||
WHERE id IN ({$idList})
|
||||
)
|
||||
UPDATE tenant_operations_log tgt
|
||||
SET log_hash = (
|
||||
WITH RECURSIVE chain(id, tenant_id, rn, hash) AS (
|
||||
-- Base: first row per tenant (rn=1), prev_hash = ''
|
||||
SELECT r.id, r.tenant_id, r.rn,
|
||||
digest(''::bytea || r.row_val::text::bytea, 'sha256')
|
||||
FROM ranked r
|
||||
WHERE r.rn = 1
|
||||
UNION ALL
|
||||
-- Recursive: each subsequent row chains off previous
|
||||
SELECT r.id, r.tenant_id, r.rn,
|
||||
digest(c.hash || r.row_val::text::bytea, 'sha256')
|
||||
FROM chain c
|
||||
JOIN ranked r ON r.tenant_id = c.tenant_id AND r.rn = c.rn + 1
|
||||
)
|
||||
SELECT hash FROM chain WHERE id = tgt.id
|
||||
)
|
||||
WHERE id IN ({$idList})
|
||||
SQL);
|
||||
} finally {
|
||||
DB::statement('ALTER TABLE tenant_operations_log ENABLE TRIGGER USER');
|
||||
}
|
||||
|
||||
// Verify all 5 rows got their hashes set (sanity check on the SQL above)
|
||||
$nullCount = DB::table('tenant_operations_log')
|
||||
->whereIn('id', $ids)
|
||||
->whereNull('log_hash')
|
||||
->count();
|
||||
expect($nullCount)->toBe(0, 'All inserted rows must have log_hash set by the chain SQL');
|
||||
|
||||
// The per-scope validator must report INTACT — no false breach at tenant boundary
|
||||
$this->artisan('audit:verify-chains')->assertSuccessful();
|
||||
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Tampering detection
|
||||
// ===========================================================================
|
||||
@@ -401,36 +500,3 @@ test('exit code is FAILURE on breach even when no active admin exists for incide
|
||||
// But email alert must still be sent
|
||||
Mail::assertSent(AuditChainBreachMail::class);
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// ADR-021: audit hash-chain canonical is GLOBAL-within-partition for all tables.
|
||||
// The trigger produces a global chain (verified on prod 2026-07-09 for all 4
|
||||
// formerly-"per-tenant" tables incl. balance_transactions; also true on
|
||||
// dev/superuser). Mixed-tenant rows inserted VIA THE TRIGGER must therefore
|
||||
// verify intact — the old per-tenant validator (ADR-018) false-positived at
|
||||
// every tenant boundary. Supersedes the per-tenant regression test above.
|
||||
// ===========================================================================
|
||||
|
||||
test('cross-tenant rows chained by the trigger (global) verify intact — ADR-021', function () {
|
||||
$t1 = ensureTenant(1);
|
||||
$t2 = ensureTenant(2);
|
||||
|
||||
// Insert via the normal path (trigger fills log_hash). On dev/superuser the
|
||||
// trigger chains GLOBALLY — exactly what prod produces. Interleave tenants so
|
||||
// the chain crosses tenant boundaries (id2:t2 after id1:t1, etc.).
|
||||
insertTenantOpsRow($t1, 'project.created');
|
||||
insertTenantOpsRow($t2, 'api_key.regenerated');
|
||||
insertTenantOpsRow($t1, 'project.updated');
|
||||
insertTenantOpsRow($t2, 'project.created');
|
||||
insertTenantOpsRow($t1, 'project.deleted');
|
||||
|
||||
// Under global scope (ADR-021) this is a valid intact chain → no breach.
|
||||
$this->artisan('audit:verify-chains')->assertSuccessful();
|
||||
|
||||
$count = DB::connection('pgsql_supplier')
|
||||
->table('incidents_log')
|
||||
->where('summary', 'like', '%chain%tenant_operations_log%')
|
||||
->count();
|
||||
expect($count)->toBe(0);
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user