diff --git a/app/.env.example b/app/.env.example index 1f3b5210..0b814c66 100644 --- a/app/.env.example +++ b/app/.env.example @@ -83,7 +83,9 @@ MAIL_PASSWORD=null MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_NAME="${APP_NAME}" SUPPORT_EMAIL=support@liderra.ru -JIVO_WIDGET_ID= +BOT_TOURS_ENABLED=false +YANDEX_GPT_API_KEY= +YANDEX_GPT_FOLDER_ID= AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= @@ -96,3 +98,12 @@ VITE_APP_NAME="${APP_NAME}" # Клиентский ключ Yandex SmartCaptcha (M-2). Пусто → fallback-чекбокс (dev). # На проде — клиентский ключ ysc1_… (для виджета на странице регистрации). VITE_YANDEX_SMARTCAPTCHA_SITEKEY= + +# Автоподбор шаг2: обход антибота справочников (2ГИС/Яндекс). Ключ — в .env, не в гите. +XFETCH_API_KEY= + +# Автоподбор шаг1: модель ИИ-фильтра «поставщик или площадка-агрегатор». +# ОБЯЗАТЕЛЬНО gpt-4o (сильная): слабая gpt-4o-mini наугад штампует живые +# федеральные МФО (Екапуста/Webbankir/СрочноДеньги…) как «агрегаторы» и режет +# их из подбора. Пусто → фолбэк на gpt-4o-mini (баг «режет федералов», 07.07.2026). +AITUNNEL_AGGREGATOR_MODEL=gpt-4o diff --git a/app/app/Console/Commands/AutopodborStudyTickCommand.php b/app/app/Console/Commands/AutopodborStudyTickCommand.php new file mode 100644 index 00000000..a53dd816 --- /dev/null +++ b/app/app/Console/Commands/AutopodborStudyTickCommand.php @@ -0,0 +1,22 @@ +tick(); + + return self::SUCCESS; + } +} diff --git a/app/app/Console/Commands/HelpRebuildKnowledgeCommand.php b/app/app/Console/Commands/HelpRebuildKnowledgeCommand.php new file mode 100644 index 00000000..14024364 --- /dev/null +++ b/app/app/Console/Commands/HelpRebuildKnowledgeCommand.php @@ -0,0 +1,58 @@ +error("В {$dir} нет статей *.md — база знаний осталась прежней."); + + return self::FAILURE; + } + + $articles = []; + foreach ($files as $file) { + $articles[] = $parser->parse('help/'.basename($file), (string) file_get_contents($file)); + } + + DB::transaction(function () use ($articles): void { + KnowledgeChunk::query()->delete(); + foreach ($articles as $article) { + foreach ($article->chunks as $i => $chunk) { + KnowledgeChunk::create([ + 'source_path' => $article->sourcePath, + 'title' => $article->title, + 'tour' => $article->tour, + 'topics' => $article->topics, + 'chunk_index' => $i, + 'content' => $chunk, + ]); + } + } + }); + + $this->info(sprintf('Проиндексировано статей: %d.', count($articles))); + + return self::SUCCESS; + } +} diff --git a/app/app/Console/Commands/PartitionsCreateMonths.php b/app/app/Console/Commands/PartitionsCreateMonths.php index 341762e9..8804e1e3 100644 --- a/app/app/Console/Commands/PartitionsCreateMonths.php +++ b/app/app/Console/Commands/PartitionsCreateMonths.php @@ -10,10 +10,16 @@ use Illuminate\Support\Carbon; /** * Создаёт ежемесячные партиции для всех таблиц в MonthlyPartitionManager::PARTITIONED_TABLES - * на N месяцев вперёд от текущей даты. + * на N месяцев вперёд от текущей даты и на M месяцев назад. * * 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). @@ -27,20 +33,23 @@ use Illuminate\Support\Carbon; class PartitionsCreateMonths extends Command { /** @var string */ - protected $signature = 'partitions:create-months {--ahead=2 : Сколько месяцев вперёд создать партиций}'; + protected $signature = 'partitions:create-months + {--ahead=2 : Сколько месяцев вперёд создать партиций} + {--behind=1 : Сколько месяцев назад создать партиций (окно последних 30 дней, стык месяцев)}'; /** @var string */ - protected $description = 'Создаёт ежемесячные партиции для всех партиционированных таблиц на N месяцев вперёд (idempotent)'; + protected $description = 'Создаёт ежемесячные партиции для всех партиционированных таблиц: N месяцев вперёд и M назад (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 = 0; $i <= $ahead; $i++) { + for ($i = -$behind; $i <= $ahead; $i++) { $monthStart = $now->copy()->addMonths($i); foreach (array_keys(MonthlyPartitionManager::PARTITIONED_TABLES) as $table) { @@ -57,7 +66,7 @@ class PartitionsCreateMonths extends Command } $this->newLine(); - $this->info("Done: {$created} created, {$skipped} skipped (ahead={$ahead})."); + $this->info("Done: {$created} created, {$skipped} skipped (ahead={$ahead}, behind={$behind})."); return self::SUCCESS; } diff --git a/app/app/Console/Commands/ReconcilePendingPayments.php b/app/app/Console/Commands/ReconcilePendingPayments.php new file mode 100644 index 00000000..379aa9d1 --- /dev/null +++ b/app/app/Console/Commands/ReconcilePendingPayments.php @@ -0,0 +1,82 @@ +option('age'), self::MIN_AGE_MINUTES); + + // Cross-tenant выборка под BYPASSRLS-ролью (как webhook — tenant заранее неизвестен). + $stale = DB::connection('pgsql_supplier')->table('saas_transactions') + ->where('status', SaasTransaction::STATUS_PENDING) + ->where('type', SaasTransaction::TYPE_TOPUP) + ->whereNotNull('gateway_payment_id') + ->where('created_at', '<=', now()->subMinutes($age)) + ->where('created_at', '>=', now()->subDays(self::MAX_AGE_DAYS)) + ->orderBy('id') + ->limit(self::BATCH_LIMIT) + ->get(); + + $settled = 0; + + foreach ($stale as $tx) { + try { + $outcome = $settlement->settleByPaymentId($tx); + } catch (Throwable $e) { + // Шлюз недоступен / платёж не найден — не роняем остальные, попробуем в следующий прогон. + Log::warning('billing.reconcile_payment_failed', [ + 'tx_id' => $tx->id, + 'tenant_id' => $tx->tenant_id, + 'error' => $e->getMessage(), + ]); + + continue; + } + + if ($outcome !== 'not_paid') { + $settled++; + } + + $this->line(sprintf('tx #%d (tenant %d, %s ₽) → %s', $tx->id, $tx->tenant_id, $tx->amount_rub, $outcome)); + } + + $this->info(sprintf('Проверено зависших: %d, закрыто: %d', $stale->count(), $settled)); + + return self::SUCCESS; + } +} diff --git a/app/app/Console/Commands/RepairSupplierProjectNamesCommand.php b/app/app/Console/Commands/RepairSupplierProjectNamesCommand.php new file mode 100644 index 00000000..75103fe1 --- /dev/null +++ b/app/app/Console/Commands/RepairSupplierProjectNamesCommand.php @@ -0,0 +1,100 @@ +_» к имени строки только при её + * СОЗДАНИИ; при обновлении он сохраняет имя ровно как прислали. Наш ночной робот слал + * голый ключ → каждый прогон стирал метку. Последствия: (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 $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; + } +} diff --git a/app/app/Console/Commands/SeedShowcaseCommand.php b/app/app/Console/Commands/SeedShowcaseCommand.php new file mode 100644 index 00000000..fc428467 --- /dev/null +++ b/app/app/Console/Commands/SeedShowcaseCommand.php @@ -0,0 +1,618 @@ +error('demo:showcase запрещена на боевом окружении.'); + + return self::FAILURE; + } + + $tenant = Tenant::query()->firstOrCreate( + ['subdomain' => 'demo-ulybka'], + [ + 'organization_name' => 'Стоматология Улыбка', + 'contact_email' => 'demo@liderra.ru', + 'timezone' => 'Europe/Moscow', + 'locale' => 'ru', + 'is_trial' => false, + 'balance_rub' => '12400.00', + 'delivered_in_month' => 180, + 'api_key_limit' => 5, + ] + ); + + User::query()->firstOrCreate( + ['tenant_id' => $tenant->id, 'email' => 'demo@liderra.ru'], + [ + 'password_hash' => Hash::make('DemoLiderra#2026'), + 'first_name' => 'Демо', + 'last_name' => 'Клиент', + 'timezone' => 'Europe/Moscow', + 'is_active' => true, + 'totp_enabled' => false, + 'sound_enabled' => true, + 'email_verified_at' => now(), + 'notification_preferences' => [ + 'new_lead' => ['inapp' => true, 'push' => true, 'email' => true], + 'low_balance' => ['email' => true], + 'zero_balance' => ['email' => true], + 'topup_success' => ['email' => true], + 'invoice_paid' => ['email' => true], + 'new_device_login' => ['email' => true], + 'marketing' => ['email' => false], + ], + ] + ); + + // Карточка проекта берёт плашку статуса из Project::aggregateSyncStatus() (ProjectCard.vue + // syncStatusLabel: ok → «Собирает заявки», pending → «Готовим к запуску», failed → ошибка). + // aggregateSyncStatus() читает sync_status у связанных supplier_projects (B1/B2/B3, см. + // resolvedSupplierProjects()) — без привязки к поставщику проект всегда «pending», сколько + // бы is_active/daily_limit_target ни было выставлено. Чтобы демо-проект выглядел РЕАЛЬНО + // работающим (а не просто «не сломанным»), заводим один демо supplier_projects под B1 с + // sync_status='ok' и линкуем его как supplier_b1_project_id — честный путь через то же + // поле, что использует настоящая синхронизация с поставщиком (SyncSupplierProjectsJob), + // а не подмена текста в UI. unique_key/supplier_external_id — заведомо демошные, никаких + // настоящих идентификаторов поставщика crm.bp-gr.ru. + $supplierProjectWorking = SupplierProject::query()->updateOrCreate( + [ + 'platform' => 'B1', + 'unique_key' => 'stomatologiya-ulybka-demo.ru', + 'subject_code' => null, + ], + [ + 'signal_type' => 'site', + 'supplier_external_id' => 'DEMO-B1-000001', + 'current_limit' => 20, + 'current_workdays' => [1, 2, 3, 4, 5, 6, 7], + 'current_regions' => null, + 'sync_status' => 'ok', + 'last_synced_at' => now(), + 'inactive_since' => null, + ] + ); + + $projectWorking = Project::query()->updateOrCreate( + ['tenant_id' => $tenant->id, 'name' => 'Стоматология — Москва'], + [ + 'type' => 'webhook', + 'signal_type' => 'site', + 'signal_identifier' => 'stomatologiya-ulybka.ru', + 'is_active' => true, + 'daily_limit_target' => 20, + 'delivered_today' => 12, + 'region_mask' => 255, + 'region_mode' => 'include', + 'delivery_days_mask' => 127, + 'assignment_strategy' => 'manual', + 'ttfr_target_minutes' => 15, + 'delivered_in_month' => 0, + 'supplier_b1_project_id' => $supplierProjectWorking->id, + ] + ); + + $projectPaused = Project::query()->updateOrCreate( + ['tenant_id' => $tenant->id, 'name' => 'Импланты — Санкт-Петербург'], + [ + 'type' => 'webhook', + 'signal_type' => 'site', + 'signal_identifier' => 'implanty-ulybka-spb.ru', + // Реальный инвариант (ProjectService::pause() строка ~292): + // paused_at ВСЕГДА идёт вместе с is_active=false — иначе портал + // не покажет карточку как «на паузе» (UI смотрит только на is_active). + 'is_active' => false, + 'paused_at' => now(), + 'daily_limit_target' => 15, + 'region_mask' => 255, + 'region_mode' => 'include', + 'delivery_days_mask' => 127, + 'assignment_strategy' => 'manual', + 'ttfr_target_minutes' => 15, + 'delivered_in_month' => 0, + ] + ); + + $projectBlocked = Project::query()->updateOrCreate( + ['tenant_id' => $tenant->id, 'name' => 'Отбеливание — Казань'], + [ + 'type' => 'webhook', + 'signal_type' => 'site', + 'signal_identifier' => 'otbelivanie-ulybka-kazan.ru', + // Реальный инвариант (ProjectService::activate() строка ~307): проект, + // не прошедший преfflight по балансу, «остаётся на паузе» — is_active=false. + 'is_active' => false, + 'preflight_blocked_at' => now(), + 'daily_limit_target' => 10, + 'region_mask' => 255, + 'region_mode' => 'include', + 'delivery_days_mask' => 127, + 'assignment_strategy' => 'manual', + 'ttfr_target_minutes' => 15, + 'delivered_in_month' => 0, + ] + ); + + $projects = [$projectWorking, $projectPaused, $projectBlocked]; + $cities = ['Москва', 'Санкт-Петербург', 'Казань']; + + $charged = []; + $today = Carbon::now(); + $yesterday = $today->copy()->subDay(); + + // 🔴 Держим received_at в ТЕКУЩЕМ месяце — партиции deals помесячные, + // и «сегодня/вчера» в конце месяца может уехать в прошлый месяц без партиции. + if ($yesterday->month !== $today->month) { + $yesterday = $today->copy()->startOfMonth(); + } + + for ($n = 1; $n <= 25; $n++) { + $phone = '7999000'.str_pad((string) $n, 4, '0', STR_PAD_LEFT); + $projectIndex = ($n - 1) % count($projects); + $project = $projects[$projectIndex]; + $status = self::STATUSES[($n - 1) % count(self::STATUSES)]; + $receivedAt = $n <= 12 ? $today->copy() : $yesterday->copy(); + + $deal = Deal::query()->firstOrCreate( + ['tenant_id' => $tenant->id, 'source_crm_id' => 900_000_000 + $n], + [ + 'project_id' => $project->id, + 'phone' => $phone, + 'status' => $status, + 'contact_name' => 'Клиент '.$n, + 'city' => $cities[$projectIndex], + 'received_at' => $receivedAt, + 'escalated_count' => 0, + 'is_test' => false, + ] + ); + + // Списываем за КАЖДУЮ заявку — как в жизни. Раньше списаний было только шесть: + // в карточке заявки и в шапке колонки «Канбана» стоимость показывалась прочерком, + // а история кошелька не сходилась (пополнено 12 400 ₽, списаний нет, баланс 12 400). + LeadCharge::query()->firstOrCreate( + ['tenant_id' => $tenant->id, 'deal_id' => $deal->id], + [ + 'deal_received_at' => $deal->received_at, + 'tier_no' => 1, + 'charge_source' => 'rub', + // Цена первой ступени — как на боевом (55 ₽), см. seedPrices(). + 'price_per_lead_kopecks' => self::PRICE_PER_LEAD_KOPECKS, + 'charged_at' => $deal->received_at, + 'created_at' => $deal->received_at, + ] + ); + + // Лента «Активность» в карточке заявки была пустой: событий у демо-заявок не было + // вовсе, а разбор обещает «когда создана, кто менял статус, когда смотрели». + // Пишем те же события, что пишет сам портал (activity_log). + $events = [ + [ActivityLog::EVENT_DEAL_CREATED, null, null, $deal->received_at], + [ActivityLog::EVENT_DEAL_STATUS_CHANGED, 'new', $deal->status, $deal->received_at->copy()->addMinutes(35)], + ]; + + foreach ($events as [$event, $old, $new, $at]) { + if ($event === ActivityLog::EVENT_DEAL_STATUS_CHANGED && $deal->status === 'new') { + continue; // статус не меняли — врать в ленте не будем + } + + ActivityLog::query()->firstOrCreate( + ['tenant_id' => $tenant->id, 'deal_id' => $deal->id, 'event' => $event], + [ + 'user_id' => null, + 'old_value' => $old, + 'new_value' => $new, + 'context' => null, + 'created_at' => $at, + ] + ); + } + + $charged[] = $deal; + } + + // Кошелёк должен СХОДИТЬСЯ: пополнено 12 400 ₽ − списано 25 × 55 ₽ = остаток. + $chargesRub = count($charged) * (self::PRICE_PER_LEAD_KOPECKS / 100); + $balanceRub = self::TOPUPS_RUB - $chargesRub; + + $this->resetMoneyJournal($tenant->id); + + // Списания в журнале — по порядку прихода заявок, иначе в истории кошелька у более + // ранней даты стоят более поздние номера, и гость видит перевёрнутую хронологию. + usort($charged, fn (Deal $a, Deal $b) => $a->received_at <=> $b->received_at); + + // 🔴 Журнал денег append-only (триггер `audit log is append-only`): строку нельзя ни + // переписать, ни удалить — только добавить. Поэтому firstOrCreate, а не updateOrCreate. + $runningBalance = 0.0; + foreach (self::TOPUPS as $amount) { + $runningBalance += $amount; + BalanceTransaction::query()->firstOrCreate( + [ + 'tenant_id' => $tenant->id, + 'type' => BalanceTransaction::TYPE_TOPUP, + 'amount_rub' => sprintf('%.2f', $amount), + ], + [ + 'amount_leads' => 0, + 'balance_rub_after' => sprintf('%.2f', $runningBalance), + 'balance_leads_after' => null, + 'description' => 'Пополнение баланса', + // Пополнение должно быть РАНЬШЕ первого списания, иначе в истории кошелька + // деньги списываются с пустого счёта — и это видно клиенту на кадрах. + 'created_at' => $today->copy()->subDays(3), + ] + ); + } + + foreach ($charged as $deal) { + $runningBalance -= self::PRICE_PER_LEAD_KOPECKS / 100; + BalanceTransaction::query()->firstOrCreate( + [ + 'tenant_id' => $tenant->id, + 'type' => BalanceTransaction::TYPE_LEAD_CHARGE, + 'related_type' => 'deal', + 'related_id' => $deal->id, + ], + [ + 'amount_rub' => sprintf('-%.2f', self::PRICE_PER_LEAD_KOPECKS / 100), + 'amount_leads' => 0, + 'balance_rub_after' => sprintf('%.2f', $runningBalance), + 'balance_leads_after' => null, + 'description' => 'Заявка №'.$deal->id, + 'created_at' => $deal->received_at, + ] + ); + } + + $tenant->forceFill(['balance_rub' => sprintf('%.2f', $balanceRub)])->save(); + + // Отчёты, заказанные прошлыми съёмками, копятся: портал держит не больше трёх штук + // «в работе» и на четвёртой гасит кнопку «Сформировать» — робот-съёмщик утыкался в + // мёртвую кнопку (14.07.2026). Чистим их вместе с остальным демо-стендом. + DB::table('report_jobs')->where('tenant_id', $tenant->id)->delete(); + + $this->seedPrices(); + + // Списания, заведённые прежними прогонами по старой (тестовой) цене, подтягиваем к + // боевой: иначе на кадре «Списания» у демо-клиента останется чужой ценник. Правим + // построителем запросов, а не моделью: LeadCharge партиционирована по deal_received_at, + // и сохранение через модель трогает ключ партиции. + LeadCharge::query() + ->where('tenant_id', $tenant->id) + ->where('price_per_lead_kopecks', '!=', 55_00) + ->update(['price_per_lead_kopecks' => 55_00]); + + $this->seedCompetitorField($tenant); + + $this->info('demo:showcase готово — тенант «Стоматология Улыбка», 3 проекта, 25 заявок, конкурентное поле, БОЕВЫЕ цены.'); + + return self::SUCCESS; + } + + /** + * Стирает журнал кошелька ТОЛЬКО у демо-тенанта и ТОЛЬКО на локальной машине. + * + * 🔴 Журнал денег защищён от подделки: триггер `audit log is append-only` запрещает и + * UPDATE, и DELETE — и это правильно. Но демо-стенд из-за этого копил кривую историю: + * записи прошлых прогонов оставались с прежними датами, и на кадрах получалось, что + * заявки оплачены РАНЬШЕ, чем пришли деньги (списание с пустого счёта). Переснять кадры + * начисто без сброса журнала невозможно. + * + * Защита от глупости: на боевом окружении команда вообще не запускается (проверка в + * handle()), а здесь дополнительно требуем локальную среду. Отключение триггеров живёт + * ровно на время удаления и возвращается назад в том же соединении. + */ + private function resetMoneyJournal(int $tenantId): void + { + if (! in_array(config('app.env'), ['local', 'testing'], true)) { + $this->warn('Журнал кошелька не сброшен: это не локальная среда. История демо-денег может выглядеть непоследовательно.'); + + return; + } + + DB::transaction(function () use ($tenantId) { + DB::statement("SET LOCAL session_replication_role = 'replica'"); + DB::table('balance_transactions')->where('tenant_id', $tenantId)->delete(); + }); + } + + /** + * «Конкурентное поле» для того же демо-тенанта — конкуренты-стоматологии (выдуманные, но + * правдоподобные), чтобы съёмка семи разборов автоподбора шла на ОДНОМ клиенте вместе с + * остальными тринадцатью, а не на постороннем демо-тенанте займов (было: OmegaDemoFieldSeeder). + * + * Идемпотентно: updateOrCreate по (tenant_id, search_run_id, dedup_key) для конкурентов и + * (competitor_id, dedup_key) для источников — те же естественные ключи, что и в остальной + * команде. + * + * Пометки у номеров (три штатных вида, §14 спеки): 'real' — настоящий, 'substitute' — + * подменный (коллтрекинг), 'uncertain' — «на сайте, проверьте» (не подтверждено ссылкой). + * У «Белый Зуб» — все три пометки на одной карточке (тема «Пометки у найденных номеров»), + * у «Улыбка Плюс» — «uncertain» как единственная пометка (вторая карточка для той же темы). + * + * 🔴 «Белый Зуб (доп. филиал)» — НАСТОЯЩИЙ дубль «Белый Зуб» (тот же site_url): нужен теме + * «Дубли конкурентов». Совпадение ищет CompetitorIdentity::keys() ТОЛЬКО по site_url и + * directory_urls самого конкурента (не по источникам) — поэтому у дубля намеренно НЕТ ни + * одного источника: слияние в «Белый Зуб» не добавит и не переставит его карточки источников, + * порядок ✓/⚠/🎭 для темы «Пометки у номеров» остаётся стабильным независимо от того, в каком + * порядке идёт съёмка тем «Дубли конкурентов» и «Пометки у номеров» в одном прогоне. + * + * directory_urls: id карточек 2ГИС/Яндекса — РАЗНЫЕ и НЕ «0» у каждого конкурента (кроме + * намеренного дубля, где сходится site_url). id «0» CompetitorIdentity::directoryKey() + * фильтрует как пустышку — баг 13.07.2026 в OmegaDemoFieldSeeder (все фирмы на /firm/0 ложно + * склеились в дубли) здесь не воспроизводим: используем несовпадающие числовые id. + */ + /** + * Цены на стенде = цены боевого liderra.ru (снимок админки 14.07.2026): сетка за лид + * 55/50/45/40/35/30/25 ₽ и допуслуги «Конкурентного поля» — подбор конкурентов 50 ₽, + * изучение конкурента 10 ₽. + * + * Зачем: кадры разборов снимаются с НАСТОЯЩЕГО портала, и на них видны цены. Локальные + * тестовые тарифы (500→250 ₽ из PricingTierSeeder, допуслуги 300/50 ₽) в десять раз выше + * боевых — гость на лендинге увидел бы чужой ценник и ушёл. Правится тут, а не в + * PricingTierSeeder: тот — общий дефолт для тестов, его трогать нельзя. + */ + private function seedPrices(): void + { + $grid = [ + ['tier_no' => 1, 'leads_in_tier' => 500, 'price_per_lead_kopecks' => 55_00], + ['tier_no' => 2, 'leads_in_tier' => 500, 'price_per_lead_kopecks' => 50_00], + ['tier_no' => 3, 'leads_in_tier' => 1500, 'price_per_lead_kopecks' => 45_00], + ['tier_no' => 4, 'leads_in_tier' => 2500, 'price_per_lead_kopecks' => 40_00], + ['tier_no' => 5, 'leads_in_tier' => 5000, 'price_per_lead_kopecks' => 35_00], + ['tier_no' => 6, 'leads_in_tier' => 5000, 'price_per_lead_kopecks' => 30_00], + ['tier_no' => 7, 'leads_in_tier' => null, 'price_per_lead_kopecks' => 25_00], + ]; + + // Портал берёт действующую версию сетки по дате (effective_from ≤ сегодня). Прежние + // версии гасим, чтобы на кадрах не всплыл старый тестовый ценник. + PricingTier::query()->update(['is_active' => false]); + + foreach ($grid as $row) { + PricingTier::query()->updateOrCreate( + ['tier_no' => $row['tier_no'], 'effective_from' => '2026-01-01'], + $row + ['is_active' => true] + ); + } + + $services = [ + 'autopodbor_price_search_rub' => ['50', 'Сбор конкурентов — ₽ за успешный подбор'], + 'autopodbor_price_study_rub' => ['10', 'Сбор источников — ₽ за изучение конкурента'], + ]; + + foreach ($services as $key => [$value, $description]) { + SystemSetting::query()->updateOrCreate( + ['key' => $key], + [ + 'value' => $value, + 'type' => 'string', + 'description' => $description, + 'updated_at' => now(), + ] + ); + } + } + + private function seedCompetitorField(Tenant $tenant): void + { + // Фича выключена системным флагом по умолчанию (миграция 2026_06_28_110000, value='0') — + // без этого /autopodbor у демо-клиента был бы недоступен. Флаг общесистемный, не per-tenant. + SystemSetting::query()->updateOrCreate( + ['key' => 'autopodbor_enabled'], + [ + 'value' => '1', + 'type' => 'bool', + 'description' => 'Автоподбор конкурентов: вкл/выкл вкладку', + 'updated_at' => now(), + ] + ); + + // 🔴 Поле конкурентов пересобираем НАЧИСТО. Оно живое: в портале конкурентов + // склеивают, добавляют вручную, удаляют — и демо-поле после первого же опыта + // перестаёт совпадать с тем, что ждут сценарии съёмки (они целятся в карточки по + // номеру: «вторая», «третья»). Реальный случай 14.07.2026: после проверки склейки + // «Дента-Люкс» исчезла, завелись чужие карточки — и робот-съёмщик не нашёл легенду + // пометок, потому что вторая карточка оказалась без источников. Правки касаются + // ТОЛЬКО демо-тенанта; проекты на автоподбор не ссылаются (внешних ключей нет). + AutopodborSource::query()->where('tenant_id', $tenant->id)->delete(); + AutopodborCompetitor::query()->where('tenant_id', $tenant->id)->delete(); + AutopodborMergeEvent::query()->where('tenant_id', $tenant->id)->delete(); + + $run = AutopodborRun::query()->updateOrCreate( + ['tenant_id' => $tenant->id, 'kind' => 'search', 'region_code' => 77], + [ + 'status' => 'done', + 'params' => ['region' => 'Москва'], + 'started_at' => now(), + 'finished_at' => now(), + ] + ); + + $gis = fn (int $id): string => "https://2gis.ru/msk/firm/{$id}"; + $ya = fn (string $slug): string => "https://yandex.ru/maps/org/{$slug}"; + + // box=field — конкуренты, которых клиент уже взял в работу. Экран сортирует карточки по + // убыванию relevance_pct (FieldWorkspaceScreen), поэтому порядок этого массива (relevance + // по убыванию) даёт тот же порядок карточек на экране — сценарии съёмки полагаются на + // конкретные позиции (nth-of-type). + $field = [ + [ + 'name' => 'Дента-Люкс', 'site' => 'denta-lux-demo.ru', 'federal' => false, 'rel' => 96, + 'desc' => 'Сеть стоматологий, брекеты и импланты', + 'dirs' => [$gis(500001), $ya('dentalux')], + 'sources' => [ + ['site', 'denta-lux-demo.ru', null, null], + ['call', '79990000101', 'real', 'city'], + ], + ], + [ + // Тема «Пометки у номеров», карточка 1: все три пометки сразу. + 'name' => 'Белый Зуб', 'site' => 'belyi-zub-demo.ru', 'federal' => false, 'rel' => 91, + 'desc' => 'Стоматология полного цикла, отбеливание', + 'dirs' => [$gis(500002), $ya('belyizub')], + 'sources' => [ + ['call', '79990000102', 'real', 'mobile'], + ['call', '79990000103', 'uncertain', 'city'], + ['call', '79990000104', 'substitute', 'city'], + ], + ], + [ + // Тема «Пометки у номеров», карточка 2: «на сайте — проверьте» отдельной строкой. + 'name' => 'Улыбка Плюс', 'site' => 'ulybka-plus-demo.ru', 'federal' => true, 'rel' => 86, + 'desc' => 'Федеральная сеть, детская и взрослая стоматология', + 'dirs' => [$gis(500003)], + 'sources' => [ + ['site', 'ulybka-plus-demo.ru', null, null], + ['call', '79990000105', 'uncertain', 'mobile'], + ], + ], + [ + // Никогда не изучался — нужен теме «Собрать источники конкурента» (первый сбор). + 'name' => 'Стома-Эксперт', 'site' => 'stoma-expert-demo.ru', 'federal' => true, 'rel' => 81, + 'desc' => 'Федеральная сеть стоматологических клиник', + 'dirs' => [$ya('stomaekspert')], + 'sources' => [], + ], + [ + // Источник без проекта — нужен теме «Проект из найденного источника». + 'name' => 'Мега-Дент', 'site' => 'mega-dent-demo.ru', 'federal' => true, 'rel' => 76, + 'desc' => 'Федеральная сеть, импланты под ключ', + 'dirs' => [$gis(500005)], + 'sources' => [ + ['site', 'mega-dent-demo.ru', null, null], + ['call', '79990000106', 'real', 'tollfree'], + ], + ], + [ + // Настоящий дубль «Белый Зуб» — см. докблок метода. Источников нарочно нет. + 'name' => 'Белый Зуб (доп. филиал)', 'site' => 'belyi-zub-demo.ru', 'federal' => false, 'rel' => 74, + 'desc' => 'Второй кабинет той же сети — филиал на Взлётке', + 'dirs' => [$gis(500006)], + 'sources' => [], + 'dedupSuffix' => ':branch', + ], + ]; + + // box=proposal — найдено движком, клиент ещё не разобрал. Сайты нарочно не пересекаются + // ни с одним конкурентом поля — единственная запланированная дублирующаяся пара выше. + $proposals = [ + ['name' => 'Смайл Студио', 'site' => 'smile-studio-demo.ru', 'federal' => false, 'rel' => 68, 'desc' => 'Студия эстетической стоматологии'], + ['name' => 'Дентал Ай Ти', 'site' => 'dental-it-demo.ru', 'federal' => true, 'rel' => 62, 'desc' => 'Сеть стоматологий с онлайн-записью'], + ]; + + foreach ($field as $c) { + $studied = $c['sources'] !== []; + $comp = AutopodborCompetitor::query()->updateOrCreate( + [ + 'tenant_id' => $tenant->id, + 'search_run_id' => $run->id, + 'dedup_key' => 'site:'.$c['site'].($c['dedupSuffix'] ?? ''), + ], + [ + 'name' => $c['name'], + 'description' => $c['desc'], + 'is_federal' => $c['federal'], + 'relevance_pct' => $c['rel'], + 'origin' => 'auto', + 'box' => 'field', + 'site_url' => $c['site'], + 'directory_urls' => $c['dirs'], + 'elements' => [['name' => $c['name'], 'sites' => [$c['site']], 'cards' => $c['dirs']]], + 'study_run_id' => $studied ? $run->id : null, + 'studied_at' => $studied ? now() : null, + ] + ); + + foreach ($c['sources'] as [$type, $ident, $kind, $ptype]) { + AutopodborSource::query()->updateOrCreate( + ['competitor_id' => $comp->id, 'dedup_key' => $type.':'.$ident], + [ + 'tenant_id' => $tenant->id, + 'study_run_id' => $run->id, + 'signal_type' => $type, + 'identifier' => $ident, + 'phone_kind' => $kind, + 'phone_type' => $ptype, + 'box' => 'field', + 'provenance_label' => $type === 'site' ? 'сайт компании' : 'карточка в 2ГИС', + ] + ); + } + } + + foreach ($proposals as $c) { + AutopodborCompetitor::query()->updateOrCreate( + ['tenant_id' => $tenant->id, 'search_run_id' => $run->id, 'dedup_key' => 'site:'.$c['site']], + [ + 'name' => $c['name'], + 'description' => $c['desc'], + 'is_federal' => $c['federal'], + 'relevance_pct' => $c['rel'], + 'origin' => 'auto', + 'box' => 'proposal', + 'site_url' => $c['site'], + ] + ); + } + } +} diff --git a/app/app/Console/Commands/VisitorsPrune.php b/app/app/Console/Commands/VisitorsPrune.php new file mode 100644 index 00000000..1d60e9bd --- /dev/null +++ b/app/app/Console/Commands/VisitorsPrune.php @@ -0,0 +1,37 @@ +option('days'); + + $deleted = DB::connection('pgsql_supplier') + ->table('site_visitors') + ->where('last_seen_at', '<', now()->subDays($days)) + ->delete(); + + $this->info("Удалено гостей: {$deleted} (старше {$days} дней)"); + + return self::SUCCESS; + } +} diff --git a/app/app/Exceptions/Autopodbor/BatchBudgetException.php b/app/app/Exceptions/Autopodbor/BatchBudgetException.php new file mode 100644 index 00000000..08708dd8 --- /dev/null +++ b/app/app/Exceptions/Autopodbor/BatchBudgetException.php @@ -0,0 +1,15 @@ + (string) config('services.dadata.topup_url') ?: null, 'supplier' => (string) config('services.supplier.topup_url') ?: null, 'yandex_cloud' => $this->ycTopupUrl(), + 'email' => (string) config('services.yandex360.topup_url') ?: null, default => null, }; } diff --git a/app/app/Http/Controllers/Api/AdminSystemSettingsController.php b/app/app/Http/Controllers/Api/AdminSystemSettingsController.php index c031a259..d248487c 100644 --- a/app/app/Http/Controllers/Api/AdminSystemSettingsController.php +++ b/app/app/Http/Controllers/Api/AdminSystemSettingsController.php @@ -31,12 +31,21 @@ use Illuminate\Support\Facades\DB; */ class AdminSystemSettingsController extends Controller { + /** Ключи-секреты: значение не отдаём в общий список (хранится зашифрованно). */ + private const SENSITIVE_KEYS = ['supplier_webhook_secret']; + /** GET /api/admin/system-settings */ public function index(): JsonResponse { - return response()->json([ - 'settings' => SystemSetting::orderBy('key')->get(), - ]); + $settings = SystemSetting::orderBy('key')->get()->map(function (SystemSetting $s) { + if (in_array($s->key, self::SENSITIVE_KEYS, true)) { + $s->value = $s->value === '' ? '' : '••• (скрыто)'; + } + + return $s; + }); + + return response()->json(['settings' => $settings]); } /** PUT /api/admin/system-settings/{key} */ diff --git a/app/app/Http/Controllers/Api/AdminVisitorsController.php b/app/app/Http/Controllers/Api/AdminVisitorsController.php new file mode 100644 index 00000000..3361611e --- /dev/null +++ b/app/app/Http/Controllers/Api/AdminVisitorsController.php @@ -0,0 +1,142 @@ +query('period', '7d')) { + 'today' => now()->startOfDay(), + '30d' => now()->subDays(30), + default => now()->subDays(7), + }; + + return [$from, now()]; + } + + /** Гости-люди периода (без хостингов/VPN). */ + private function peopleIds(Carbon $from, Carbon $to): Builder + { + return DB::table('site_visitors') + ->whereBetween('first_seen_at', [$from, $to]) + ->where('is_datacenter', false) + ->select('id'); + } + + public function funnel(Request $request): JsonResponse + { + [$from, $to] = $this->periodRange($request); + + $counts = []; + foreach (['view', 'alive', 'cta_click', 'register_open', 'register_done', 'login_done'] as $e) { + $counts[$e] = (int) DB::table('site_events') + ->whereBetween('occurred_at', [$from, $to]) + ->where('event', $e) + ->whereIn('visitor_id', $this->peopleIds($from, $to)) + ->distinct('visitor_id') + ->count('visitor_id'); + } + + $vpn = (int) DB::table('site_visitors') + ->whereBetween('first_seen_at', [$from, $to]) + ->where('is_datacenter', true) + ->count(); + + return response()->json([ + 'period' => (string) $request->query('period', '7d'), + 'funnel' => $counts, + 'vpn_or_hosting' => $vpn, + ]); + } + + public function channels(Request $request): JsonResponse + { + [$from, $to] = $this->periodRange($request); + + $rows = DB::table('site_visitors as v') + ->whereBetween('v.first_seen_at', [$from, $to]) + ->where('v.is_datacenter', false) + ->groupBy('v.channel') + ->select([ + 'v.channel', + DB::raw('COUNT(*)::int AS people'), + DB::raw("COUNT(*) FILTER (WHERE EXISTS (SELECT 1 FROM site_events e WHERE e.visitor_id = v.id AND e.event = 'cta_click'))::int AS cta_clicks"), + DB::raw("COUNT(*) FILTER (WHERE EXISTS (SELECT 1 FROM site_events e WHERE e.visitor_id = v.id AND e.event = 'register_done'))::int AS registrations"), + DB::raw("COUNT(*) FILTER (WHERE EXISTS (SELECT 1 FROM site_events e WHERE e.visitor_id = v.id AND e.event = 'login_done'))::int AS logins"), + ]) + ->orderByDesc('people') + ->get(); + + return response()->json(['channels' => $rows]); + } + + public function visits(Request $request): JsonResponse + { + [$from, $to] = $this->periodRange($request); + + $rows = DB::table('site_visitors as v') + ->whereBetween('v.first_seen_at', [$from, $to]) + ->orderByDesc('v.first_seen_at') + ->limit(200) + ->select([ + 'v.id', 'v.first_seen_at', 'v.channel', 'v.device', 'v.city', 'v.asn_org', + 'v.is_datacenter', 'v.is_human', 'v.ip', 'v.tenant_id', + DB::raw('(SELECT array_agg(DISTINCT e.event) FROM site_events e WHERE e.visitor_id = v.id) AS steps'), + ]) + ->get() + ->map(function (object $r) { + $r->steps = $r->steps ? explode(',', trim((string) $r->steps, '{}')) : []; + $r->is_datacenter = (bool) $r->is_datacenter; + $r->is_human = (bool) $r->is_human; + + return $r; + }); + + return response()->json(['visits' => $rows]); + } + + public function portalActivity(Request $request): JsonResponse + { + [$from, $to] = $this->periodRange($request); + + $screens = DB::table('site_events') + ->whereBetween('occurred_at', [$from, $to]) + ->where('event', 'portal_screen') + ->whereNotNull('screen') + ->groupBy('screen') + ->select(['screen', DB::raw('COUNT(*)::int AS opens'), DB::raw('COUNT(DISTINCT visitor_id)::int AS people')]) + ->orderByDesc('opens') + ->limit(20) + ->get(); + + $clients = DB::table('site_visitors as v') + ->join('tenants as t', 't.id', '=', 'v.tenant_id') + ->whereBetween('v.last_seen_at', [$from, $to]) + ->whereNotNull('v.tenant_id') + ->groupBy('t.id', 't.organization_name', 't.subdomain') + ->select(['t.organization_name as name', 't.subdomain', DB::raw('MAX(v.last_seen_at) AS last_seen_at')]) + ->orderByDesc('last_seen_at') + ->limit(50) + ->get(); + + return response()->json(['screens' => $screens, 'clients' => $clients]); + } +} diff --git a/app/app/Http/Controllers/Api/AdminYandex360BalanceController.php b/app/app/Http/Controllers/Api/AdminYandex360BalanceController.php new file mode 100644 index 00000000..21842f0f --- /dev/null +++ b/app/app/Http/Controllers/Api/AdminYandex360BalanceController.php @@ -0,0 +1,42 @@ +json($this->store->status()); + } + + /** PUT /api/admin/settings/yandex360-balance — {balance:number|null} */ + public function update(Request $request): JsonResponse + { + $raw = $request->input('balance'); + $balance = ($raw === null || $raw === '') ? null : (float) $raw; + if ($balance !== null && $balance < 0) { + return response()->json([ + 'message' => 'Баланс не может быть отрицательным.', + 'errors' => ['balance' => ['Введите сумму ≥ 0 или очистите поле.']], + ], 422); + } + + $this->store->set($balance); + + return response()->json($this->store->status()); + } +} diff --git a/app/app/Http/Controllers/Api/AuthController.php b/app/app/Http/Controllers/Api/AuthController.php index 519665d4..02ab3dd9 100644 --- a/app/app/Http/Controllers/Api/AuthController.php +++ b/app/app/Http/Controllers/Api/AuthController.php @@ -12,6 +12,7 @@ use App\Models\ImpersonationToken; use App\Models\Tenant; use App\Models\User; use App\Services\NotificationService; +use App\Services\Tracking\VisitTracker; use App\Services\UserSessionTracker; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -144,6 +145,9 @@ class AuthController extends Controller $this->logAuthEvent('login_success', $user->id, $user->tenant_id, $user->email, $ip, $request->userAgent(), null); app(UserSessionTracker::class)->record($request, $user->id); + // Учёт посетителей (spec 2026-07-13): связываем гостя с лендинга и вход в кабинет. + app(VisitTracker::class)->trackAuth($request, (int) $user->tenant_id, (int) $user->id, 'login_done'); + return response()->json([ 'user' => $this->userResource($user), 'requires_2fa' => false, diff --git a/app/app/Http/Controllers/Api/AutopodborController.php b/app/app/Http/Controllers/Api/AutopodborController.php new file mode 100644 index 00000000..f931dd2a --- /dev/null +++ b/app/app/Http/Controllers/Api/AutopodborController.php @@ -0,0 +1,1152 @@ +user()->tenant_id; + + $runs = AutopodborRun::where('tenant_id', $tenantId) + ->orderByDesc('id') + ->limit(20) + ->get(); + + return response()->json([ + 'enabled' => SystemSettings::bool('autopodbor_enabled'), + 'runs' => RunResource::collection($runs), + 'prices' => [ + 'search' => (string) (SystemSettings::get('autopodbor_price_search_rub') ?? '0'), + 'study' => (string) (SystemSettings::get('autopodbor_price_study_rub') ?? '0'), + ], + ]); + } + + /** GET /api/autopodbor/runs/{run} */ + public function run(Request $request, int $run): JsonResponse + { + $r = AutopodborRun::where('tenant_id', $request->user()->tenant_id) + ->findOrFail($run); + + return response()->json(['data' => new RunResource($r)]); + } + + /** GET /api/autopodbor/competitors/{competitor} */ + public function competitor(Request $request, int $competitor, AutopodborDedup $dedup): JsonResponse + { + $tenantId = $request->user()->tenant_id; + + $comp = AutopodborCompetitor::where('tenant_id', $tenantId) + ->with('sources.project') + ->findOrFail($competitor); + + $sources = $comp->sources->map(function (AutopodborSource $s) use ($dedup) { + $existingProjectId = $s->created_project_id + ?? $dedup->existingProjectId($s->tenant_id, $s->signal_type, $s->identifier); + + return array_merge( + (new SourceResource($s))->resolve(), + [ + 'existing_project_id' => $existingProjectId, + 'project' => $this->projectStatus($s->project), + ] + ); + }); + + // Идёт ли по этому конкуренту сбор источников прямо сейчас — чтобы фронт подхватил индикатор + // «идёт сбор / этап» при заходе на карточку (после ухода со страницы опрос в памяти теряется). + $activeRun = AutopodborRun::where('tenant_id', $tenantId) + ->where('competitor_id', $comp->id) + ->whereIn('status', ['queued', 'running']) + ->orderByDesc('id') + ->first(); + + return response()->json([ + 'data' => new CompetitorResource($comp), + 'sources' => $sources, + 'active_run' => $activeRun !== null ? new RunResource($activeRun) : null, + ]); + } + + /** GET /api/autopodbor/runs/{run}/competitors */ + public function runCompetitors(Request $request, int $run): JsonResponse + { + $tenantId = $request->user()->tenant_id; + // убедимся, что прогон принадлежит tenant (404 если чужой) + AutopodborRun::where('tenant_id', $tenantId)->findOrFail($run); + + $competitors = AutopodborCompetitor::where('tenant_id', $tenantId) + ->where('search_run_id', $run) + ->orderByDesc('relevance_pct') + ->orderBy('id') + ->get(); + + return response()->json(['data' => CompetitorResource::collection($competitors)]); + } + + /** POST /api/autopodbor/search */ + public function search(Request $request, AutopodborRunService $svc): JsonResponse + { + $v = $request->validate([ + 'region_code' => 'required|integer', + 'examples' => 'array', + 'about_self' => 'array', + 'include_federal' => 'boolean', + ]); + + try { + $run = $svc->startSearch( + $request->user()->tenant_id, + (int) $v['region_code'], + $v['examples'] ?? [], + $v['about_self'] ?? [], + (bool) ($v['include_federal'] ?? false), + ); + + return response()->json(['data' => new RunResource($run)], 201); + } catch (RunInFlightException) { + return response()->json(['error' => 'run_in_flight'], 409); + } catch (InsufficientBalanceException) { + return response()->json(['error' => 'balance_insufficient'], 409); + } + } + + /** POST /api/autopodbor/study */ + public function study(Request $request, AutopodborRunService $svc): JsonResponse + { + $v = $request->validate([ + 'competitor_id' => 'required|integer', + ]); + + try { + $run = $svc->startStudy( + $request->user()->tenant_id, + (int) $v['competitor_id'], + ); + + return response()->json(['data' => new RunResource($run)], 201); + } catch (RunInFlightException) { + return response()->json(['error' => 'run_in_flight'], 409); + } catch (InsufficientBalanceException) { + return response()->json(['error' => 'balance_insufficient'], 409); + } + } + + /** 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 + { + $v = $request->validate([ + 'name' => 'required|string', + 'region_code' => 'required|integer', + ]); + + try { + $run = $svc->startResolve( + $request->user()->tenant_id, + $v['name'], + (int) $v['region_code'], + ); + + return response()->json(['data' => new RunResource($run)], 201); + } catch (RunInFlightException) { + return response()->json(['error' => 'run_in_flight'], 409); + } + } + + /** POST /api/autopodbor/manual-study */ + public function manualStudy(Request $request, AutopodborRunService $svc, AutopodborNormalizer $norm): JsonResponse + { + $v = $request->validate([ + 'competitor_id' => ['nullable', 'integer'], + 'name' => ['nullable', 'string', 'max:255'], + 'site_url' => ['nullable', 'string', 'max:2000'], + 'directory' => ['nullable', 'string', 'max:2000'], + 'region_code' => ['required', 'integer'], + ]); + $uid = $request->user()->tenant_id; + + try { + if (! empty($v['competitor_id'])) { + $run = $svc->startStudy($uid, (int) $v['competitor_id']); + } else { + $site = ! empty($v['site_url']) ? $norm->domainHead($v['site_url']) : null; + $name = ! empty($v['name']) ? $v['name'] : ($site ?? 'Конкурент'); + if (empty($v['name']) && $site === null) { + return response()->json(['error' => 'name_or_site_required'], 422); + } + $run = $svc->startManualStudy($uid, [ + 'name' => $name, + 'site_url' => $site, + 'directory_urls' => ! empty($v['directory']) ? [$v['directory']] : [], + ], (int) $v['region_code']); + } + } catch (RunInFlightException) { + return response()->json(['error' => 'run_in_flight'], 409); + } catch (InsufficientBalanceException) { + return response()->json(['error' => 'balance_insufficient'], 409); + } + + return response()->json(['data' => new RunResource($run)], 201); + } + + /** + * GET /api/autopodbor/field — рабочее место «Конкурентное поле». + * Конкуренты в ящике «поле» с их источниками в поле, статусом проекта по каждому + * источнику и счётчиками (источников / создано проектов / в работе). + */ + public function field(Request $request): JsonResponse + { + $tenantId = $request->user()->tenant_id; + + $competitors = AutopodborCompetitor::where('tenant_id', $tenantId) + ->where('box', 'field') + ->with(['sources' => function ($q) { + $q->where('box', 'field')->with('project'); + }]) + ->orderByDesc('relevance_pct') + ->orderBy('id') + ->get(); + + $payload = $competitors->map(function (AutopodborCompetitor $comp) { + $sources = $comp->sources->map(fn (AutopodborSource $s) => array_merge( + (new SourceResource($s))->resolve(), + ['project' => $this->projectStatus($s->project)], + )); + + $created = $comp->sources->filter(fn ($s) => $s->project !== null); + $inWork = $created->filter( + fn ($s) => $s->project->is_active && $s->project->preflight_blocked_at === null + ); + + return array_merge( + (new CompetitorResource($comp))->resolve(), + [ + 'counters' => [ + 'sources' => $comp->sources->count(), + 'projects_created' => $created->count(), + 'projects_in_work' => $inWork->count(), + ], + 'sources' => $sources, + ], + ); + }); + + return response()->json(['competitors' => $payload]); + } + + /** + * GET /api/autopodbor/field/duplicate-groups — группы конкурентов-кандидатов на ручную склейку + * («Почистить поле»). Опознавалка — та же, что защита повторного поиска ({@see CompetitorIdentity}: + * сайт + карточки), ПЛЮС вскрывшиеся при изучении телефоны/сайты из источников. Клиент подтверждает + * склейку сам (см. merge) — здесь только предлагаем. + */ + public function duplicateGroups(Request $request, AutopodborDedup $dedup, CompetitorIdentity $identity, AutopodborCompetitorMerger $merger): JsonResponse + { + $tenantId = $request->user()->tenant_id; + + // Ищем дубли внутри одного ящика: поле (по умолчанию) или предложения (кнопка в «Предложениях»). + // scope=cross — кросс-ящик поле↔предложения: предложение-дубль карточки поля тоже всплывёт (иначе + // проект-конкурент из предложений, напр. ЖК застройщика, к карточке поля не подцепится — merge + // кросс-ящик уже умеет). Обратная совместимость: без scope — прежний одиночный ящик. + $cross = $request->query('scope') === 'cross'; + $box = in_array($request->query('box'), ['field', 'proposal'], true) + ? $request->query('box') + : 'field'; + + $competitors = AutopodborCompetitor::where('tenant_id', $tenantId) + ->when($cross, + fn ($q) => $q->whereIn('box', ['field', 'proposal']), + fn ($q) => $q->where('box', $box), + ) + ->with('sources') + ->orderBy('id') + ->get(); + + $items = $competitors->map(function (AutopodborCompetitor $c) use ($identity): array { + $keys = $identity->keys(['site_url' => $c->site_url, 'directory_urls' => $c->directory_urls ?? []]); + // Телефоны, вскрытые при сборе (поле phones) — чтобы совпадение по телефону всплывало + // уже на «Предложениях», где источников ещё нет. Людный/рекламный номер отсеет duplicateGroups. + foreach ($c->phones ?? [] as $ph) { + $ph = trim((string) $ph); + if ($ph !== '') { + $keys[] = 'p:'.$ph; + } + } + foreach ($c->sources as $s) { + if ($s->identifier !== '') { + if ($s->signal_type === 'call') { + $keys[] = 'p:'.$s->identifier; // вскрытый телефон + } elseif ($s->signal_type === 'site') { + $keys[] = 's:'.$s->identifier; // вскрытый домен (голова) + } + } + // Адрес филиала (a:) — слабый сигнал; людные адреса-бизнес-центры отсеет duplicateGroups. + $office = trim((string) ($s->office ?? '')); + if ($office !== '') { + $keys[] = 'a:'.mb_strtolower(preg_replace('/\s+/u', ' ', $office) ?? $office, 'UTF-8'); + } + } + + return ['id' => $c->id, 'keys' => array_values(array_unique($keys))]; + })->all(); + + $byId = $competitors->keyBy('id'); + + $rawGroups = $dedup->duplicateGroups($items); + // Кросс-ящик на экране поля: оставляем ТОЛЬКО группы, где есть хотя бы одна карточка ПОЛЯ + // (чтобы клиент видел «предложение — дубль вашей карточки», а не пары чисто-предложений). + if ($cross) { + $rawGroups = array_values(array_filter( + $rawGroups, + fn (array $g): bool => collect($g['ids'])->contains(fn (int $id): bool => $byId->get($id)?->box === 'field'), + )); + } + + $groups = array_map(function (array $g) use ($byId, $merger): array { + // Какая карточка ОСТАНЕТСЯ после склейки (та же логика, что и при merge) — чтобы окно + // показало «останется эта» и не затёрло имя вслепую (баг «САПС/Метрополис»). + $members = collect($g['ids'])->map(fn (int $id) => $byId->get($id))->filter()->values(); + $survivorId = $members->count() >= 2 ? $merger->survivorId($members) : ($g['ids'][0] ?? null); + + return [ + 'reason' => $g['reason'], + // Сайт + ссылки-справочники каждой фирмы — чтобы клиент открыл и сам решил: одна фирма или разные. + 'competitors' => array_map(fn (int $id): array => [ + 'id' => $id, + 'name' => $byId->get($id)?->name, + 'box' => $byId->get($id)?->box, // поле / предложение — чтобы клиент понял, что цепляем + 'site_url' => $byId->get($id)?->site_url, + 'directory_urls' => $byId->get($id)?->directory_urls ?? [], + 'survivor' => $id === $survivorId, // true → эта карточка останется + ], $g['ids']), + ]; + }, $rawGroups); + + return response()->json(['groups' => array_values($groups)]); + } + + /** + * POST /api/autopodbor/field/merge — сливает подтверждённую клиентом группу дублей в одного. + * Project-safe (см. {@see AutopodborCompetitorMerger}). Возвращает id выжившего. + */ + public function mergeCompetitors(Request $request, AutopodborCompetitorMerger $merger): JsonResponse + { + $tenantId = $request->user()->tenant_id; + + $v = $request->validate([ + 'competitor_ids' => 'required|array|min:2', + 'competitor_ids.*' => 'integer', + 'name' => 'sometimes|nullable|string|max:255', + ]); + + // Явная проверка принадлежности тенанту (RLS тоже страхует): реально существующих ≥2. + $ownedCount = AutopodborCompetitor::where('tenant_id', $tenantId) + ->whereIn('id', $v['competitor_ids']) + ->count(); + if ($ownedCount < 2) { + return response()->json(['error' => 'invalid_group'], 422); + } + + $survivorId = $merger->merge($tenantId, $v['competitor_ids'], $v['name'] ?? null, $request->user()->id); + + return response()->json(['survivor_id' => $survivorId]); + } + + /** + * GET /api/autopodbor/field/merge-events — журнал слияний (что во что слили + снимок поглощённых). + * Делает склейку прозрачной: видно историю и можно вернуть поглощённую карточку. + */ + public function mergeEvents(Request $request): JsonResponse + { + $tenantId = $request->user()->tenant_id; + + $events = AutopodborMergeEvent::where('tenant_id', $tenantId) + ->orderByDesc('id') + ->limit(50) + ->get(); + + return response()->json([ + 'events' => $events->map(fn (AutopodborMergeEvent $e): array => [ + 'id' => $e->id, + 'survivor_id' => $e->survivor_id, + 'survivor_name' => $e->survivor_name, + 'reason' => $e->reason, + 'restored' => $e->restored, + 'created_at' => $e->created_at?->toIso8601String(), + // имена поглощённых — чтобы клиент видел «Метрополис → в СМ-сити» + 'absorbed' => collect($e->absorbed ?? [])->map(fn (array $a): array => [ + 'id' => $a['id'] ?? null, + 'name' => $a['name'] ?? '', + 'box' => $a['box'] ?? null, + ])->values()->all(), + ])->all(), + ]); + } + + /** + * POST /api/autopodbor/field/merge-events/{event}/restore — возвращает поглощённые карточки из + * снимка события как отдельные карточки-предложения. Кроме самой карточки-фирмы, возвращает и её + * ИСТОЧНИКИ (autopodbor_sources) — а вместе с источником, у которого был ПРОЕКТ (по нему идут + * заявки/деньги), возвращает и сам проект: перевешивает его created_project_id обратно на + * воскрешённую карточку и откатывает имя проекта к тому, что было до склейки. Без этого клиент + * видел пустую воскрешённую карточку, а его проект оставался под именем чужой фирмы (13.07.2026). + * + * Старые записи журнала (до появления снимка absorbed_sources) откатываются как раньше — только + * карточка, без источников/проектов, потому что снимка физически нет. Ответ помечает такой случай + * 'partial' => true, чтобы фронт мог честно предупредить клиента. + */ + public function restoreMergeEvent(Request $request, int $event): JsonResponse + { + $tenantId = $request->user()->tenant_id; + + $ev = AutopodborMergeEvent::where('tenant_id', $tenantId)->find($event); + if ($ev === null) { + return response()->json(['error' => 'not_found'], 404); + } + if ($ev->restored) { + return response()->json(['error' => 'already_restored'], 422); + } + + $hasSourceSnapshot = $ev->absorbed_sources !== null; + + $created = []; + foreach ($ev->absorbed ?? [] as $a) { + $comp = AutopodborCompetitor::create([ + 'tenant_id' => $tenantId, + 'name' => $a['name'] ?? 'Возвращённая карточка', + 'description' => $a['description'] ?? null, + 'is_federal' => (bool) ($a['is_federal'] ?? false), + 'site_url' => $a['site_url'] ?? null, + 'directory_urls' => $a['directory_urls'] ?? [], + 'phones' => $a['phones'] ?? [], + 'box' => 'proposal', // возвращаем как предложение — клиент решит, что дальше + 'dedup_key' => 'restore:'.$ev->id.':'.($a['id'] ?? uniqid()), + ]); + + $sourcesRestored = $hasSourceSnapshot + ? $this->restoreAbsorbedSources($tenantId, $comp, $ev->absorbed_sources[$a['id']] ?? []) + : 0; + + $created[] = ['id' => $comp->id, 'name' => $comp->name, 'sources_restored' => $sourcesRestored]; + } + + $ev->update(['restored' => true]); + + return response()->json([ + 'restored' => $created, + 'partial' => ! $hasSourceSnapshot, + 'message' => $hasSourceSnapshot + ? null + : 'Частичный откат: снимок источников для этого слияния не сохранялся — вернулась только карточка, проекты (если были) остались у объединённой фирмы.', + ]); + } + + /** + * Возвращает источники поглощённого конкурента на воскрешённую карточку $comp по снимку $snapshots + * (см. {@see AutopodborCompetitorMerger}). Если исходная строка источника + * ещё существует (обычный случай — при слиянии её просто перевесили на выжившего) — перевешиваем + * её competitor_id ОБРАТНО, а не создаём дубль. Если строка была удалена при разрешении коллизии + * dedup_key во время слияния — восстанавливаем её из снимка заново. Проект (если был приклеен) + * возвращаем ТОЛЬКО этой карточке и откатываем его имя к тому, что было до склейки. + * + * @param array> $snapshots + */ + private function restoreAbsorbedSources(int $tenantId, AutopodborCompetitor $comp, array $snapshots): int + { + $restored = 0; + foreach ($snapshots as $s) { + $existing = ($s['id'] ?? null) !== null + ? AutopodborSource::where('tenant_id', $tenantId)->find($s['id']) + : null; + + if ($existing !== null) { + $existing->competitor_id = $comp->id; + $existing->save(); + $projectId = $existing->created_project_id; + } else { + $new = AutopodborSource::create([ + 'tenant_id' => $tenantId, + 'competitor_id' => $comp->id, + 'study_run_id' => $s['study_run_id'], + 'signal_type' => $s['signal_type'], + 'identifier' => $s['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, + 'dedup_key' => $s['dedup_key'], + 'created_project_id' => $s['created_project_id'] ?? null, + 'box' => $s['box'] ?? 'field', + 'where_found' => $s['where_found'] ?? null, + 'office' => $s['office'] ?? null, + 'confirmations' => $s['confirmations'] ?? 1, + ]); + $projectId = $new->created_project_id; + } + $restored++; + + if ($projectId !== null && filled($s['project_name_before'] ?? null)) { + Project::where('tenant_id', $tenantId)->where('id', $projectId) + ->update(['name' => $s['project_name_before']]); + } + } + + return $restored; + } + + /** + * POST /api/autopodbor/competitors/manual — завести конкурента вручную сразу В ПОЛЕ, + * без запуска изучения (§14.2 «+ Добавить вручную»). Изучение источников — отдельно, по кнопке. + */ + public function manualCompetitor(Request $request, AutopodborNormalizer $norm): JsonResponse + { + $v = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string', 'max:2000'], + 'is_federal' => ['boolean'], + 'relevance_pct' => ['nullable', 'integer', 'min:0', 'max:100'], + 'site_url' => ['nullable', 'string', 'max:2000'], + 'directory' => ['nullable', 'string', 'max:2000'], + 'directory_urls' => ['nullable', 'array'], + 'directory_urls.*' => ['string', 'max:2000'], + 'elements' => ['nullable', 'array'], + 'elements.*.name' => ['sometimes', 'nullable', 'string', 'max:255'], + 'elements.*.sites' => ['sometimes', 'array'], + 'elements.*.sites.*' => ['nullable', 'string', 'max:2000'], + 'elements.*.cards' => ['sometimes', 'array'], + 'elements.*.cards.*' => ['nullable', 'string', 'max:2000'], + ]); + $uid = $request->user()->tenant_id; + + $elements = CompetitorElements::normalize($v['elements'] ?? []); + + if ($elements !== []) { + // Элементы заданы формой — они источник правды; легаси-поля выводим из них. + $legacy = CompetitorElements::deriveLegacy($elements); + $site = $legacy['site_url'] !== null ? $norm->domainHead($legacy['site_url']) : null; + $dirs = $legacy['directory_urls']; + } else { + // Легаси-путь (одно поле сайт + справочники) — соберём один элемент для консистентности. + $site = ! empty($v['site_url']) ? $norm->domainHead($v['site_url']) : null; + $dirs = $v['directory_urls'] ?? (! empty($v['directory']) ? [$v['directory']] : []); + $dirs = array_values(array_filter(array_map('trim', $dirs))); + $elements = ($site !== null || $dirs !== []) + ? [['name' => $v['name'], 'sites' => $site !== null ? [$site] : [], 'cards' => $dirs]] + : []; + } + + $comp = AutopodborCompetitor::create([ + 'tenant_id' => $uid, + 'search_run_id' => null, + 'name' => $v['name'], + 'description' => $v['description'] ?? null, + 'is_federal' => (bool) ($v['is_federal'] ?? false), + 'relevance_pct' => $v['relevance_pct'] ?? null, + 'origin' => 'manual', + 'box' => 'field', + 'site_url' => $site, + 'directory_urls' => $dirs, + 'elements' => $elements, + 'dedup_key' => $norm->competitorKey($v['name'], $site), + ]); + + return response()->json(['data' => new CompetitorResource($comp)], 201); + } + + /** PATCH /api/autopodbor/competitors/{id} — правка полей карточки конкурента */ + public function updateCompetitor(Request $request, int $competitor, AutopodborProjectCreator $creator): JsonResponse + { + $v = $request->validate([ + 'name' => ['sometimes', 'string', 'max:255'], + 'description' => ['sometimes', 'nullable', 'string', 'max:2000'], + 'is_federal' => ['sometimes', 'boolean'], + 'relevance_pct' => ['sometimes', 'nullable', 'integer', 'min:0', 'max:100'], + 'site_url' => ['sometimes', 'nullable', 'string', 'max:2000'], + 'directory_urls' => ['sometimes', 'array'], + 'directory_urls.*' => ['string', 'max:2000'], + 'elements' => ['sometimes', 'array'], + 'elements.*.name' => ['sometimes', 'nullable', 'string', 'max:255'], + 'elements.*.sites' => ['sometimes', 'array'], + 'elements.*.sites.*' => ['nullable', 'string', 'max:2000'], + 'elements.*.cards' => ['sometimes', 'array'], + 'elements.*.cards.*' => ['nullable', 'string', 'max:2000'], + 'box' => ['sometimes', 'string', 'in:proposal,field'], + ]); + + // Элементы — источник правды: нормализуем и ВЫВОДИМ site_url/directory_urls из ВСЕХ + // элементов (никакой потери карточек при правке — прежний баг с пересбором из 2 полей). + if (array_key_exists('elements', $v)) { + $elements = CompetitorElements::normalize($v['elements']); + $legacy = CompetitorElements::deriveLegacy($elements); + $v['elements'] = $elements; + // site_url — колонка VARCHAR(255): кладём ДОМЕН, а не сырой адрес. Люди вставляют + // ссылку целиком, с путём и хвостом параметров; без приведения к домену длинный + // адрес роняет запись в базу (22001) вместо понятного ответа. Как в manualCompetitor. + $v['site_url'] = $legacy['site_url'] !== null + ? app(AutopodborNormalizer::class)->domainHead($legacy['site_url']) + : null; + $v['directory_urls'] = $legacy['directory_urls']; + } + + $comp = AutopodborCompetitor::where('tenant_id', $request->user()->tenant_id) + ->findOrFail($competitor); + + // Переименование карточки — проекты, созданные из её источников, носят имя-снимок конкурента. + // Синхронизируем их имена вслед за новым (см. syncNamesForCompetitor); только отображаемое имя. + $nameChanged = array_key_exists('name', $v) && $v['name'] !== $comp->name; + + $comp->update($v); + + if ($nameChanged) { + $creator->syncNamesForCompetitor($request->user()->tenant_id, $comp); + } + + return response()->json(['data' => new CompetitorResource($comp)]); + } + + /** + * DELETE /api/autopodbor/competitors/{id} — удаление конкурента и его источников. + * Блокируется, если у любого источника есть активный созданный проект + * (управлять проектом нужно через раздел проектов — §14.10). + */ + public function destroyCompetitor(Request $request, int $competitor): JsonResponse + { + $comp = AutopodborCompetitor::where('tenant_id', $request->user()->tenant_id) + ->with('sources.project') + ->findOrFail($competitor); + + $hasActive = $comp->sources->contains( + fn (AutopodborSource $s) => $s->project && $s->project->is_active + ); + + if ($hasActive) { + return response()->json(['error' => 'has_active_projects'], 409); + } + + // Мягкое удаление: прячем конкурента в архив (для группы «ранее удалённые» при повторном + // подборе), источники и опознавалки сохраняем — историю не стираем (спека §4). + $comp->update(['box' => 'archived']); + + return response()->json(null, 204); + } + + /** + * POST /api/autopodbor/competitors/{competitor}/actualize — актуализация сайта фирмы поля (спека §6). + * action=add — добавить новый сайт-источник (старый продолжает работать). action=replace — заменить: + * если по старому сайту есть активный проект → 409 manage_via_project (менять только через раздел + * проектов, §14.10); иначе прежние сайт-источники в архив, новый заводим. Имя фирмы берём из поля — + * не перезатираем. Возвращает id заведённого источника — фронт ведёт клиента в форму создания проекта. + */ + public function actualize(Request $request, int $competitor, AutopodborNormalizer $norm): JsonResponse + { + $v = $request->validate([ + 'action' => ['required', 'in:add,replace'], + 'new_site' => ['required', 'string', 'max:2000'], + ]); + $tenantId = $request->user()->tenant_id; + + $comp = AutopodborCompetitor::where('tenant_id', $tenantId) + ->with('sources.project') + ->findOrFail($competitor); + + if ($comp->study_run_id === null) { + return response()->json(['error' => 'not_studied'], 422); + } + + $site = $norm->domainHead($v['new_site']); + + if ($v['action'] === 'replace') { + $hasActive = $comp->sources->contains( + fn (AutopodborSource $s) => $s->signal_type === 'site' && $s->project && $s->project->is_active + ); + if ($hasActive) { + return response()->json(['error' => 'manage_via_project'], 409); + } + // старый сайт умер: прежние сайт-источники в архив (мягко), новый заведём ниже. + $comp->sources()->where('signal_type', 'site')->update(['box' => 'archived']); + } + + $source = AutopodborSource::updateOrCreate( + ['competitor_id' => $comp->id, 'dedup_key' => $norm->sourceKey('site', $site)], + [ + 'tenant_id' => $tenantId, + 'study_run_id' => $comp->study_run_id, + 'signal_type' => 'site', + 'identifier' => $site, + 'box' => 'field', + 'provenance_label' => 'Актуализация: новый сайт', + ], + ); + + return response()->json(['source_id' => $source->id], 200); + } + + /** + * GET /api/autopodbor/proposals — предложения, разложенные по группам (спека §3, §5.1): + * new (новые) / actualize (на актуализацию) / archived (ранее удалённые). Скрытые полные дубли + * активных фирм не показываем. Сверка «та же фирма» — {@see ProposalClassifier} по опознавалкам. + */ + public function proposals(Request $request, ProposalClassifier $classifier): JsonResponse + { + $tenantId = $request->user()->tenant_id; + + $proposals = AutopodborCompetitor::where('tenant_id', $tenantId)->where('box', 'proposal') + ->orderByDesc('relevance_pct')->orderBy('id')->get(); + $existing = AutopodborCompetitor::where('tenant_id', $tenantId) + ->whereIn('box', ['field', 'archived'])->with('sources')->get(); + + $finds = $proposals->map(fn (AutopodborCompetitor $c) => $this->toClassifyRow($c))->all(); + $state = $existing->map(fn (AutopodborCompetitor $c) => $this->toClassifyRow($c) + ['box' => $c->box])->all(); + + $groups = ['new' => [], 'actualize' => [], 'archived' => []]; + $byId = $proposals->keyBy('id'); + $existingById = $existing->keyBy('id'); + foreach ($classifier->classify($finds, $state) as $row) { + if ($row['group'] === 'hidden') { + continue; // скрытые дубли не показываем + } + $card = (new CompetitorResource($byId->get($row['find']['id'])))->resolve(); + if ($row['group'] === 'actualize') { + $card['matched_id'] = $row['matched_id']; + $card['delta_keys'] = $row['delta_keys']; + $card['matched'] = $this->matchedSummary($existingById->get($row['matched_id'])); + } + $groups[$row['group']][] = $card; + } + + return response()->json(['groups' => $groups]); + } + + /** + * POST /api/autopodbor/proposals/dismiss-archived — убрать всю группу «ранее удалённые» разом + * (снова спрятать в архив всплывшие предложения; §5.1). Новые/актуализацию не трогает. + */ + public function dismissArchivedGroup(Request $request, ProposalClassifier $classifier): JsonResponse + { + $tenantId = $request->user()->tenant_id; + + $proposals = AutopodborCompetitor::where('tenant_id', $tenantId)->where('box', 'proposal')->get(); + $existing = AutopodborCompetitor::where('tenant_id', $tenantId)->whereIn('box', ['field', 'archived'])->get(); + + $finds = $proposals->map(fn (AutopodborCompetitor $c) => $this->toClassifyRow($c))->all(); + $state = $existing->map(fn (AutopodborCompetitor $c) => $this->toClassifyRow($c) + ['box' => $c->box])->all(); + + $ids = collect($classifier->classify($finds, $state)) + ->where('group', 'archived')->pluck('find.id')->all(); + + AutopodborCompetitor::where('tenant_id', $tenantId)->whereIn('id', $ids)->update(['box' => 'archived']); + + return response()->json(['dismissed' => count($ids)]); + } + + /** + * POST /api/autopodbor/competitors/{competitor}/dismiss-actualize — «мягко» отклонить находку + * актуализации: (1) запоминаем её новые ключи (сайт/адрес) у совпавшей фирмы поля, чтобы та же + * находка больше не всплывала (§actualize); (2) находку-предложение убираем в архив, чтобы исчезла + * из ленты сейчас. Саму фирму поля НЕ трогаем. Реально другой новый сайт даст новый ключ → покажется. + */ + public function dismissActualize(Request $request, int $competitor, ProposalClassifier $classifier): JsonResponse + { + $tenantId = $request->user()->tenant_id; + + $find = AutopodborCompetitor::where('tenant_id', $tenantId)->where('box', 'proposal')->findOrFail($competitor); + $existing = AutopodborCompetitor::where('tenant_id', $tenantId)->whereIn('box', ['field', 'archived'])->get(); + + $finds = [$this->toClassifyRow($find)]; + $state = $existing->map(fn (AutopodborCompetitor $c) => $this->toClassifyRow($c) + ['box' => $c->box])->all(); + $row = collect($classifier->classify($finds, $state))->firstWhere('find.id', $find->id); + + if ($row !== null && $row['group'] === 'actualize' && $row['matched_id'] !== null) { + $matched = $existing->firstWhere('id', $row['matched_id']); + if ($matched !== null) { + $matched->update([ + 'dismissed_actualize_keys' => array_values(array_unique(array_merge( + (array) ($matched->dismissed_actualize_keys ?? []), + $row['delta_keys'], + ))), + ]); + } + } + + // Находку-предложение — в архив, чтобы исчезла из ленты сейчас (фирму поля не трогаем). + $find->update(['box' => 'archived']); + + return response()->json(['dismissed' => true]); + } + + /** + * Строка конкурента для {@see ProposalClassifier}: id + опознавалки (сайт/карточки). Имя — для UI. + * + * @return array{id:int,name:string,site_url:?string,directory_urls:array} + */ + private function toClassifyRow(AutopodborCompetitor $c): array + { + return [ + 'id' => $c->id, + 'name' => $c->name, + 'site_url' => $c->site_url, + 'directory_urls' => $c->directory_urls ?? [], + // Отклонённые клиентом ключи актуализации (у фирм поля) — классификатор их вычитает. + 'dismissed_actualize_keys' => $c->dismissed_actualize_keys ?? [], + ]; + } + + /** + * Краткая карточка совпавшей фирмы поля для группы «на актуализацию»: имя (из поля — не трогаем) + + * все её текущие сайты (site_url + сайт-источники) и карточки справочника — фронт показывает их + * ссылками «проверьте перед решением». null — если фирма вдруг не найдена. + * + * @return array{id:int,name:string,sites:list,directory_urls:array}|null + */ + private function matchedSummary(?AutopodborCompetitor $m): ?array + { + if ($m === null) { + return null; + } + + return [ + 'id' => $m->id, + 'name' => $m->name, + 'sites' => collect([$m->site_url]) + ->merge($m->sources->where('signal_type', 'site')->pluck('identifier')) + ->filter()->unique()->values()->all(), + 'directory_urls' => $m->directory_urls ?? [], + ]; + } + + /** PATCH /api/autopodbor/competitors/{id}/box — перенос конкурента предложение↔поле */ + public function competitorBox(Request $request, int $competitor): JsonResponse + { + $v = $request->validate([ + 'box' => ['required', 'string', 'in:proposal,field'], + ]); + + $comp = AutopodborCompetitor::where('tenant_id', $request->user()->tenant_id) + ->findOrFail($competitor); + + $comp->update(['box' => $v['box']]); + + return response()->json(['data' => new CompetitorResource($comp)]); + } + + /** + * PATCH /api/autopodbor/sources/{id} — правка значения/провенанса/ящика источника. + * Тип источника (signal_type) НЕИЗМЕНЯЕМ (как в ProjectService — молча игнорируем). + * Смена самого значения (identifier) у источника с активным проектом запрещена — + * это смена источника проекта, делается через раздел проектов (§14.10). + */ + public function updateSource(Request $request, int $source): JsonResponse + { + $v = $request->validate([ + 'identifier' => ['sometimes', 'string', 'max:500'], + 'phone_kind' => ['sometimes', 'nullable', 'string', 'in:real,substitute'], + 'phone_type' => ['sometimes', 'nullable', 'string', 'in:city,mobile,tollfree'], + 'provenance_url' => ['sometimes', 'nullable', 'string', 'max:500'], // колонка VARCHAR(500) + 'provenance_label' => ['sometimes', 'nullable', 'string', 'max:255'], + 'box' => ['sometimes', 'string', 'in:proposal,field'], + ]); + + $src = AutopodborSource::where('tenant_id', $request->user()->tenant_id) + ->with('project') + ->findOrFail($source); + + $changesIdentifier = array_key_exists('identifier', $v) && $v['identifier'] !== $src->identifier; + if ($changesIdentifier && $src->project && $src->project->is_active) { + return response()->json(['error' => 'manage_via_project'], 409); + } + + $src->update($v); + + return response()->json(['data' => new SourceResource($src)]); + } + + /** + * DELETE /api/autopodbor/sources/{id} — удаление источника. + * Блокируется, если у источника есть активный созданный проект (§14.10). + */ + public function destroySource(Request $request, int $source): JsonResponse + { + $src = AutopodborSource::where('tenant_id', $request->user()->tenant_id) + ->with('project') + ->findOrFail($source); + + if ($src->project && $src->project->is_active) { + return response()->json(['error' => 'has_active_project'], 409); + } + + // Мягкое удаление: прячем в архив, но помним — при повторном сборе источников такой источник, + // если движок найдёт его снова, покажем группой «ранее удалённые — вернуть?» (вдруг передумал). + // Из списков поля/предложений он исчезает (они фильтруют box=field/proposal). + $src->update(['box' => 'archived']); + + return response()->json(null, 204); + } + + /** PATCH /api/autopodbor/sources/{id}/box — перенос источника предложение↔в работу */ + public function sourceBox(Request $request, int $source): JsonResponse + { + $v = $request->validate([ + 'box' => ['required', 'string', 'in:proposal,field'], + ]); + + $src = AutopodborSource::where('tenant_id', $request->user()->tenant_id) + ->findOrFail($source); + + $src->update(['box' => $v['box']]); + + return response()->json(['data' => new SourceResource($src)]); + } + + /** POST /api/autopodbor/sources/manual */ + public function addManualSource(Request $request, AutopodborNormalizer $norm, AutopodborDedup $dedup, AutopodborProjectCreator $creator): JsonResponse + { + $v = $request->validate([ + 'competitor_id' => ['required', 'integer'], + 'raw' => ['required', 'string', 'max:500'], + // #4: force — клиент уже увидел предупреждение о дубле и решил продолжить. + 'force' => ['sometimes', 'boolean'], + // absorb_source_id — источник-дубль у прежнего конкурента, который надо убрать + // («оставить здесь» / «перенести проект сюда»). + 'absorb_source_id' => ['sometimes', 'nullable', 'integer'], + ]); + $uid = $request->user()->tenant_id; + + $comp = AutopodborCompetitor::where('tenant_id', $uid)->findOrFail((int) $v['competitor_id']); + if ($comp->study_run_id === null) { + return response()->json(['error' => 'not_studied'], 422); + } + + $raw = trim($v['raw']); + $digits = preg_replace('/\D+/', '', $raw) ?? ''; + $isCall = strlen($digits) >= 10; + $signalType = $isCall ? 'call' : 'site'; + $identifier = $isCall ? $norm->phone($raw) : $norm->domainHead($raw); + + // #4 — проверка дубля источника (в момент добавления). Если такой же источник уже есть + // у ДРУГОГО конкурента или по нему уже создан проект — не дублируем молча, отдаём клиенту + // решение (удалить/перенести). Пропускаем, если клиент уже подтвердил (force) или источник + // уже есть у ЭТОГО же конкурента (тогда updateOrCreate просто обновит существующий). + $alreadyHere = AutopodborSource::where('tenant_id', $uid) + ->where('competitor_id', $comp->id) + ->where('signal_type', $signalType) + ->where('identifier', $identifier) + ->exists(); + + if (! ($v['force'] ?? false) && ! $alreadyHere) { + $dupSource = AutopodborSource::where('tenant_id', $uid) + ->where('signal_type', $signalType) + ->where('identifier', $identifier) + ->where('competitor_id', '!=', $comp->id) + ->with('competitor') + ->first(); + $projectId = $dedup->existingProjectId($uid, $signalType, $identifier); + $project = $projectId !== null ? Project::where('tenant_id', $uid)->find($projectId) : null; + + if ($dupSource !== null || $project !== null) { + return response()->json([ + 'error' => 'duplicate_source', + 'duplicate' => [ + 'competitor_id' => $dupSource?->competitor_id, + 'competitor_name' => $dupSource?->competitor?->name, + 'source_id' => $dupSource?->id, + 'has_project' => $project !== null, + 'project_id' => $project?->id, + 'project_name' => $project?->name, + ], + ], 409); + } + } + + // Если по идентификатору уже есть проект — привязываем его к новому источнику + // («перенести проект сюда»): проект живёт по номеру/сайту, поставщику ничего не переоформляем. + $existingProjectId = $dedup->existingProjectId($uid, $signalType, $identifier); + + $source = AutopodborSource::updateOrCreate( + ['competitor_id' => $comp->id, 'dedup_key' => $norm->sourceKey($signalType, $raw)], + [ + 'tenant_id' => $uid, + 'study_run_id' => $comp->study_run_id, + 'signal_type' => $signalType, + 'identifier' => $identifier, + 'phone_kind' => $isCall ? 'real' : null, + 'provenance_url' => null, + 'provenance_label' => 'Добавлено вручную', + // Источник, вбитый руками, — доверенный: сразу в работу, а не в предложения. + 'box' => 'field', + 'created_project_id' => $existingProjectId, + ], + ); + + // ИНВАРИАНТ «проект принадлежит РОВНО одному источнику»: при переносе проекта на новый + // источник снимаем его привязку со ВСЕХ прочих источников. Иначе один проект «висит» + // в двух конкурентах сразу (жёсткий баг с дублями). Надёжнее необязательного absorb ниже. + if ($existingProjectId !== null) { + AutopodborSource::where('tenant_id', $uid) + ->where('created_project_id', $existingProjectId) + ->where('id', '!=', $source->id) + ->update(['created_project_id' => null]); + } + + // «Убрать там» / «перенести сюда»: удаляем источник-дубль у прежнего конкурента. + // Проект НЕ трогаем — он живёт по идентификатору и теперь привязан к новому источнику. + if (! empty($v['absorb_source_id'])) { + AutopodborSource::where('tenant_id', $uid) + ->whereKey((int) $v['absorb_source_id']) + ->where('id', '!=', $source->id) + ->delete(); + } + + // «Перенести проект сюда»: проект теперь у нового конкурента — обновляем его имя на новое + // (имя конкурента, при совпадении — с источником; без бессмысленной цифры). + if ($existingProjectId !== null) { + $project = Project::where('tenant_id', $uid)->find($existingProjectId); + if ($project !== null) { + $newName = $creator->nameForSource($uid, $source, $project->id); + if ($newName !== $project->name) { + $project->update(['name' => $newName]); + } + } + } + + return response()->json(['data' => new SourceResource($source)], 201); + } + + /** + * Статус проекта источника для UI (пауза/работа/блок). null — проекта нет. + * + * @return array{id: int, name: string, is_active: bool, paused_at: ?string, preflight_blocked_at: ?string}|null + */ + private function projectStatus(?Project $project): ?array + { + if ($project === null) { + return null; + } + + return [ + 'id' => $project->id, + 'name' => $project->name, + 'signal_identifier' => $project->signal_identifier, + 'is_active' => (bool) $project->is_active, + 'paused_at' => $project->paused_at?->toIso8601String(), + 'preflight_blocked_at' => $project->preflight_blocked_at?->toIso8601String(), + 'daily_limit_target' => (int) $project->daily_limit_target, + 'delivered_in_month' => (int) $project->delivered_in_month, + 'delivery_days_mask' => (int) $project->delivery_days_mask, + 'regions' => $project->regions ?? [], + ]; + } + + /** POST /api/autopodbor/projects */ + public function createProjects(Request $request, AutopodborProjectCreator $creator): JsonResponse + { + $v = $request->validate([ + 'source_ids' => 'required|array', + 'source_ids.*' => 'integer', + 'regions' => 'array', + 'regions.*' => 'integer', + 'daily_limit_target' => 'required|integer|min:1|max:10000', + 'delivery_days_mask' => 'required|integer', + 'launch' => 'boolean', + ]); + + $tenant = $request->user()->tenant; + $launch = (bool) ($v['launch'] ?? false); + + // Гейт реквизитов первого проекта — как в ProjectController@store. + if (Project::where('tenant_id', $tenant->id)->count() === 0 + && ! app(RequisitesService::class)->isLightComplete($tenant)) { + return response()->json(['error' => 'requisites_required'], 422); + } + + $projects = $creator->createFromSources( + $tenant->id, + $v['source_ids'], + [ + 'regions' => $v['regions'] ?? [], + 'daily_limit_target' => (int) $v['daily_limit_target'], + 'delivery_days_mask' => (int) $v['delivery_days_mask'], + ], + $launch, + ); + + $launched = collect($projects)->filter(fn ($p) => $p->is_active)->count(); + $deferred = count($projects) - $launched; + // payload баланса — от первого удержанного (для сообщения «пополните ~X ₽»). + $held = collect($projects)->first(fn ($p) => ($p->launch_deferred ?? false)); + + return response()->json([ + 'data' => collect($projects)->map(fn ($p) => [ + 'id' => $p->id, + 'name' => $p->name, + 'is_active' => (bool) $p->is_active, + ])->all(), + 'launch' => [ + 'launched' => $launched, + 'deferred' => $deferred, + 'balance' => $held?->gate_payload, + ], + ], 201); + } +} diff --git a/app/app/Http/Controllers/Api/BillingController.php b/app/app/Http/Controllers/Api/BillingController.php index 5f14e735..bd168628 100644 --- a/app/app/Http/Controllers/Api/BillingController.php +++ b/app/app/Http/Controllers/Api/BillingController.php @@ -8,6 +8,7 @@ use App\Http\Controllers\Controller; use App\Models\BalanceTransaction; use App\Models\PricingTier; use App\Models\Project; +use App\Models\SaasTransaction; use App\Models\Tenant; use App\Models\User; use App\Repositories\PricingTierRepository; @@ -94,6 +95,40 @@ class BillingController extends Controller * считается на лету через BalanceToLeadsConverter (точный расчёт по * ступеням, не «по текущей»). Тариф унифицирован до name+features. */ + /** + * GET /api/billing/last-payment — судьба последней онлайн-оплаты за сутки. + * + * Кабинет обязан говорить правду: если шлюз отменил платёж (человек не довёл оплату + * до конца), клиент должен увидеть «оплата не завершена» и кнопку «Оплатить снова», + * а не вечное «платёж обрабатывается, баланс скоро обновится» (инцидент 14.07.2026: + * платёж на 10 000 ₽ отменён ЮKassa, а кабинет обещал зачисление). + * + * Успешные платежи не возвращаем — там и так виден пополненный баланс. + */ + public function lastPayment(Request $request): JsonResponse + { + /** @var User $user */ + $user = $request->user(); + + $tx = SaasTransaction::query() + ->where('tenant_id', (int) $user->tenant_id) + ->where('type', SaasTransaction::TYPE_TOPUP) + ->whereIn('status', [SaasTransaction::STATUS_PENDING, SaasTransaction::STATUS_FAILED]) + ->where('created_at', '>=', Carbon::now()->subDay()) + ->latest('id') + ->first(); + + return response()->json([ + 'data' => $tx === null ? null : [ + 'id' => $tx->id, + 'status' => $tx->status, + 'amount_rub' => (string) $tx->amount_rub, + 'failure_reason' => $tx->failure_reason, + 'created_at' => $tx->created_at?->toIso8601String(), + ], + ]); + } + public function wallet(Request $request): JsonResponse { /** @var User $user */ diff --git a/app/app/Http/Controllers/Api/ChatController.php b/app/app/Http/Controllers/Api/ChatController.php new file mode 100644 index 00000000..f4f06a56 --- /dev/null +++ b/app/app/Http/Controllers/Api/ChatController.php @@ -0,0 +1,115 @@ +validate([ + 'chat_id' => ['required', 'string', 'regex:/^[a-f0-9]{32}$/'], + 'text' => ['required', 'string', 'max:1000'], + ]); + + $text = trim($data['text']); + if ($text === '') { + return response()->json(['message' => 'Пустое сообщение.'], 422); + } + + $userId = Auth::id(); + + if ($this->deniesAccess($data['chat_id'])) { + return response()->json(['message' => 'Разговор не найден.'], 404); + } + + $refusal = app(ChatRateLimiter::class) + ->check($data['chat_id'], (string) $request->ip(), $userId !== null); + + if ($refusal !== null) { + return response()->json(['message' => $refusal], 429); + } + + $row = BotDialog::create([ + 'chat_id' => $data['chat_id'], + 'direction' => 'in', + 'message' => $text, + 'source' => $userId !== null ? 'portal' : 'landing', + 'user_id' => $userId, + 'ip' => $request->ip(), + 'created_at' => now(), + ]); + + ProcessChatMessageJob::dispatch($data['chat_id'], (int) $row->id, $text); + + return response()->json(['message_id' => (int) $row->id]); + } + + /** + * Опрос окошка: «что нового в разговоре после реплики N?». + * after=0 → вся переписка (клиент перезагрузил страницу и вернулся). + */ + public function messages(Request $request, string $chat): JsonResponse + { + if ($this->deniesAccess($chat)) { + return response()->json(['message' => 'Разговор не найден.'], 404); + } + + $after = max(0, (int) $request->query('after', '0')); + + $rows = BotDialog::query() + ->where('chat_id', $chat) + ->where('id', '>', $after) + ->orderBy('id') + ->limit(100) + ->get(['id', 'direction', 'message', 'created_at']); + + return response()->json([ + 'messages' => $rows->map(fn (BotDialog $d) => [ + 'id' => (int) $d->id, + 'direction' => $d->direction, + 'text' => $d->message, + 'at' => $d->created_at?->toIso8601String(), + ])->all(), + ]); + } + + /** + * Владелец разговора: первый вошедший клиент, писавший в него. Личные цифры (баланс, + * проекты) уходят в переписку — значит, чужому её видеть нельзя, а ключ разговора + * живёт в браузере и может попасть в чужие руки. + */ + private function ownerOf(string $chatId): ?int + { + $owner = BotDialog::query() + ->where('chat_id', $chatId) + ->whereNotNull('user_id') + ->orderBy('id') + ->value('user_id'); + + return $owner === null ? null : (int) $owner; + } + + /** Чужому не подтверждаем даже существование разговора — 404, не 403. */ + private function deniesAccess(string $chatId): bool + { + $owner = $this->ownerOf($chatId); + + return $owner !== null && $owner !== Auth::id(); + } +} diff --git a/app/app/Http/Controllers/Api/DealController.php b/app/app/Http/Controllers/Api/DealController.php index f2d75c2a..fb5cb6e5 100644 --- a/app/app/Http/Controllers/Api/DealController.php +++ b/app/app/Http/Controllers/Api/DealController.php @@ -6,6 +6,7 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\ActivityLog; +use App\Models\AutopodborSource; use App\Models\Deal; use App\Models\LeadCharge; use App\Models\Project; @@ -13,6 +14,7 @@ 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; @@ -104,7 +106,7 @@ class DealController extends Controller $query = Deal::query() ->select('deals.*') ->where('tenant_id', $tenantId) - ->with(['project:id,name,signal_type,signal_identifier,sms_keyword,sms_senders', 'manager:id,email,first_name,last_name']); + ->with(['project:id,name,signal_type,signal_identifier,sms_keyword,sms_senders,is_active', 'manager:id,email,first_name,last_name']); if ($onlyDeleted) { $query->withTrashed()->whereNotNull('deleted_at'); @@ -206,12 +208,31 @@ class DealController extends Controller }); } + // Автоподбор: по каждой сделке — конкурент + источник (связь + // deal.project_id → autopodbor_sources.created_project_id → competitor). + // Батч-запрос по project_id всей страницы (без N+1). Своя транзакция с + // app.current_tenant_id — иначе RLS на autopodbor_* вернёт 0 строк. + $sourcesByProject = collect(); + $projectIds = $deals->pluck('project_id')->filter()->unique()->values()->all(); + if ($projectIds !== []) { + $sourcesByProject = DB::transaction(function () use ($tenantId, $projectIds) { + DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); + + return AutopodborSource::query() + ->where('tenant_id', $tenantId) + ->whereIn('created_project_id', $projectIds) + ->with('competitor:id,name') + ->get() + ->groupBy('created_project_id'); + }); + } + $payload = [ 'deals' => $deals->map(fn (Deal $d) => [ 'id' => $d->id, 'tenant_id' => $d->tenant_id, 'project_id' => $d->project_id, - 'project_name' => $d->project?->name, + 'project_name' => SupplierProjectName::strip($d->project?->name), 'phone' => $d->phone, 'contact_name' => $d->contact_name, 'status' => $d->status, @@ -230,6 +251,21 @@ class DealController extends Controller 'project_sms_keyword' => $d->project?->sms_keyword, 'project_sms_senders' => $d->project?->sms_senders, 'cost_kopecks' => $costByDeal[$d->id] ?? null, + // Автоподбор (2026-07-08): конкурент + источник для колонок «Сделок». + ...(function () use ($d, $sourcesByProject) { + $group = $sourcesByProject[$d->project_id] ?? collect(); + $repr = $group->firstWhere('box', 'field') ?? $group->first(); + + return [ + 'competitor_id' => $repr?->competitor_id, + 'competitor_name' => $repr?->competitor?->name, + 'source_identifier' => $repr?->identifier, + 'source_signal_type' => $repr?->signal_type, + 'source_project_id' => $repr ? $d->project_id : null, + 'source_is_active' => $repr ? (bool) $d->project?->is_active : null, + 'source_extra_count' => max(0, $group->count() - 1), + ]; + })(), ]), 'limit' => $limit, 'next_cursor' => $nextCursor, @@ -308,7 +344,7 @@ class DealController extends Controller 'id' => $deal->id, 'tenant_id' => $deal->tenant_id, 'project_id' => $deal->project_id, - 'project_name' => $deal->project?->name, + 'project_name' => SupplierProjectName::strip($deal->project?->name), 'phone' => $deal->phone, 'contact_name' => $deal->contact_name, 'comment' => $deal->comment, diff --git a/app/app/Http/Controllers/Api/DealExportController.php b/app/app/Http/Controllers/Api/DealExportController.php index c2e75a37..f892644c 100644 --- a/app/app/Http/Controllers/Api/DealExportController.php +++ b/app/app/Http/Controllers/Api/DealExportController.php @@ -8,6 +8,7 @@ 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; @@ -121,7 +122,7 @@ class DealExportController extends Controller foreach ($deals as $deal) { /** @var Deal $deal */ $signal = $deal->project?->signal_type; - $source = trim(($deal->project?->name ?? '—').' · ' + $source = trim((SupplierProjectName::strip($deal->project?->name) ?? '—').' · ' .(self::SIGNAL_LABELS[$signal] ?? '—')); // F-CSV: свободный текст (телефон/источник/город/статус/ // комментарий) экранируем от formula-инъекции. Дата — diff --git a/app/app/Http/Controllers/Api/PaymentWebhookController.php b/app/app/Http/Controllers/Api/PaymentWebhookController.php index e8491d1d..a9f78e3f 100644 --- a/app/app/Http/Controllers/Api/PaymentWebhookController.php +++ b/app/app/Http/Controllers/Api/PaymentWebhookController.php @@ -5,10 +5,8 @@ declare(strict_types=1); namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; -use App\Models\PaymentGateway; -use App\Models\SaasTransaction; -use App\Services\Billing\BillingTopupService; use App\Services\Billing\Gateway\PaymentGatewayDriver; +use App\Services\Billing\PaymentSettlementService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -34,7 +32,7 @@ class PaymentWebhookController extends Controller { public function __construct( private readonly PaymentGatewayDriver $driver, - private readonly BillingTopupService $topupService, + private readonly PaymentSettlementService $settlement, ) {} public function receive(Request $request): JsonResponse @@ -60,60 +58,11 @@ class PaymentWebhookController extends Controller return response()->json(['status' => 'unknown'], 200); } - $gateway = $this->gatewayFor($tx); + // Server-to-server сверка — источник правды о статусе. Дальше судьбу платежа + // (зачислить / закрыть как отменённый / ждать) решает PaymentSettlementService — + // тот же путь, что и у команды billing:reconcile-payments. + $verify = $this->driver->verifyPayment($this->settlement->gatewayFor($tx), $paymentId); - // Server-to-server сверка — источник правды о статусе. - $verify = $this->driver->verifyPayment($gateway, $paymentId); - if (! $verify->isSucceeded()) { - return response()->json(['status' => 'not_paid'], 200); - } - - // Confused-deputy: сверенный платёж должен быть РОВНО тем, что в webhook. - if ($verify->gatewayPaymentId !== $paymentId) { - return response()->json(['status' => 'ignored'], 200); - } - - // Защита от чужой валюты с тем же числом — зачисляем только рубли. - if ($verify->currency !== 'RUB') { - return response()->json(['status' => 'currency_mismatch'], 200); - } - - // Защита: оплаченная сумма должна совпасть с запрошенной (scale 2). - if (bccomp((string) $verify->amountRub, (string) $tx->amount_rub, 2) !== 0) { - return response()->json(['status' => 'amount_mismatch'], 200); - } - - DB::transaction(function () use ($tx, $verify) { - // RLS-контекст для этой транзакции (PgBouncer-safe SET LOCAL). - DB::statement('SET LOCAL app.current_tenant_id = '.(int) $tx->tenant_id); - - // Атомарно занимаем pending→success; 0 строк = уже зачислено (дубль/гонка). - $claimed = SaasTransaction::where('id', $tx->id) - ->where('status', SaasTransaction::STATUS_PENDING) - ->update(['status' => SaasTransaction::STATUS_SUCCESS, 'completed_at' => now()]); - - if ($claimed === 0) { - return; // идемпотентный no-op - } - - $balanceTx = $this->topupService->topup( - (int) $tx->tenant_id, (string) $tx->amount_rub, null - ); - - SaasTransaction::where('id', $tx->id)->update([ - 'balance_rub_after' => $balanceTx->balance_rub_after, - 'payment_method' => $verify->paymentMethod, - 'balance_transaction_id' => $balanceTx->id, // provenance: оплата → строка журнала - ]); - }); - - return response()->json(['status' => 'ok'], 200); - } - - private function gatewayFor(object $tx): PaymentGateway - { - return $tx->gateway_id !== null - ? PaymentGateway::findOrFail($tx->gateway_id) - : PaymentGateway::where('code', $tx->gateway_code)->firstOrFail(); + return response()->json(['status' => $this->settlement->settle($tx, $verify, $paymentId)], 200); } } diff --git a/app/app/Http/Controllers/Api/ProjectController.php b/app/app/Http/Controllers/Api/ProjectController.php index ef5c43d1..ab8e9bcf 100644 --- a/app/app/Http/Controllers/Api/ProjectController.php +++ b/app/app/Http/Controllers/Api/ProjectController.php @@ -9,15 +9,14 @@ use App\Http\Requests\BulkProjectActionRequest; use App\Http\Requests\StoreProjectRequest; use App\Http\Requests\UpdateProjectRequest; use App\Http\Resources\ProjectResource; -use App\Jobs\SyncSupplierProjectJob; use App\Models\Project; use App\Models\Tenant; -use App\Repositories\PricingTierRepository; -use App\Services\Billing\BalancePreflightService; +use App\Services\Billing\LaunchBalanceGate; use App\Services\Project\ProjectService; use App\Services\Requisites\RequisitesService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; /** * Проекты tenant'а — расширенный API для ProjectsView + NewDealDialog. @@ -134,35 +133,18 @@ class ProjectController extends Controller return response()->json(['error' => 'requisites_required'], 422); } - $forceSaveBlocked = (bool) ($validated['force_save_blocked'] ?? false); - unset($validated['force_save_blocked']); + unset($validated['force_save_blocked']); // больше не блокируем создание - // Spec C §3.4: преfflight баланса при создании. existingLimit учитывает только активные. - $existingLimit = (int) Project::where('tenant_id', $tenant->id) - ->where('is_active', true) - ->whereNull('preflight_blocked_at') - ->sum('daily_limit_target'); - $wouldBeRequired = $existingLimit + (int) $validated['daily_limit_target']; + $project = $this->projects->create($tenant, $validated, launch: true); - $preflight = $this->runPreflight($tenant, $wouldBeRequired); - - if (! $preflight['passes'] && ! $forceSaveBlocked) { - return response()->json([ - 'error' => 'balance_insufficient', - 'current_balance_rub' => (string) $tenant->balance_rub, - 'current_capacity_leads' => $preflight['capacity_leads'], - 'would_be_required_leads' => $wouldBeRequired, - 'deficit_leads' => $preflight['deficit_leads'], - ], 409); - } - - if (! $preflight['passes'] && $forceSaveBlocked) { - $validated['preflight_blocked_at'] = now(); - } - - $project = $this->projects->create($tenant, $validated); - - return response()->json(['data' => new ProjectResource($project->loadCount('supplierProjects'))], 201); + return response()->json([ + 'data' => new ProjectResource($project->loadCount('supplierProjects')), + 'launch' => [ + 'launched' => $project->launch_deferred ? 0 : 1, + 'deferred' => $project->launch_deferred ? 1 : 0, + 'balance' => $project->gate_payload, + ], + ], 201); } /** PATCH /api/projects/{id} */ @@ -171,34 +153,28 @@ class ProjectController extends Controller $project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id); $validated = $request->validated(); $tenant = $request->user()->tenant; - $forceSaveBlocked = (bool) ($validated['force_save_blocked'] ?? false); unset($validated['force_save_blocked']); - // Spec C §3.4: преfflight при изменении лимита — учитываем новое значение для ЭТОГО - // проекта + лимиты остальных активных не-blocked. - if (array_key_exists('daily_limit_target', $validated)) { - $existingLimit = (int) Project::where('tenant_id', $tenant->id) - ->where('id', '!=', $project->id) - ->where('is_active', true) - ->whereNull('preflight_blocked_at') - ->sum('daily_limit_target'); - $wouldBeRequired = $existingLimit + (int) $validated['daily_limit_target']; + $isLimitRaise = array_key_exists('daily_limit_target', $validated) + && (int) $validated['daily_limit_target'] > (int) $project->daily_limit_target + && $project->is_active && $project->preflight_blocked_at === null; - $preflight = $this->runPreflight($tenant, $wouldBeRequired); + if ($isLimitRaise) { + $newLimit = (int) $validated['daily_limit_target']; + $tenantId = $tenant->id; - if (! $preflight['passes'] && ! $forceSaveBlocked) { - return response()->json([ - 'error' => 'balance_insufficient', - 'current_balance_rub' => (string) $tenant->balance_rub, - 'current_capacity_leads' => $preflight['capacity_leads'], - 'would_be_required_leads' => $wouldBeRequired, - 'deficit_leads' => $preflight['deficit_leads'], - ], 409); - } + return DB::transaction(function () use ($project, $validated, $tenant, $tenantId, $newLimit): JsonResponse { + Tenant::whereKey($tenantId)->lockForUpdate()->firstOrFail(); - if (! $preflight['passes'] && $forceSaveBlocked) { - $validated['preflight_blocked_at'] = now(); - } + $gate = app(LaunchBalanceGate::class)->evaluate($tenant, $newLimit, [$project->id]); + if (! $gate->passes) { + return response()->json(['error' => 'balance_insufficient', 'balance' => $gate->toBalancePayload()], 409); + } + + $updated = $this->projects->update($project, $validated); + + return response()->json(['data' => new ProjectResource($updated->loadCount('supplierProjects'))]); + }); } $updated = $this->projects->update($project, $validated); @@ -206,34 +182,6 @@ class ProjectController extends Controller return response()->json(['data' => new ProjectResource($updated->loadCount('supplierProjects'))]); } - /** - * @return array{passes: bool, capacity_leads: int, deficit_leads: int} - */ - private function runPreflight(Tenant $tenant, int $requiredLeads): array - { - // Косяк 01: действующая версия тарифа по дате (как списание/витрина), а не «по-простому». - $tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow')); - - // Safe fallback: без активных pricing_tiers биллинг не настроен — - // преfflight не имеет смысла, пропускаем (legacy-окружения / тесты). - if ($tiers->isEmpty()) { - return ['passes' => true, 'capacity_leads' => PHP_INT_MAX, 'deficit_leads' => 0]; - } - - $result = (new BalancePreflightService)->evaluate( - balanceRub: (string) $tenant->balance_rub, - deliveredInMonth: (int) $tenant->delivered_in_month, - requiredLeads: $requiredLeads, - tiers: $tiers, - ); - - return [ - 'passes' => $result->passes, - 'capacity_leads' => $result->capacityLeads, - 'deficit_leads' => $result->deficitLeads, - ]; - } - /** GET /api/projects/{id} */ public function show(Request $request, int $id): JsonResponse { @@ -269,23 +217,13 @@ class ProjectController extends Controller $request->validate(['is_active' => ['required', 'boolean']]); $project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id); - // Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md (Task 11). - // paused_at — anchor для SupplierSnapshotGuard grace-расчёта. - $newActive = $request->boolean('is_active'); - $project->update([ - 'is_active' => $newActive, - 'paused_at' => $newActive ? null : now(), - ]); + $result = $this->projects->setActive($project, $request->boolean('is_active')); - // #10: pause/resume must reach the supplier. The job's group recompute pushes - // status=paused when no active project of the group remains (resume → active). - // G (балансовый блок): заблокированный за нехваткой баланса проект не - // возобновляется/синхронизируется у поставщика (зеркалит create-гард). - if ($project->preflight_blocked_at === null) { - SyncSupplierProjectJob::dispatch($project->id); + if ($result->activate_deferred ?? false) { + return response()->json(['error' => 'balance_insufficient', 'balance' => $result->gate_payload], 409); } - return response()->json(['data' => new ProjectResource($project->fresh()->loadCount('supplierProjects'))]); + return response()->json(['data' => new ProjectResource($result->loadCount('supplierProjects'))]); } /** POST /api/projects/bulk — batch pause/resume/delete/update_regions/update_days/update_limit */ diff --git a/app/app/Http/Controllers/Api/RegistrationController.php b/app/app/Http/Controllers/Api/RegistrationController.php index f01313a5..84f76f68 100644 --- a/app/app/Http/Controllers/Api/RegistrationController.php +++ b/app/app/Http/Controllers/Api/RegistrationController.php @@ -12,13 +12,14 @@ use App\Http\Requests\Auth\ResendCodeRequest; use App\Models\User; use App\Services\Auth\RegistrationException; use App\Services\Auth\RegistrationService; +use App\Services\Tracking\VisitTracker; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Auth; /** * Самозапись клиента (G1/SP1): register → confirm-email → (вход). * Подтверждение почты 6-значным кодом; новый тенант создаётся в статусе - * pending_email_confirm, активируется и получает 300 ₽ при подтверждении. + * pending_email_confirm, активируется и получает 1000 ₽ при подтверждении. */ class RegistrationController extends Controller { @@ -69,6 +70,10 @@ class RegistrationController extends Controller $request->session()->regenerate(); $this->logAuthEvent('register_success', $user->id, $user->tenant_id, $user->email, $request->ip(), $request->userAgent(), null); + // Учёт посетителей (spec 2026-07-13): регистрация состоялась именно здесь — + // на /register клиента ещё нет, есть только ожидающая подтверждения заявка. + app(VisitTracker::class)->trackAuth($request, (int) $user->tenant_id, (int) $user->id, 'register_done'); + return response()->json([ 'user' => $this->userResource($user), 'requires_2fa' => false, diff --git a/app/app/Http/Controllers/Api/TenantChargesController.php b/app/app/Http/Controllers/Api/TenantChargesController.php index c0c04d77..25aca45b 100644 --- a/app/app/Http/Controllers/Api/TenantChargesController.php +++ b/app/app/Http/Controllers/Api/TenantChargesController.php @@ -7,8 +7,7 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\BalanceTransaction; use App\Models\Deal; -use App\Models\LeadCharge; -use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Query\Builder; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Carbon; @@ -38,16 +37,37 @@ class TenantChargesController extends Controller { $tenantId = (int) $request->user()->tenant_id; - // Explicit tenant_id фильтр — defense-in-depth поверх RLS. В тестах PG - // superuser BYPASSRLS, и без явного where() запрос вернул бы строки - // других тенантов (см. InAppNotificationController паттерн). - $query = LeadCharge::query() + $period = $request->query('period'); + $source = $request->query('charge_source'); + + // Explicit tenant_id фильтр — defense-in-depth поверх RLS (в тестах PG superuser BYPASSRLS). + // Лид-списания. + $lead = DB::table('lead_charges') ->where('tenant_id', $tenantId) - ->orderBy('charged_at', 'desc'); + ->selectRaw("id, charged_at, 'lead' as kind, deal_id, tier_no, charge_source, price_per_lead_kopecks, cast(null as text) as label"); + $this->applyPeriodTo($lead, 'charged_at', $period); + if (is_string($source) && $source !== '') { + $lead->where('charge_source', $source); + } - $this->applyFilters($query, $request); + // Списания автоподбора (balance_transactions) — тоже показываем в «Списаниях», иначе клиент + // видит их только в «Истории транзакций». Фильтр charge_source (prepaid/rub) к автоподбору + // неприменим → при выбранном источнике автоподбор не подмешиваем. + if (! is_string($source) || $source === '') { + $auto = DB::table('balance_transactions') + ->where('tenant_id', $tenantId) + ->where('type', 'autopodbor_charge') + ->selectRaw("id, created_at as charged_at, 'autopodbor' as kind, cast(null as bigint) as deal_id, cast(null as integer) as tier_no, cast(null as varchar) as charge_source, cast(round(abs(amount_rub) * 100) as bigint) as price_per_lead_kopecks, cast('Автоподбор конкурентов' as text) as label"); + $this->applyPeriodTo($auto, 'created_at', $period); + $rows = $lead->unionAll($auto); + } else { + $rows = $lead; + } - $page = $query->paginate(20); + $page = DB::query()->fromSub($rows, 'c') + ->orderByDesc('charged_at') + ->orderByDesc('id') + ->paginate(20); return response()->json([ 'data' => $page->items(), @@ -141,6 +161,29 @@ class TenantChargesController extends Controller } }); + // Списания автоподбора — тоже в CSV (после лид-строк). charge_source к ним неприменим, + // поэтому при выбранном источнике их не выгружаем (как и в списке). + if ($source === null || $source === '') { + $autoq = DB::table('balance_transactions') + ->where('tenant_id', $tenantId) + ->where('type', 'autopodbor_charge') + ->orderByDesc('created_at') + ->orderByDesc('id'); + $this->applyPeriodTo($autoq, 'created_at', $period); + $autoq->chunk(500, function ($rows) use ($out) { + foreach ($rows as $r) { + fputcsv($out, [ + Carbon::parse($r->created_at)->toIso8601String(), + 'Автоподбор конкурентов', + '', + '', + number_format(abs((float) $r->amount_rub), 2, '.', ''), + $r->balance_rub_after ?? '', + ]); + } + }); + } + fclose($out); }); }, 200, [ @@ -150,22 +193,12 @@ class TenantChargesController extends Controller } /** - * @param Builder $query + * Фильтр периода на произвольную колонку/билдер (lead_charges.charged_at либо + * balance_transactions.created_at) — единый для лид- и автоподбор-веток union. + * + * @param Builder $query */ - private function applyFilters($query, Request $request): void - { - $this->applyPeriodFilter($query, $request->query('period')); - - $source = $request->query('charge_source'); - if (is_string($source) && $source !== '') { - $query->where('charge_source', $source); - } - } - - /** - * @param Builder $query - */ - private function applyPeriodFilter($query, mixed $period): void + private function applyPeriodTo($query, string $column, mixed $period): void { if (! is_string($period) || $period === '') { return; @@ -174,14 +207,14 @@ class TenantChargesController extends Controller $now = Carbon::now('Europe/Moscow'); if ($period === 'current_month') { - $query->where('charged_at', '>=', $now->copy()->startOfMonth()); + $query->where($column, '>=', $now->copy()->startOfMonth()); } elseif ($period === 'last_month') { - $query->whereBetween('charged_at', [ + $query->whereBetween($column, [ $now->copy()->subMonth()->startOfMonth(), $now->copy()->subMonth()->endOfMonth(), ]); } elseif ($period === '90d') { - $query->where('charged_at', '>=', $now->copy()->subDays(90)); + $query->where($column, '>=', $now->copy()->subDays(90)); } } } diff --git a/app/app/Http/Controllers/Api/TrackController.php b/app/app/Http/Controllers/Api/TrackController.php new file mode 100644 index 00000000..b9971552 --- /dev/null +++ b/app/app/Http/Controllers/Api/TrackController.php @@ -0,0 +1,54 @@ +validate([ + 'event' => 'required|string|in:'.implode(',', VisitTracker::EVENTS), + 'host' => 'nullable|string|max:64', + 'path' => 'nullable|string|max:500', + 'screen' => 'nullable|string|max:64', + 'referrer' => 'nullable|string|max:1000', + 'utm' => 'nullable|array', + 'utm.*' => 'nullable|string|max:128', + ]); + + // register_done / login_done пишет только backend — с фронта не принимаем. + if (in_array($data['event'], ['register_done', 'login_done'], true)) { + abort(422, 'событие пишется сервером'); + } + + $vid = $tracker->ensureVisitor($request->cookie('lid_vid'), [ + 'ip' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'referrer' => $data['referrer'] ?? $request->header('referer'), + 'path' => $data['path'] ?? null, + 'utm' => $data['utm'] ?? [], + ]); + + $tracker->record($vid, $data['event'], [ + 'host' => $data['host'] ?? null, + 'path' => $data['path'] ?? null, + 'screen' => $data['screen'] ?? null, + ]); + + return response()->noContent()->cookie( + 'lid_vid', $vid, 60 * 24 * 365, '/', '.liderra.ru', true, true, false, 'lax' + ); + } +} diff --git a/app/app/Http/Controllers/Api/TwoFactorController.php b/app/app/Http/Controllers/Api/TwoFactorController.php index b0f9305e..5f1ac8b5 100644 --- a/app/app/Http/Controllers/Api/TwoFactorController.php +++ b/app/app/Http/Controllers/Api/TwoFactorController.php @@ -10,6 +10,7 @@ use App\Http\Requests\Auth\UseRecoveryCodeRequest; use App\Http\Requests\Auth\VerifyTwoFactorRequest; use App\Models\User; use App\Models\UserRecoveryCode; +use App\Services\Tracking\VisitTracker; use App\Services\UserSessionTracker; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Auth; @@ -100,6 +101,9 @@ class TwoFactorController extends Controller $user->update(['last_login_at' => now()]); app(UserSessionTracker::class)->record($request, $user->id); + // Учёт посетителей (spec 2026-07-13): вход через 2FA — такой же вход в кабинет. + app(VisitTracker::class)->trackAuth($request, (int) $user->tenant_id, (int) $user->id, 'login_done'); + $this->logAuthEvent( '2fa_verify_success', $user->id, @@ -204,6 +208,9 @@ class TwoFactorController extends Controller $user->update(['last_login_at' => now()]); app(UserSessionTracker::class)->record($request, $user->id); + // Учёт посетителей (spec 2026-07-13): вход через 2FA — такой же вход в кабинет. + app(VisitTracker::class)->trackAuth($request, (int) $user->tenant_id, (int) $user->id, 'login_done'); + $this->logAuthEvent( '2fa_recovery_used', $user->id, diff --git a/app/app/Http/Controllers/Api/V1/DealsController.php b/app/app/Http/Controllers/Api/V1/DealsController.php index 7d9c491a..d036df62 100644 --- a/app/app/Http/Controllers/Api/V1/DealsController.php +++ b/app/app/Http/Controllers/Api/V1/DealsController.php @@ -6,6 +6,7 @@ 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; @@ -87,7 +88,7 @@ class DealsController extends Controller 'contact_name' => $d->contact_name, 'city' => $d->city, 'status' => $d->status, - 'project' => $d->project?->name, + 'project' => SupplierProjectName::strip($d->project?->name), ])->all(), 'next_cursor' => $next, ]); diff --git a/app/app/Http/Resources/Autopodbor/CompetitorResource.php b/app/app/Http/Resources/Autopodbor/CompetitorResource.php new file mode 100644 index 00000000..4272003c --- /dev/null +++ b/app/app/Http/Resources/Autopodbor/CompetitorResource.php @@ -0,0 +1,30 @@ + $this->id, + 'name' => $this->name, + 'description' => $this->description, + 'is_federal' => $this->is_federal, + 'relevance_pct' => $this->relevance_pct, + 'origin' => $this->origin, + 'box' => $this->box, + 'site_url' => $this->site_url, + 'directory_urls' => $this->directory_urls, + 'elements' => $this->elements ?? [], + 'studied_at' => $this->studied_at?->toIso8601String(), + 'study_run_id' => $this->study_run_id, + 'search_run_id' => $this->search_run_id, + ]; + } +} diff --git a/app/app/Http/Resources/Autopodbor/RunResource.php b/app/app/Http/Resources/Autopodbor/RunResource.php new file mode 100644 index 00000000..1007d185 --- /dev/null +++ b/app/app/Http/Resources/Autopodbor/RunResource.php @@ -0,0 +1,37 @@ + $this->id, + 'kind' => $this->kind, + 'competitor_id' => $this->competitor_id, + 'status' => $this->status, + 'region_code' => $this->region_code, + 'params' => $this->params, + 'price_rub_charged' => $this->price_rub_charged, + 'error_code' => $this->error_code, + // Клиенту показать, что идёт работа: место в общей очереди (пока queued) и текущий этап (пока running). + 'queue_position' => $this->status === 'queued' ? app(AutopodborQueue::class)->pending() : null, + 'progress' => $this->progress, + 'result' => $this->result, + 'competitors_count' => AutopodborCompetitor::where('search_run_id', $this->id)->count(), + 'sources_count' => AutopodborSource::where('study_run_id', $this->id)->count(), + 'started_at' => $this->started_at?->toIso8601String(), + 'finished_at' => $this->finished_at?->toIso8601String(), + 'created_at' => $this->created_at?->toIso8601String(), + ]; + } +} diff --git a/app/app/Http/Resources/Autopodbor/SourceResource.php b/app/app/Http/Resources/Autopodbor/SourceResource.php new file mode 100644 index 00000000..95968c0a --- /dev/null +++ b/app/app/Http/Resources/Autopodbor/SourceResource.php @@ -0,0 +1,30 @@ + $this->id, + 'competitor_id' => $this->competitor_id, + 'signal_type' => $this->signal_type, + 'identifier' => $this->identifier, + 'phone_kind' => $this->phone_kind, + 'phone_type' => $this->phone_type, + 'box' => $this->box, + 'provenance_url' => $this->provenance_url, + 'provenance_label' => $this->provenance_label, + 'created_project_id' => $this->created_project_id, + 'where_found' => $this->where_found ?? [], + 'office' => $this->office, + 'confirmations' => $this->confirmations, + ]; + } +} diff --git a/app/app/Http/Resources/ProjectResource.php b/app/app/Http/Resources/ProjectResource.php index ba31b0b8..34acf32b 100644 --- a/app/app/Http/Resources/ProjectResource.php +++ b/app/app/Http/Resources/ProjectResource.php @@ -52,6 +52,8 @@ class ProjectResource extends JsonResource 'last_synced_at' => $this->aggregateLastSyncedAt(), // H (балансовый блок): проект приостановлен из-за нехватки баланса (read-only для UI). 'balance_blocked' => $this->preflight_blocked_at !== null, + // Task 15: сырое поле — UI отличает «заблокирован при попытке запуска» vs «закончился баланс». + 'preflight_blocked_at' => $this->preflight_blocked_at?->toIso8601String(), 'supplier_links' => $this->when( $request->routeIs('projects.show'), fn () => $this->getSupplierLinks(), diff --git a/app/app/Jobs/Autopodbor/RunAutopodborResolveJob.php b/app/app/Jobs/Autopodbor/RunAutopodborResolveJob.php new file mode 100644 index 00000000..87a509da --- /dev/null +++ b/app/app/Jobs/Autopodbor/RunAutopodborResolveJob.php @@ -0,0 +1,113 @@ +onQueue('autopodbor-find'); + } + + /** + * Защита от нахлёста: один прогон резолва не обрабатывается двумя воркерами разом (ретрай). + * + * @return array + */ + public function middleware(): array + { + return [new WithoutOverlapping((string) $this->runId)]; + } + + public function handle(CompetitorAgent $agent, AutopodborDedup $dedup): void + { + // Сначала представляемся базе, ПОТОМ читаем прогон: на боевом роль crm_app_user под FORCE RLS + // не видит строку без tenant-контекста (на dev маскировалось суперюзером postgres → ModelNotFound + // всплыл только на проде 07.07.2026). tenantId приходит из dispatch. Старый путь без tenantId + // (только тесты, где ambient-контекст уже задан) — безопасный откат ниже. + 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()]); + + try { + $p = $run->params; + + $res = $agent->resolveByName(new ResolveByNameRequest( + name: $p['name'], + regionCode: (int) $run->region_code, + )); + + $unique = $dedup->dedupCompetitors($res->candidates); + + if ($unique === []) { + $run->update(['status' => 'empty', 'finished_at' => now()]); + + return; + } + + foreach ($unique as $c) { + AutopodborCompetitor::updateOrCreate( + [ + 'tenant_id' => $run->tenant_id, + 'search_run_id' => $run->id, + 'dedup_key' => $c['dedup_key'], + ], + [ + 'name' => $c['name'], + 'description' => $c['description'] ?? null, + 'is_federal' => (bool) ($c['is_federal'] ?? false), + 'relevance_pct' => null, + 'origin' => 'resolve', + 'site_url' => $c['site_url'] ?? null, + 'directory_urls' => $c['directory_urls'] ?? [], + 'provenance' => $c['provenance'] ?? [], + ] + ); + } + + $run->update(['status' => 'done', 'finished_at' => now()]); + } catch (\Throwable $e) { + $run->update([ + 'status' => 'failed', + 'error_code' => substr($e->getMessage(), 0, 64), + 'finished_at' => now(), + ]); + throw $e; + } + } +} diff --git a/app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php b/app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php new file mode 100644 index 00000000..60f8a399 --- /dev/null +++ b/app/app/Jobs/Autopodbor/RunAutopodborSearchJob.php @@ -0,0 +1,216 @@ +onQueue('autopodbor-find'); + } + + /** + * Защита от нахлёста: один и тот же прогон не обрабатывается двумя воркерами разом + * (ретрай/повторный dispatch). Ключ — runId. + * + * @return array + */ + public function middleware(): array + { + return [new WithoutOverlapping((string) $this->runId)]; + } + + public function handle(CompetitorAgent $agent, AutopodborDedup $dedup, AutopodborChargeService $charge, CompetitorIdentity $identity, AutopodborNormalizer $normalizer, RunProgressChannel $progress): void + { + // Сначала представляемся базе, ПОТОМ читаем прогон: на боевом роль 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 { + $p = $run->params; + $max = (int) (SystemSettings::get('autopodbor_max_competitors') ?? 15); + + $request = new FindCompetitorsRequest( + regionCode: (int) $run->region_code, + examples: $p['examples'] ?? [], + aboutSelf: $p['about_self'] ?? [], + includeFederal: (bool) ($p['include_federal'] ?? false), + maxCompetitors: $max, + ); + + // Авто-повтор при ПУСТОМ сборе: внешние источники (2ГИС/Яндекс/EXA) иногда моргают и + // отдают пусто — вместо «ничего не нашлось» пробуем ещё раз (по умолчанию до 2 доп. попыток). + // Реально пустая ниша (все попытки пусты) идёт дальше штатным путём «empty» — без списания. + $retries = max(0, (int) config('autopodbor.search_retry_on_empty', 2)); + $attempt = 0; + do { + $attempt++; + $res = $agent->findCompetitors($request); + } while ($res->competitors === [] && $attempt <= $retries); + + $unique = $dedup->dedupCompetitors($res->competitors); + + // На записи выкидываем ТОЛЬКО полный дубль АКТИВНОЙ фирмы (поле/предложения) — та же + // опознавалка и ни одной новой (это «скрытая» группа, спека §8). Находку, совпавшую с + // АРХИВОМ удалённых, сохраняем — её покажем как «ранее удалён». Деление на группы — + // при показе /proposals. Исключаем свой же прогон (ретрай не должен схлопнуть свои + // результаты). Конкурентов без опознавалок (нет сайта/карточек) не с чем сверять — оставляем. + $activeKeySets = AutopodborCompetitor::where('tenant_id', $run->tenant_id) + ->where('box', '!=', 'archived') + ->where(function ($q) use ($run) { + $q->where('search_run_id', '!=', $run->id)->orWhereNull('search_run_id'); + }) + ->get(['site_url', 'directory_urls']) + ->map(fn ($c) => array_flip($identity->keys([ + 'site_url' => $c->site_url, + 'directory_urls' => $c->directory_urls ?? [], + ]))) + ->all(); + + $unique = array_values(array_filter($unique, function (array $c) use ($identity, $activeKeySets): bool { + $keys = $identity->keys(['site_url' => $c['site_url'] ?? null, 'directory_urls' => $c['directory_urls'] ?? []]); + if ($keys === []) { + return true; + } + foreach ($activeKeySets as $have) { + $shares = false; + $hasNew = false; + foreach ($keys as $k) { + if (isset($have[$k])) { + $shares = true; + } else { + $hasNew = true; + } + } + if ($shares && ! $hasNew) { + return false; // полный дубль активной фирмы → скрыть + } + } + + return true; + })); + + if ($unique === []) { + $run->update(['status' => 'empty', 'finished_at' => now()]); + $this->notifyReady($run, 0); + + return; + } + + // $max === 0 → «не режем»: сохраняем ВСЕХ найденных. Иначе array_slice(…, 0, 0) обнулил бы + // непустой результат (баг: прогон сохранял 0 конкурентов, но всё равно списывал). + $saved = $max > 0 ? array_slice($unique, 0, $max) : $unique; + foreach ($saved as $c) { + AutopodborCompetitor::updateOrCreate( + [ + 'tenant_id' => $run->tenant_id, + 'search_run_id' => $run->id, + 'dedup_key' => $c['dedup_key'], + ], + [ + 'name' => $c['name'], + 'description' => $c['description'] ?? null, + 'is_federal' => (bool) ($c['is_federal'] ?? false), + 'relevance_pct' => $c['relevance_pct'] ?? null, + 'origin' => 'auto', + 'site_url' => $c['site_url'] ?? null, + 'directory_urls' => $c['directory_urls'] ?? [], + // Телефоны (нормализованные) — для показа совпадений клиенту на «Предложениях». + // В тихую склейку НЕ идут (решение 03.07) — только клиентская «Найти дубли». + 'phones' => array_values(array_unique(array_filter(array_map( + static fn ($p): string => $normalizer->phone((string) $p), + $c['phones'] ?? [], + )))), + 'provenance' => $c['provenance'] ?? [], + ] + ); + } + + $price = (string) (SystemSettings::get('autopodbor_price_search_rub') ?? '0'); + $charge->chargeForRun($run, $price); + + $run->update(['status' => 'done', 'finished_at' => now()]); + $this->notifyReady($run, count($saved)); + } catch (\Throwable $e) { + $run->update([ + 'status' => 'failed', + 'error_code' => substr($e->getMessage(), 0, 64), + 'finished_at' => now(), + ]); + throw $e; + } + } + + /** + * Письмо клиенту «подбор готов» — чтобы он не ждал у экрана. Не роняет успешный подбор, + * если почта недоступна (try/catch + report). + */ + private function notifyReady(AutopodborRun $run, int $found): void + { + try { + $email = Tenant::query()->whereKey($run->tenant_id)->value('contact_email'); + if (is_string($email) && $email !== '') { + Mail::to($email)->send(new AutopodborReadyMail($run, $found)); + } + } catch (\Throwable $e) { + report($e); + } + } +} diff --git a/app/app/Jobs/Autopodbor/RunAutopodborStudyJob.php b/app/app/Jobs/Autopodbor/RunAutopodborStudyJob.php new file mode 100644 index 00000000..883a8a9a --- /dev/null +++ b/app/app/Jobs/Autopodbor/RunAutopodborStudyJob.php @@ -0,0 +1,179 @@ +onQueue('autopodbor-study'); + } + + /** + * Защита от нахлёста: один прогон изучения не обрабатывается двумя воркерами разом (ретрай). + * + * @return array + */ + public function middleware(): array + { + return [new WithoutOverlapping((string) $this->runId)]; + } + + public function handle( + CompetitorAgent $agent, + AutopodborDedup $dedup, + AutopodborChargeService $charge, + AutopodborNormalizer $norm, + RunProgressChannel $progress, + AutopodborStudyScheduler $scheduler, + ): void { + // Внешний try/finally: распорядитель должен взять следующего кандидата ПОСЛЕ ЛЮБОГО + // завершения этого прогона — done/empty/failed, включая ранние return'ы и проброшенное + // исключение из внутреннего catch. Дорожка шага 2 одна на весь портал (см. + // AutopodborStudyScheduler) — если не звать tick() здесь, освободившаяся дорожка простаивала + // бы до следующего крон-тика (autopodbor:study-tick, страховка раз в минуту). + 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]); + } + + $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 { + $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, + ], + regionCode: (int) $run->region_code, + )); + + $unique = $dedup->dedupSources($res->sources); + + if ($unique === []) { + $run->update(['status' => 'empty', 'finished_at' => now()]); + + return; + } + + // Классификация для итога (важно при ПОВТОРНОМ сборе): сколько источников совсем новых, + // а какие — ранее удалённые (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(); + } + } +} diff --git a/app/app/Jobs/Bot/ProcessChatMessageJob.php b/app/app/Jobs/Bot/ProcessChatMessageJob.php new file mode 100644 index 00000000..34b639e6 --- /dev/null +++ b/app/app/Jobs/Bot/ProcessChatMessageJob.php @@ -0,0 +1,165 @@ +onQueue('bot'); + } + + /** + * Сколько прошлых реплик отдаём боту как память разговора (5 пар вопрос-ответ). + * Было 3 пары — в живом диалоге бот «забывал» цифру, названную им же в начале + * (прогон диалогами 12.07.2026). + */ + private const HISTORY_LIMIT = 10; + + public function handle(): void + { + $startedAt = hrtime(true); + + $history = BotDialog::query() + ->where('chat_id', $this->chatId) + ->where('id', '<', $this->inId) + ->orderByDesc('id') + ->limit(self::HISTORY_LIMIT) + ->get(['direction', 'message']) + ->reverse() + ->map(fn (BotDialog $d) => [ + 'role' => $d->direction === 'in' ? 'user' : 'assistant', + 'text' => trim((string) preg_replace('/\n*👉 Показать на портале:.*$/su', '', $d->message)), + ]) + ->values() + ->all(); + + // Кто пишет: вошедший клиент или гость с лендинга. Вошедшему собираем карточку + // фактов (баланс, ступень, проекты, заявки) — цифры считает КОД, не модель. + $row = BotDialog::query()->whereKey($this->inId)->first(['source', 'user_id']); + $fromPortal = ($row?->source ?? 'portal') !== 'landing'; + $userId = $row?->user_id === null ? null : (int) $row->user_id; + + // Карточка — не критичный путь: если посчитать не удалось (клиент удалён из базы, + // база моргнула), клиент НЕ должен остаться без ответа — отвечаем как гостю, + // но отвечаем, а не роняем всю задачу (tries=1 — повтора не будет). + $facts = null; + if ($userId !== null) { + try { + $facts = app(ClientFacts::class)->card($userId); + } catch (\Throwable $e) { + Log::error('Не удалось собрать карточку фактов клиента для бота', [ + 'chat_id' => $this->chatId, + 'user_id' => $userId, + 'error' => $e->getMessage(), + ]); + } + } + + // Номер в реплике = человек хочет, чтобы ему перезвонили. Принимаем ВСЕГДА, + // просил бот телефон или нет (прогон диалогами 12.07.2026: контакт терялся). + $answer = ($this->awaitsContact() || app(ContactCapture::class)->extractPhone($this->text) !== null) + ? $this->captureContact($history, $fromPortal) + : app(BotAnswerService::class)->answer($this->text, $history, $fromPortal, $facts); + + BotDialog::create([ + 'chat_id' => $this->chatId, + 'direction' => 'out', + 'message' => $answer->text, + 'matched_chunks' => $answer->matchedChunkIds, + 'latency_ms' => (int) ((hrtime(true) - $startedAt) / 1_000_000), + 'escalated' => $answer->escalate, + 'created_at' => now(), + ]); + } + + /** Ждём ли мы от клиента телефон: последний ответ бота был просьбой оставить номер. */ + private function awaitsContact(): bool + { + $lastOut = BotDialog::query() + ->where('chat_id', $this->chatId) + ->where('direction', 'out') + ->where('id', '<', $this->inId) + ->latest('id') + ->value('message'); + + return is_string($lastOut) && str_contains($lastOut, 'номер телефона'); + } + + /** + * @param list $history + */ + private function captureContact(array $history = [], bool $fromPortal = true): BotAnswer + { + $phone = app(ContactCapture::class)->extractPhone($this->text); + if ($phone === null) { + // Клиент передумал и задал новый вопрос — отвечаем С ПАМЯТЬЮ разговора. + return app(BotAnswerService::class)->answer($this->text, $history, $fromPortal); + } + + $rows = BotDialog::query() + ->where('chat_id', $this->chatId) + ->orderBy('id') + ->get(['direction', 'message', 'source', 'user_id']); + + $question = $rows + ->filter(fn (BotDialog $d) => $d->direction === 'in') + ->slice(0, -1) + ->last()?->message ?? $this->text; + + $transcript = $rows + ->map(fn (BotDialog $d) => ($d->direction === 'in' ? 'Клиент: ' : 'Бот: ').$d->message) + ->all(); + + $client = $rows->firstWhere(fn (BotDialog $d) => $d->direction === 'in'); + $source = (string) ($client?->source ?? 'portal'); + $userId = $client?->user_id !== null ? (int) $client?->user_id : null; + + $to = (string) config('services.support.email'); + if ($to !== '') { + // Письмо — В ОЧЕРЕДЬ. Прогон 12.07.2026: почта не ответила → упала вся задача + // (tries=1), и клиент, оставивший номер, не получил НИЧЕГО. + try { + Mail::to($to)->queue(new BotContactRequestMail($phone, $question, $this->chatId, $transcript, $source, $userId)); + } catch (\Throwable $e) { + Log::error('Не удалось поставить в очередь письмо с контактом', [ + 'chat_id' => $this->chatId, + 'error' => $e->getMessage(), + ]); + } + } + + return new BotAnswer(BotAnswerService::CONTACT_TAKEN_TEXT, escalate: true); + } +} diff --git a/app/app/Jobs/External/RefreshExternalBalancesJob.php b/app/app/Jobs/External/RefreshExternalBalancesJob.php index 92da3a04..d117b854 100644 --- a/app/app/Jobs/External/RefreshExternalBalancesJob.php +++ b/app/app/Jobs/External/RefreshExternalBalancesJob.php @@ -4,17 +4,23 @@ declare(strict_types=1); namespace App\Jobs\External; +use App\Mail\ExternalServiceDownMail; use App\Services\Dashboard\BalanceHealth; use App\Services\External\BalanceProvider; +use App\Services\External\CaptchaLivenessProbe; use App\Services\External\DadataBalanceProvider; +use App\Services\External\LivenessProbe; use App\Services\External\SupplierBalanceProvider; +use App\Services\External\Yandex360BalanceProvider; use App\Services\External\YandexCloudBalanceProvider; +use App\Services\External\YooKassaLivenessProbe; 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; /** * Ежедневно собирает баланс внешних сервисов и пишет в external_service_balances. @@ -37,11 +43,48 @@ class RefreshExternalBalancesJob implements ShouldQueue DadataBalanceProvider::class, SupplierBalanceProvider::class, YandexCloudBalanceProvider::class, + Yandex360BalanceProvider::class, + ]; + } + + /** @var array|null Подмена списка проб в тестах; null → дефолт. */ + private static ?array $livenessOverride = null; + + /** @param array $probes */ + public static function useLivenessProbes(array $probes): void + { + self::$livenessOverride = $probes; + } + + public static function resetLivenessProbes(): void + { + self::$livenessOverride = null; + } + + /** @return array */ + private function livenessProbes(): array + { + if (self::$livenessOverride !== null) { + return self::$livenessOverride; + } + + return [ + app(YooKassaLivenessProbe::class), + app(CaptchaLivenessProbe::class), ]; } public function handle(): void { + // Прежние цвета (для edge-trigger алерта): service_key => light. + $priorLights = DB::connection(self::DB_CONNECTION) + ->table('external_service_balances') + ->pluck('light', 'service_key') + ->all(); + + /** @var array $newlyRed */ + $newlyRed = []; + foreach ($this->providers() as $cls) { /** @var BalanceProvider $p */ $p = app($cls); @@ -85,6 +128,46 @@ class RefreshExternalBalancesJob implements ShouldQueue 'updated_at' => now(), ], ); + + if ($h['light'] === 'red' && ($priorLights[$key] ?? null) !== 'red') { + $newlyRed[] = ['key' => $key, 'detail' => 'баланс на исходе']; + } + } + + // Пробы живости (сервисы без денежного баланса): пишем в ту же таблицу. + foreach ($this->livenessProbes() as $probe) { + $reading = $probe->check(); // не бросает + $key = $probe->serviceKey(); + + // Свежий builder на каждую итерацию (как в балансовом цикле). + $table = DB::connection(self::DB_CONNECTION)->table('external_service_balances'); + + $table->updateOrInsert( + ['service_key' => $key], + [ + 'balance_amount' => null, + 'currency' => 'RUB', + 'daily_spend_estimate' => null, + 'days_left' => null, + 'light' => $reading->light, + // ok=true у green/red (статус определённый), false у grey (не смогли/не применимо). + 'ok' => $reading->light !== 'grey', + // error несёт человеческую подпись для red/grey (для плитки); у green — null. + 'error' => $reading->light === 'green' ? null : $reading->detail, + 'checked_at' => $reading->checkedAt, + 'updated_at' => now(), + ], + ); + + if ($reading->light === 'red' && ($priorLights[$key] ?? null) !== 'red') { + $newlyRed[] = ['key' => $key, 'detail' => $reading->detail]; + } + } + + // Edge-trigger: одно письмо, если появились новые красные сервисы. + if ($newlyRed !== []) { + $to = (string) config('services.monitoring.alert_email', 'ops@liderra.ru'); + Mail::to($to)->send(new ExternalServiceDownMail($newlyRed, now())); } } @@ -104,6 +187,10 @@ class RefreshExternalBalancesJob implements ShouldQueue (float) config('services.supplier.red_floor_rub', 5000), (float) config('services.supplier.amber_floor_rub', 15000), ], + 'email' => [ + (float) config('services.yandex360.red_floor_rub', 500), + (float) config('services.yandex360.amber_floor_rub', 2000), + ], default => [0.0, 0.0], }; } diff --git a/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php b/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php index ef6c35ae..6620fe8d 100644 --- a/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php +++ b/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php @@ -134,6 +134,10 @@ class SyncSupplierProjectsJob implements ShouldQueue $groups[$key]['projects'][] = $project; } + // Активные сегодня источники (по слепку) — их заказы НЕ гасим в deactivation-проходе. + /** @var array $activeKeys */ + $activeKeys = array_fill_keys(array_keys($groups), true); + // 3. Sync each group try { foreach ($groups as $group) { @@ -192,6 +196,13 @@ 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( startedAt: $startedAt, @@ -210,6 +221,103 @@ class SyncSupplierProjectsJob implements ShouldQueue } } + /** + * Deactivation (18:00, money-critical — owner-reported 2026-07-08, verified on prod live DB): + * гасим у поставщика заказы источников, у которых на СЕГОДНЯШНИЙ слепок не осталось ни + * одного активного проекта. Поставщик фиксирует завтрашнюю заливку своим снимком в 21:00, + * поэтому единственное окно остановить заказ — этот 18:00-прогон ДО снимка. Без этого + * осиротевший заказ оставался включённым (галочка вкл, лимит > 0) → поставщик лил лиды и + * списывал за остановленный клиентом проект. + * + * Активность — из СЛЕПКА ($activeKeys = ключи sync-групп за завтра), НЕ из live is_active: + * слепок-инвариант — проект, paused'нутый уже ПОСЛЕ снятия слепка, честно доезжает + * поставщику (см. collectEligibleProjects); гасим только то, чего в слепке нет. + * + * Осиротевший = supplier_project с external_id, inactive_since IS NULL, чей + * (signal_type|unique_key) не входит в $activeKeys. Шлём updateProject(status='paused') с + * ненулевым лимитом (кабинет отклоняет limit=0 даже при паузе), помечаем inactive_since + * ТОЛЬКО после успешной отправки (иначе — ретрай следующим прогоном). Реактивацию при + * возврате проекта делают CleanupInactiveSupplierProjectsJob (Phase A) + syncGroup(active). + * + * @param array $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. diff --git a/app/app/Jobs/Supplier/VerifySupplierOrderJob.php b/app/app/Jobs/Supplier/VerifySupplierOrderJob.php index 071aa38c..8d39d6a8 100644 --- a/app/app/Jobs/Supplier/VerifySupplierOrderJob.php +++ b/app/app/Jobs/Supplier/VerifySupplierOrderJob.php @@ -111,10 +111,18 @@ final class VerifySupplierOrderJob implements ShouldQueue } } + /** Канал у поставщика — служебное поле src, не имя. rt=Ростелеком, bl=Билайн, mt=МТС. */ + private const SRC_TO_PLATFORM = ['rt' => 'B1', 'bl' => 'B2', 'mt' => 'B3']; + /** * Портальные строки → формат SupplierOrderVerifier. «Наши» = те, чей портальный id * совпадает с нашим supplier_external_id (tag НЕ используется — робот шлёт регион). * + * Площадка берётся из `src`, а НЕ из префикса имени: метку «B_» портал ставит + * только при создании строки и теряет при обновлении (прод-инцидент 11–12.07.2026 — + * строки в кабинете были, но проверка объявляла их missing). `src` — то же поле, + * по которому матчит saveProjectMultiFlag; оно от имени не зависит. + * * @param array> $rows * @param list $ourExternalIds * @return list @@ -127,8 +135,8 @@ final class VerifySupplierOrderJob implements ShouldQueue if (! isset($ours[(string) ($row['id'] ?? '')])) { continue; } - $name = (string) ($row['name'] ?? ''); - if (preg_match('/^(B[123])_/', $name, $mm) !== 1) { + $platform = self::SRC_TO_PLATFORM[(string) ($row['src'] ?? '')] ?? null; + if ($platform === null) { continue; } $signalType = match ($row['type'] ?? null) { @@ -140,7 +148,7 @@ final class VerifySupplierOrderJob implements ShouldQueue $out[] = [ 'signal_type' => $signalType, 'identifier' => (string) ($row['content'] ?? ''), - 'platform' => $mm[1], + 'platform' => $platform, 'limit' => (int) ($row['lim'] ?? 0), 'on' => (string) ($row['status'] ?? '') === '1', ]; diff --git a/app/app/Mail/AutopodborReadyMail.php b/app/app/Mail/AutopodborReadyMail.php new file mode 100644 index 00000000..62a4ff33 --- /dev/null +++ b/app/app/Mail/AutopodborReadyMail.php @@ -0,0 +1,47 @@ +found > 0 + ? "Подбор конкурентов готов: {$this->found} — Лидерра" + : 'Подбор конкурентов готов — Лидерра'; + + return new Envelope(subject: $subject); + } + + public function content(): Content + { + return new Content( + markdown: 'mail.autopodbor-ready', + with: [ + 'found' => $this->found, + 'url' => rtrim((string) config('app.url'), '/').'/autopodbor', + ], + ); + } +} diff --git a/app/app/Mail/BotContactRequestMail.php b/app/app/Mail/BotContactRequestMail.php new file mode 100644 index 00000000..22dd4188 --- /dev/null +++ b/app/app/Mail/BotContactRequestMail.php @@ -0,0 +1,60 @@ + $transcript вся переписка чата («Клиент: …», «Бот: …») + * @param string $source 'portal' (личный кабинет) или 'landing' (гость) + * @param int|null $userId ID клиента, если он вошёл в кабинет; null для гостя + */ + public function __construct( + public readonly string $phone, + public readonly string $question, + public readonly string $chatId, + public readonly array $transcript = [], + public readonly string $source = 'portal', + public readonly ?int $userId = null, + ) {} + + public function envelope(): Envelope + { + return new Envelope( + subject: 'Лидерра. Клиент из чата просит связаться — '.$this->phone, + ); + } + + public function content(): Content + { + return new Content( + view: 'emails.bot_contact_request', + with: [ + 'phone' => $this->phone, + 'question' => $this->question, + 'chatId' => $this->chatId, + 'transcript' => $this->transcript, + 'source' => $this->source, + 'userId' => $this->userId, + ], + ); + } +} diff --git a/app/app/Mail/ChatBudgetAlertMail.php b/app/app/Mail/ChatBudgetAlertMail.php new file mode 100644 index 00000000..a9b5e6fd --- /dev/null +++ b/app/app/Mail/ChatBudgetAlertMail.php @@ -0,0 +1,37 @@ + $this->used, + 'limit' => ChatRateLimiter::GLOBAL_PER_DAY, + ], + ); + } +} diff --git a/app/app/Mail/ExternalServiceDownMail.php b/app/app/Mail/ExternalServiceDownMail.php new file mode 100644 index 00000000..23d621ff --- /dev/null +++ b/app/app/Mail/ExternalServiceDownMail.php @@ -0,0 +1,40 @@ + $services */ + public function __construct( + public readonly array $services, + public readonly CarbonInterface $checkedAt, + ) {} + + public function envelope(): Envelope + { + $names = implode(', ', array_map(fn ($s) => $s['key'], $this->services)); + + return new Envelope(subject: 'Лидерра: внешний сервис недоступен / баланс на исходе — '.$names); + } + + public function content(): Content + { + return new Content(view: 'emails.external_service_down'); + } +} diff --git a/app/app/Models/AutopodborCompetitor.php b/app/app/Models/AutopodborCompetitor.php new file mode 100644 index 00000000..9b70970c --- /dev/null +++ b/app/app/Models/AutopodborCompetitor.php @@ -0,0 +1,63 @@ + 'bool', + 'directory_urls' => 'array', + 'elements' => 'array', + 'phones' => 'array', + 'provenance' => 'array', + 'dismissed_actualize_keys' => 'array', + 'studied_at' => 'datetime', + 'created_at' => 'datetime', + ]; + + public function sources(): HasMany + { + return $this->hasMany(AutopodborSource::class, 'competitor_id'); + } + + public function searchRun(): BelongsTo + { + return $this->belongsTo(AutopodborRun::class, 'search_run_id'); + } + + public function studyRun(): BelongsTo + { + return $this->belongsTo(AutopodborRun::class, 'study_run_id'); + } +} diff --git a/app/app/Models/AutopodborMergeEvent.php b/app/app/Models/AutopodborMergeEvent.php new file mode 100644 index 00000000..b84e6470 --- /dev/null +++ b/app/app/Models/AutopodborMergeEvent.php @@ -0,0 +1,47 @@ + 'array', + // Снимок источников (autopodbor_sources) каждого поглощённого конкурента — по id конкурента, + // чтобы «Вернуть» мог отдать источники (и через них — проекты) назад воскрешённой карточке, + // а не оставлять их у выжившей. NULL у записей журнала, созданных до этой возможности + // (см. миграцию 2026_07_14_090000) — такие события восстанавливаются частично (без источников). + 'absorbed_sources' => 'array', + 'restored' => 'bool', + 'created_at' => 'datetime', + ]; + + public function survivor(): BelongsTo + { + return $this->belongsTo(AutopodborCompetitor::class, 'survivor_id'); + } +} diff --git a/app/app/Models/AutopodborRun.php b/app/app/Models/AutopodborRun.php new file mode 100644 index 00000000..2e1b0061 --- /dev/null +++ b/app/app/Models/AutopodborRun.php @@ -0,0 +1,48 @@ + 'array', + 'progress' => 'array', + 'result' => 'array', + 'price_rub_charged' => 'decimal:2', + 'started_at' => 'datetime', + 'finished_at' => 'datetime', + 'created_at' => 'datetime', + ]; + + public function competitors(): HasMany + { + return $this->hasMany(AutopodborCompetitor::class, 'search_run_id'); + } +} diff --git a/app/app/Models/AutopodborSource.php b/app/app/Models/AutopodborSource.php new file mode 100644 index 00000000..76cf2943 --- /dev/null +++ b/app/app/Models/AutopodborSource.php @@ -0,0 +1,50 @@ + 'datetime', + 'where_found' => 'array', + 'confirmations' => 'integer', + ]; + + public function competitor(): BelongsTo + { + return $this->belongsTo(AutopodborCompetitor::class, 'competitor_id'); + } + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class, 'created_project_id'); + } +} diff --git a/app/app/Models/BalanceTransaction.php b/app/app/Models/BalanceTransaction.php index bd082341..f949da2f 100644 --- a/app/app/Models/BalanceTransaction.php +++ b/app/app/Models/BalanceTransaction.php @@ -42,6 +42,8 @@ class BalanceTransaction extends Model public const TYPE_MIGRATION = 'migration'; + public const TYPE_AUTOPODBOR_CHARGE = 'autopodbor_charge'; + public $timestamps = false; protected $fillable = [ diff --git a/app/app/Models/BotDialog.php b/app/app/Models/BotDialog.php new file mode 100644 index 00000000..5b8003ec --- /dev/null +++ b/app/app/Models/BotDialog.php @@ -0,0 +1,21 @@ + 'array', 'escalated' => 'bool', 'created_at' => 'datetime']; +} diff --git a/app/app/Models/KnowledgeChunk.php b/app/app/Models/KnowledgeChunk.php new file mode 100644 index 00000000..9ba1e10a --- /dev/null +++ b/app/app/Models/KnowledgeChunk.php @@ -0,0 +1,17 @@ +app->bind( + LlmClient::class, + fn ($app) => config('services.bot.llm') === 'aitunnel' + ? $app->make(AitunnelChatClient::class) + : $app->make(YandexGptClient::class), + ); + // Резерв канала миграции проектов: SupplierProjectChannel резолвится в // декоратор-оркестратор (ярус 1 AJAX → ярус 2 browser-form → ярус 3 queue). // Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.4 @@ -120,6 +131,10 @@ class AppServiceProvider extends ServiceProvider // прикрыт per-источник лимитом 120/мин ПЕРЕД ApiKeyAuth — режет brute/DoS // по ключам и снимает нагрузку bcrypt/DB до аутентификации. Ключ лимитера — // сам Bearer-ключ (sha256, «per ключ»); без заголовка — fallback на IP. + // track (spec 2026-07-13): публичный маячок учёта посетителей — 60/мин с IP, + // защита от накрутки счётчика. + RateLimiter::for('track', fn (Request $request) => Limit::perMinute(60)->by($request->ip() ?: 'unknown')); + RateLimiter::for('api-v1', function (Request $request) { $header = (string) $request->header('Authorization', ''); $bearer = str_starts_with($header, 'Bearer ') diff --git a/app/app/Providers/AutopodborServiceProvider.php b/app/app/Providers/AutopodborServiceProvider.php new file mode 100644 index 00000000..94079265 --- /dev/null +++ b/app/app/Providers/AutopodborServiceProvider.php @@ -0,0 +1,240 @@ +app->singleton(RunProgressChannel::class); + + // Шаг 2 (изучение конкурента) — настоящий движок: сайт конкурента берём обычным curl + + // локальный Playwright, справочники 2ГИС/Яндекс — через антибот xfetch.ru ИЛИ свой рендер-сервис. + // + // Слой добычи 2ГИС/Яндекс переключается флагом autopodbor.self_render (§4, §12): + // ВКЛ → SelfRenderClient (виртуалка+прокси; 2ГИС отдаёт initialState) + TwoGisJsonReader. + // ВЫКЛ → XfetchClient (xf4) + HtmlTwoGisReader — как сейчас. + // Фетчер и 2ГИС-ридер меняются ПАРОЙ (иначе JSON уйдёт в HTML-парсер и вернёт пусто). + // + // Шаг 1 (поиск): при autopodbor.real_find — ЖИВОЙ движок; без ИИ-ключа отсев/похожесть отключены + // (деградация). Флаг ВЫКЛ → findCompetitors отдаёт демо-заглушку. + $this->app->bind(CompetitorAgent::class, function ($app): CompetitorAgent { + $selfRender = (bool) config('autopodbor.self_render'); + $sr = $selfRender ? $this->selfRenderClient() : null; + + // Загрузчик xf4 (нужен всегда для xf4-пути и как база RoutingBatchFetcher под Яндекс). + $xfetch = new XfetchClient( + apiKey: config('services.xfetch.key'), + endpoint: config('services.xfetch.endpoint', 'https://xf4.ru/fetch'), + concurrency: (int) config('services.xfetch.concurrency', 4), + ); + + // Основной загрузчик КАРТОЧЕК: self-render ИЛИ xf4 — обёрнут кэшем (§render_cache). + // Ключ по URL в Redis → шаг 1 и шаг 2 делят ОДИН кэш; кэшируются только карточки + // (/firm/, /maps/org/), поиск/ленты (/search/) идут мимо (passthrough). Флаг ВЫКЛ → как есть. + $pages = $this->wrapCache($sr ?? $xfetch); + // В self-render режиме обёрнутый загрузчик = обёрнутый self-render (тот же экземпляр $pages). + $srCards = $sr !== null ? $pages : null; + $reader = $this->twoGisReader(); + + // Таймаут 15с (было 45): сайт конкурента отвечает за 1-3с; режем только висяки, не теряя живых. + $siteFetcher = new CurlPlaywrightFetcher(timeout: 15); + $fetcher = new CompositeFetcher( + siteFetcher: $siteFetcher, + 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), + reverse: config('autopodbor.phone_reverse') + ? $this->buildPhoneReverse($pages, $reader) + : null, + reader: $reader, + // self-render: Яндекс-справочник шага 2 — через виртуалку+прокси; иначе (null) движок + // строит дефолт (локальный Playwright). §5.3/§5.7. Загрузчик карточек — обёрнутый кэшем. + yandexDir: $srCards !== null ? new XfetchDirectoryFetcher($srCards, new DirectoryParser) : null, + ) + : null; + + $liveFind = config('autopodbor.real_find') + ? $this->buildLiveFind($xfetch, $srCards, $reader) + : null; + + return new RealCompetitorAgent( + $fetcher, + new FakeCompetitorAgent, + validityGate: $validityGate, + liveFind: $liveFind, + deep: $deep, + progress: $app->make(RunProgressChannel::class), + ); + }); + } + + /** + * Обернуть загрузчик КАРТОЧЕК общим кэшем (§render_cache). ВКЛ → {@see CachingPageFetcher} + * (карточки /firm/, /maps/org/ в Redis на N дней; поиск/ленты — мимо). ВЫКЛ → фетчер как есть. + * Ключ по URL → любые обёрнутые экземпляры делят один кэш (шаг 1 ↔ шаг 2 ↔ повторный прогон). + */ + public function wrapCache(BatchPageFetcher $inner): BatchPageFetcher + { + return config('autopodbor.render_cache') + ? new CachingPageFetcher($inner, (int) config('autopodbor.render_cache_ttl_days', 5)) + : $inner; + } + + /** Клиент своего рендер-сервиса (виртуалка). Без endpoint/key деградирует в пусто (§5.6). */ + private function selfRenderClient(): SelfRenderClient + { + return new SelfRenderClient( + endpoint: (string) config('services.self_render.endpoint', ''), + key: (string) config('services.self_render.key', ''), + concurrency: (int) config('services.self_render.concurrency', 8), + ); + } + + /** 2ГИС-ридер под текущий флаг: initialState-JSON (self-render) ИЛИ HTML (xf4). Пара к фетчеру. */ + public function twoGisReader(): TwoGisReader + { + return config('autopodbor.self_render') ? new TwoGisJsonReader : new HtmlTwoGisReader; + } + + /** Источник ленты Яндекс.Карт: виртуалка+прокси (self-render) ИЛИ локальный Playwright (xf4). */ + public function yandexListSource(): YandexListSource + { + if (config('autopodbor.self_render')) { + return new RenderServiceYandexListSource( + (string) config('services.self_render.endpoint', ''), + (string) config('services.self_render.key', ''), + ); + } + + return new LocalYandexListSource((string) config('autopodbor.node_bin', 'node')); + } + + private function buildPhoneReverse(BatchPageFetcher $pages, TwoGisReader $reader): PhoneReverse + { + $gis = new PhoneReverseExpander($pages, $reader); + if (! config('autopodbor.phone_reverse_yandex')) { + return $gis; + } + $node = (string) config('autopodbor.node_bin', 'node'); + // Яндекс-разворот: под self_render — виртуалка+прокси (лента + чтение карточки), иначе локальный Playwright. + // Чтение карточек (/maps/org/) под self_render оборачиваем общим кэшем; Playwright (xf4) — нет. + $cardFetcher = config('autopodbor.self_render') + ? $this->wrapCache($this->selfRenderClient()) + : new PlaywrightPageFetcher($node); + $yandex = new YandexPhoneReverseExpander( + new PlaywrightYandexPhoneSearch($this->yandexListSource()), + new PlaywrightYandexCardReader($cardFetcher), + ); + + // phone_reverse_parallel: 2ГИС и Яндекс крутятся ОДНОВРЕМЕННО (итог = max, оба источника целы). + // Иначе — последовательный композит (как раньше). По умолчанию ВЫКЛ, откат — флаг в false. + return config('autopodbor.phone_reverse_parallel') + ? new ParallelPhoneReverse($gis, $yandex) + : new CompositePhoneReverse([$gis, $yandex]); + } + + /** + * Живой движок поиска шага 1 — ФИНАЛ v4: АНАЛИЗ (мелкая модель → запросы-рубрики) → КАНАЛ А (скрейп + * категории 2ГИС с пагинацией → карточки) → КАНАЛ В (модель → ИМЕНА федералов; их САЙТ через EXA) → + * сборка (отсев агрегаторов + дедуп + похожесть + DTO). Все ИИ/exa/фетч-клиенты деградируют при пустом + * ключе. Слой 2ГИС/Яндекс — self-render ($sr) ИЛИ xf4 ($xfetch), пара с $reader. + */ + private function buildLiveFind(XfetchClient $xfetch, ?BatchPageFetcher $srCards, TwoGisReader $reader): LiveFindCompetitors + { + $http = $this->app->make(HttpFactory::class); + $node = (string) config('autopodbor.node_bin', 'node'); + + // Скрейп листинга 2ГИС (страницы /search/ — не кэшируются): self-render ИЛИ LivePageFetcher(xfetch). + $catPages = $srCards ?? new LivePageFetcher($xfetch); + // Обогатитель КАРТОЧЕК: self-render (обёрнут кэшем) ИЛИ RoutingBatchFetcher, где 2ГИС-база + // обёрнута кэшем (/firm/ из кэша), а карточки Яндекса — локальный Playwright (кэш не трогаем). + $enrichPages = $srCards ?? new RoutingBatchFetcher($this->wrapCache($xfetch), ['yandex' => new PlaywrightPageFetcher($node)]); + + $assembler = new FindCompetitorsAssembler( + new AggregatorFilter($this->app->make(AitunnelAggregatorClassifier::class)), + new AutopodborDedup(new AutopodborNormalizer), + new EmbeddingRelevance($this->app->make(AitunnelEmbedder::class)), + ); + + return new LiveFindCompetitors( + new AitunnelQueryAnalyzer($http), + // 2ГИС: 4 страницы на рубрику (5-я — «хвост» низкоранговых, шум). Кол-во режем на старте. + new CategoryScraper($catPages, $reader, maxPages: 4), + new PlaywrightYandexDirectory($this->yandexListSource()), + new ChannelBSearch(new AitunnelResearcher($http), new ResearcherParser), + new ExaSiteFinder($http), + $assembler, + enricher: new CompetitorCardEnricher($enrichPages, new DirectoryParser, $reader), + // Канал прогресса (синглтон): джоба привяжет его к прогону, движок отметит этапы. + progress: $this->app->make(RunProgressChannel::class), + ); + } +} diff --git a/app/app/Services/Audit/AuditChainConfig.php b/app/app/Services/Audit/AuditChainConfig.php index f8b91ee7..a6d19ce6 100644 --- a/app/app/Services/Audit/AuditChainConfig.php +++ b/app/app/Services/Audit/AuditChainConfig.php @@ -13,8 +13,11 @@ use InvalidArgumentException; * verify (App\Console\Commands\VerifyAuditChains) and rebuild * (App\Console\Commands\AuditRebuildChain). * - * ADR-018: per-tenant via RLS scope for tenant tables, - * global for BYPASSRLS tables. + * 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 => ''). * * columns: list in ordinal_position order from db/schema.sql. * '__log_hash__' -- marker for log_hash position -> NULL::bytea in ROW(). @@ -43,7 +46,7 @@ final class AuditChainConfig 'old_value', 'new_value', 'context', 'ip_address', 'user_agent', '__log_hash__', 'created_at', ], - 'partition' => 'PARTITION BY tenant_id', + 'partition' => '', // ADR-021: global-within-partition (per-tenant never реализован) ], 'tenant_operations_log' => [ 'columns' => [ @@ -51,7 +54,7 @@ final class AuditChainConfig 'event', 'payload_before', 'payload_after', 'ip_address', 'user_agent', '__log_hash__', 'created_at', ], - 'partition' => 'PARTITION BY tenant_id', + 'partition' => '', // ADR-021: global-within-partition (per-tenant never реализован) ], 'balance_transactions' => [ 'columns' => [ @@ -60,7 +63,7 @@ final class AuditChainConfig 'related_type', 'related_id', 'user_id', 'admin_user_id', '__log_hash__', 'created_at', ], - 'partition' => 'PARTITION BY tenant_id', + 'partition' => '', // ADR-021: global-within-partition (per-tenant never реализован) ], 'pd_processing_log' => [ 'columns' => [ @@ -68,7 +71,7 @@ final class AuditChainConfig 'purpose', 'actor_tenant_user_id', 'actor_admin_user_id', 'ip_address', '__log_hash__', 'created_at', ], - 'partition' => 'PARTITION BY tenant_id', + 'partition' => '', // ADR-021: global-within-partition (per-tenant never реализован) ], 'saas_admin_audit_log' => [ 'columns' => [ diff --git a/app/app/Services/Auth/RegistrationService.php b/app/app/Services/Auth/RegistrationService.php index 02533b8b..01665501 100644 --- a/app/app/Services/Auth/RegistrationService.php +++ b/app/app/Services/Auth/RegistrationService.php @@ -35,7 +35,7 @@ class RegistrationService private const MAX_FAILED_ATTEMPTS = 5; - private const START_BALANCE_RUB = '300.00'; + private const START_BALANCE_RUB = '1000.00'; public function __construct(private readonly CaptchaVerifier $captcha) {} diff --git a/app/app/Services/Autopodbor/Agent/Aggregator/AggregatorClassifier.php b/app/app/Services/Autopodbor/Agent/Aggregator/AggregatorClassifier.php new file mode 100644 index 00000000..a13f8d1e --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Aggregator/AggregatorClassifier.php @@ -0,0 +1,19 @@ + $candidates + * @return array + */ + public function filter(array $candidates): array + { + $candidates = array_values($candidates); + + // Классификатор умеет батч → один запрос на весь список (иначе ~90 запросов по одному, «умирает»). + $verdicts = $this->classifier instanceof BatchAggregatorClassifier && $candidates !== [] + ? $this->classifier->classifyManyAggregators(array_map(fn (array $c): array => [ + 'name' => (string) ($c['name'] ?? ''), + 'site_url' => $c['site_url'] ?? null, + 'description' => $c['description'] ?? null, + ], $candidates)) + : null; + + $out = []; + foreach ($candidates as $i => $c) { + $verdict = $verdicts !== null + ? ($verdicts[$i] ?? null) + : $this->classifier->isAggregator( + (string) ($c['name'] ?? ''), + $c['site_url'] ?? null, + $c['description'] ?? null, + ); + if ($verdict === true) { + continue; // площадка — не конкурент + } + $out[] = $c; + } + + return array_values($out); + } +} diff --git a/app/app/Services/Autopodbor/Agent/Aggregator/AitunnelAggregatorClassifier.php b/app/app/Services/Autopodbor/Agent/Aggregator/AitunnelAggregatorClassifier.php new file mode 100644 index 00000000..b2cae63e --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Aggregator/AitunnelAggregatorClassifier.php @@ -0,0 +1,189 @@ + $companies + * @return list + */ + public function classifyManyAggregators(array $companies): array + { + $companies = array_values($companies); + $n = count($companies); + $none = array_fill(0, $n, null); + if ($n === 0) { + return []; + } + + $cfg = (array) config('services.aitunnel'); + $key = (string) ($cfg['key'] ?? ''); + if ($key === '') { + return $none; + } + + $batchSize = max(1, (int) ($cfg['aggregator_batch_size'] ?? 40)); + $out = $none; + $offset = 0; + foreach (array_chunk($companies, $batchSize) as $chunk) { + foreach ($this->classifyChunk($chunk, $key, $cfg) as $i => $verdict) { + $out[$offset + $i] = $verdict; + } + $offset += count($chunk); + } + + return $out; + } + + /** + * Одна порция: нумеруем 1..m, один запрос к модели с длинным таймаутом. Ответ → вердикты порции. + * + * @param list $chunk + * @return list + */ + private function classifyChunk(array $chunk, string $key, array $cfg): array + { + $none = array_fill(0, count($chunk), null); + + $lines = []; + foreach ($chunk as $i => $c) { + $lines[] = ($i + 1).'. «'.($c['name'] ?? '').'» сайт: '.($c['site_url'] ?: '—'). + ' описание: '.($c['description'] ?: '—'); + } + $prompt = "Список компаний:\n".implode("\n", $lines)."\n\n". + 'Для КАЖДОЙ определи: это сам поставщик услуги/товара (SUPPLIER) или площадка-агрегатор/' + .'каталог/маркетплейс/сравнение/справочник, который сводит клиентов с разными компаниями ' + .'(AGGREGATOR)? Ответ — СТРОГО JSON-массив по номерам, без текста вне него: ' + .'[{"n":1,"v":"SUPPLIER"},{"n":2,"v":"AGGREGATOR"}]'; + + try { + $resp = $this->http + ->withToken($key) + ->timeout((int) ($cfg['aggregator_timeout_sec'] ?? 90)) + ->post(rtrim((string) ($cfg['base_url'] ?? ''), '/').'/chat/completions', [ + 'model' => $this->model($cfg), + 'messages' => [['role' => 'user', 'content' => $prompt]], + 'temperature' => 0, + ]); + + if (! $resp->successful()) { + return $none; + } + + return $this->parseVerdicts((string) $resp->json('choices.0.message.content'), $none); + } catch (\Throwable) { + return $none; + } + } + + /** + * JSON-массив [{n,v}] из ответа → вердикты по индексам (n − 1). Пропущенные — остаются null. + * + * @param list $none болванка нужной длины + * @return list + */ + private function parseVerdicts(string $content, array $none): array + { + $start = strpos($content, '['); + $end = strrpos($content, ']'); + if ($start === false || $end === false || $end < $start) { + return $none; + } + $arr = json_decode(substr($content, $start, $end - $start + 1), true); + if (! is_array($arr)) { + return $none; + } + + $out = $none; + $count = count($none); + foreach ($arr as $row) { + if (! is_array($row) || ! isset($row['n'])) { + continue; + } + $idx = ((int) $row['n']) - 1; + if ($idx < 0 || $idx >= $count) { + continue; + } + $v = mb_strtolower((string) ($row['v'] ?? '')); + if (str_contains($v, 'aggregator')) { + $out[$idx] = true; + } elseif (str_contains($v, 'supplier')) { + $out[$idx] = false; + } + } + + return $out; + } + + /** + * Модель для отсева агрегаторов: aggregator_model (посильнее, вариант А) → фолбэк на общий + * chat_model → gpt-4o-mini. Отдельный ключ, чтобы усилить точность классификации, не трогая + * остальные ИИ-вызовы. + * + * @param array $cfg + */ + private function model(array $cfg): string + { + $m = (string) ($cfg['aggregator_model'] ?? ''); + + return $m !== '' ? $m : (string) ($cfg['chat_model'] ?? 'gpt-4o-mini'); + } + + public function isAggregator(string $name, ?string $siteUrl, ?string $description): ?bool + { + $cfg = (array) config('services.aitunnel'); + $key = (string) ($cfg['key'] ?? ''); + if ($key === '') { + return null; + } + + $prompt = "Компания: «{$name}». Сайт: ".($siteUrl ?: '—').'. Описание: '.($description ?: '—').'. ' + .'Это сам поставщик услуги/товара (ответь SUPPLIER) или площадка-агрегатор/каталог/' + .'маркетплейс/сравнение/справочник, который сводит клиентов с разными компаниями ' + .'(ответь AGGREGATOR)? Ответь РОВНО одним словом: SUPPLIER или AGGREGATOR.'; + + try { + $resp = $this->http + ->withToken($key) + ->timeout((int) ($cfg['timeout_sec'] ?? 30)) + ->post(rtrim((string) ($cfg['base_url'] ?? ''), '/').'/chat/completions', [ + 'model' => $this->model($cfg), + 'messages' => [['role' => 'user', 'content' => $prompt]], + 'temperature' => 0, + ]); + + if (! $resp->successful()) { + return null; + } + + $content = mb_strtolower((string) $resp->json('choices.0.message.content')); + if (str_contains($content, 'aggregator')) { + return true; + } + if (str_contains($content, 'supplier')) { + return false; + } + + return null; + } catch (\Throwable) { + return null; + } + } +} diff --git a/app/app/Services/Autopodbor/Agent/Aggregator/BatchAggregatorClassifier.php b/app/app/Services/Autopodbor/Agent/Aggregator/BatchAggregatorClassifier.php new file mode 100644 index 00000000..ba686a5c --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Aggregator/BatchAggregatorClassifier.php @@ -0,0 +1,19 @@ + $companies + * @return list вердикты по индексам входа: true — агрегатор, false — поставщик, null — не решил + */ + public function classifyManyAggregators(array $companies): array; +} diff --git a/app/app/Services/Autopodbor/Agent/ChannelA/AitunnelQueryAnalyzer.php b/app/app/Services/Autopodbor/Agent/ChannelA/AitunnelQueryAnalyzer.php new file mode 100644 index 00000000..91efd2bd --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/ChannelA/AitunnelQueryAnalyzer.php @@ -0,0 +1,135 @@ +contextClause($selfSite, $competitorExamples); + + // Примеры НАМЕРЕННО из РАЗНЫХ отраслей — движок универсален (клиент Лидерры может быть любой + // сферы и региона). Рубрики ИИ подбирает ПОД ОПИСАНИЕ клиента. Требуем УЗКИЕ категории и + // запрещаем зонтичные слова («финансовые услуги» и т.п.) — иначе канал А тащит не тех (юрфирмы, банки). + $prompt = "Описание деятельности клиента: «{$description}». {$context}Регион: {$region}.\n". + 'Дай от 3 до 6 КОРОТКИХ запросов-рубрик, по которым в справочниках 2ГИС и Яндекс.Карты '. + "ищут именно ПРЯМЫХ конкурентов клиента — как называется его УЗКАЯ категория.\n". + 'Правило: рубрики только КОНКРЕТНЫЕ и узкие. ЗАПРЕЩЕНЫ общие «зонтичные» слова, которые '. + 'тащат кого попало: «услуги», «финансовые услуги», «финансы», «медицина», «товары», «магазин», '. + "«сервис», «компания». Каждая рубрика — точная специализация, а не сфера целиком.\n". + 'Примеры (узкое, НЕ зонтичное): стоматология → «стоматология», «имплантация зубов» (НЕ «медицина»); '. + 'автосервис → «шиномонтаж», «автосервис» (НЕ «услуги»); займы под залог авто → «автоломбард», '. + '«займ под ПТС» (НЕ «финансовые услуги»). Подбери рубрики строго ПОД ОПИСАНИЕ клиента выше. '. + 'Только сами запросы, без пояснений, без названий фирм и без города. '. + 'Ответ — строго JSON-массив строк: ["...","..."]'; + + try { + $resp = $this->http + ->withToken($key) + ->timeout((int) ($cfg['timeout_sec'] ?? 30)) + ->post(rtrim((string) ($cfg['base_url'] ?? ''), '/').'/chat/completions', [ + 'model' => $cfg['chat_model'] ?? 'gpt-4o-mini', + 'messages' => [['role' => 'user', 'content' => $prompt]], + 'temperature' => 0, + ]); + + if (! $resp->successful()) { + return $fallback; + } + + $queries = $this->parseQueries((string) $resp->json('choices.0.message.content')); + + return $queries !== [] ? $queries : $fallback; + } catch (\Throwable) { + return $fallback; + } + } + + /** + * Контекстная фраза «посмотри наш сайт X, а также наших главных конкурентов Y» — условно: + * каждая часть добавляется, только если данные есть. Пусто → ''. + * + * @param list $competitorExamples + */ + private function contextClause(string $selfSite, array $competitorExamples): string + { + $selfSite = trim($selfSite); + $examples = array_values(array_filter(array_map( + static fn ($v): string => trim((string) $v), + $competitorExamples, + ), static fn (string $v): bool => $v !== '')); + + $parts = []; + if ($selfSite !== '') { + $parts[] = "посмотри наш сайт {$selfSite}"; + } + if ($examples !== []) { + $parts[] = 'а также наших главных конкурентов '.implode(', ', $examples); + } + + return $parts !== [] ? implode(', ', $parts).' ' : ''; + } + + /** + * JSON-массив строк из сырого ответа: фрагмент `[`…`]`, дедуп (без регистра), отсев пустых, лимит. + * + * @return list + */ + private function parseQueries(string $raw): array + { + $start = strpos($raw, '['); + $end = strrpos($raw, ']'); + if ($start === false || $end === false || $end < $start) { + return []; + } + $decoded = json_decode(substr($raw, $start, $end - $start + 1), true); + if (! is_array($decoded)) { + return []; + } + + $out = []; + $seen = []; + foreach ($decoded as $q) { + if (! is_string($q)) { + continue; + } + $q = trim($q); + $k = mb_strtolower($q, 'UTF-8'); + if ($q === '' || isset($seen[$k])) { + continue; + } + $seen[$k] = true; + $out[] = $q; + if (count($out) >= self::MAX_QUERIES) { + break; + } + } + + return $out; + } +} diff --git a/app/app/Services/Autopodbor/Agent/ChannelA/CategoryListingParser.php b/app/app/Services/Autopodbor/Agent/ChannelA/CategoryListingParser.php new file mode 100644 index 00000000..d9a3c2ce --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/ChannelA/CategoryListingParser.php @@ -0,0 +1,107 @@ +) + САЙТ. + * + * Сайт лежит в редиректе «Перейти на сайт»: `…//null/?http://site.ru…` — id в пути = id + * фирмы, настоящий адрес после `?`. Мусор (агрегаторы/мессенджеры/трекеры/иностранные TLD) отсеиваем — + * это не сайт фирмы. Телефоны/описание тут НЕ трогаем (это шаг 2). Чистый: на вход HTML, наружу не ходит. + */ +final class CategoryListingParser +{ + /** Не сайт фирмы: агрегаторы/каталоги/соцсети/мессенджеры/трекеры. */ + private const BLACKLIST = [ + 'avito.ru', 'youla.ru', 'zoon.ru', 'banki.ru', 'sravni.ru', 'flamp.ru', 'orgpage.ru', + 'max.ru', 'vk.com', 'ok.ru', 't.me', 'telegram.me', 'wa.me', 'facebook.com', 'instagram.com', + 'youtube.com', 'dzen.ru', 'rambler.ru', 'mail.ru', 'tns-counter.ru', 'top100.ru', + 'serving-sys.ru', 'otello.ru', 'russpass.ru', 'gosuslugi.ru', + ]; + + private const FOREIGN_TLD = ['.kg', '.kz', '.by', '.ua', '.ge', '.am', '.uz', '.md', '.com.tr']; + + /** + * @return list + */ + public function parse(string $html): array + { + $sites = $this->siteMap($html); + + $out = []; + $seen = []; + $re = '#([^<]+)#u'; + if (preg_match_all($re, $html, $m, PREG_SET_ORDER)) { + foreach ($m as $hit) { + $id = $hit[2]; + if (isset($seen[$id])) { + continue; + } + $seen[$id] = true; + $name = trim(html_entity_decode($hit[3], ENT_QUOTES | ENT_HTML5, 'UTF-8')); + if ($name === '') { + continue; + } + $out[] = [ + 'name' => $name, + 'card_url' => 'https://2gis.ru'.$hit[1], + 'site' => $sites[$id] ?? null, + ]; + } + } + + return $out; + } + + /** + * firmId → домен сайта из редиректов «Перейти на сайт». Первый «настоящий» на фирму. + * + * @return array + */ + private function siteMap(string $html): array + { + $sites = []; + if (preg_match_all('#/(\d{10,})/null/[^"?]*\?(https?://[^"\\\\?]+)#', $html, $m, PREG_SET_ORDER)) { + foreach ($m as $hit) { + $id = $hit[1]; + if (isset($sites[$id])) { + continue; + } + $domain = $this->domain($hit[2]); + if ($domain !== null && $this->isRealSite($domain)) { + $sites[$id] = $domain; + } + } + } + + return $sites; + } + + private function domain(string $url): ?string + { + $url = mb_strtolower(trim($url), 'UTF-8'); + $url = preg_replace('#^https?://#', '', $url); + $url = preg_replace('#^www\.#', '', $url); + $url = explode('/', $url)[0]; + $url = explode('?', $url)[0]; + + return $url !== '' ? $url : null; + } + + private function isRealSite(string $domain): bool + { + if (in_array($domain, self::BLACKLIST, true)) { + return false; + } + foreach (self::FOREIGN_TLD as $tld) { + if (str_ends_with($domain, $tld)) { + return false; + } + } + + return true; + } +} diff --git a/app/app/Services/Autopodbor/Agent/ChannelA/CategoryScraper.php b/app/app/Services/Autopodbor/Agent/ChannelA/CategoryScraper.php new file mode 100644 index 00000000..b9afb5ca --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/ChannelA/CategoryScraper.php @@ -0,0 +1,134 @@ + $queries запросы-рубрики из шага АНАЛИЗ + * @return list + */ + public function collectTwoGis(string $slug, array $queries): array + { + $seen = []; + $out = []; + + // Нормализуем и фильтруем пустые рубрики + $activeQueries = []; + foreach ($queries as $query) { + $query = trim((string) $query); + if ($query !== '') { + $activeQueries[] = $query; + } + } + + if ($activeQueries === []) { + return []; + } + + // Базовые URL для каждой рубрики (страница 1) + $baseUrls = []; + foreach ($activeQueries as $query) { + $baseUrls[$query] = "https://2gis.ru/{$slug}/search/".rawurlencode($query); + } + + // active — список рубрик, которые ещё «живы» (дали новые фирмы на предыдущей странице) + $active = $activeQueries; + + for ($page = 1; $page <= max(1, $this->maxPages); $page++) { + if ($active === []) { + break; + } + + // Строим URL текущей страницы для каждой активной рубрики + $urlByQuery = []; + foreach ($active as $query) { + $base = $baseUrls[$query]; + $urlByQuery[$query] = $page === 1 ? $base : $base."/page/{$page}"; + } + + // Загружаем пачку: BatchPageFetcher — параллельно; простой PageFetcher — по одному + $htmlByUrl = $this->fetchBatch(array_values($urlByQuery)); + + // Разбираем каждую рубрику; те, что дали 0 новых — выбывают + $stillActive = []; + foreach ($active as $query) { + $url = $urlByQuery[$query]; + $html = $htmlByUrl[$url] ?? ''; + $rows = $this->reader->listing($html); + + if ($rows === []) { + // Пустая страница — рубрика выбывает + continue; + } + + $added = 0; + foreach ($rows as $row) { + $key = $row['card_url']; + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + $out[] = $row; + $added++; + } + + if ($added > 0) { + $stillActive[] = $query; // есть новые — рубрика живёт + } + // $added === 0: страница без новых фирм → рубрика тоже выбывает + } + + $active = $stillActive; + } + + return $out; + } + + /** + * Загружает HTML нескольких URL. Если fetcher реализует BatchPageFetcher — делегирует + * в htmlBatch() (параллельно, порциями по лимиту сервиса). Иначе — цикл html() по одному + * (сохраняет совместимость с простыми PageFetcher-стабами в тестах). + * + * @param list $urls + * @return array url => html + */ + private function fetchBatch(array $urls): array + { + if ($this->pages instanceof BatchPageFetcher) { + return $this->pages->htmlBatch($urls); + } + + $result = []; + foreach ($urls as $url) { + $result[$url] = $this->pages->html($url); + } + + return $result; + } +} diff --git a/app/app/Services/Autopodbor/Agent/ChannelA/PlaywrightYandexDirectory.php b/app/app/Services/Autopodbor/Agent/ChannelA/PlaywrightYandexDirectory.php new file mode 100644 index 00000000..dd066f31 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/ChannelA/PlaywrightYandexDirectory.php @@ -0,0 +1,45 @@ + <город>`, + * скроллит и берёт организации (имя + карточка). Сам рендер — за {@see YandexListSource}: локальный + * Playwright (xf4-путь) ИЛИ виртуалка+прокси (self-render). Дедуп по SLUG — в {@see YandexListParser}. + * + * Источник за интерфейсом → оркестрация (URL по рубрике, потолок, слияние) тестируется офлайн фейком. + */ +final class PlaywrightYandexDirectory implements YandexDirectory +{ + public function __construct( + private readonly YandexListSource $source = new LocalYandexListSource, + // Берём ВСЕ рубрики (аналитик отдаёт до 8): каждая рубрика первой даёт самые релевантные фирмы. + private readonly int $maxQueries = 8, + // Сколько раз прокрутить ленту в каждой рубрике. Мало = быстро (верх выдачи = самые релевантные). + private readonly int $scrolls = 6, + private readonly YandexListParser $parser = new YandexListParser, + ) {} + + public function collect(string $city, array $queries): array + { + // Сырьё по всем рубрикам; дедуп/опознание фирмы по SLUG — в парсере («Корунд ×15 → один»). + $orgs = []; + foreach (array_slice($queries, 0, max(0, $this->maxQueries)) as $query) { + $query = trim((string) $query); + if ($query === '') { + continue; + } + $url = 'https://yandex.ru/maps/?text='.rawurlencode(trim($query.' '.$city)); + foreach ($this->source->fetch($url, $this->scrolls) as $org) { + $orgs[] = $org; + } + } + + return $this->parser->parse($orgs); + } +} diff --git a/app/app/Services/Autopodbor/Agent/ChannelA/QueryAnalyzer.php b/app/app/Services/Autopodbor/Agent/ChannelA/QueryAnalyzer.php new file mode 100644 index 00000000..30caef2b --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/ChannelA/QueryAnalyzer.php @@ -0,0 +1,20 @@ + $competitorExamples сайты/карточки конкурентов из запроса (пусто — не добавляем) + * @return list короткие запросы-рубрики (напр. «автоломбард», «займ под залог авто»). + */ + public function analyze(string $description, string $region, string $selfSite = '', array $competitorExamples = []): array; +} diff --git a/app/app/Services/Autopodbor/Agent/ChannelA/YandexDirectory.php b/app/app/Services/Autopodbor/Agent/ChannelA/YandexDirectory.php new file mode 100644 index 00000000..d38f9262 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/ChannelA/YandexDirectory.php @@ -0,0 +1,19 @@ + $queries запросы-рубрики из шага АНАЛИЗ + * @return list + */ + public function collect(string $city, array $queries): array; +} diff --git a/app/app/Services/Autopodbor/Agent/ChannelA/YandexListParser.php b/app/app/Services/Autopodbor/Agent/ChannelA/YandexListParser.php new file mode 100644 index 00000000..af3e80d4 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/ChannelA/YandexListParser.php @@ -0,0 +1,51 @@ +/`, а НЕ по id филиала и НЕ по тексту имени. + * + * Почему slug: у всех точек одной сети slug общий (напр. `korund`), а id у каждой точки свой — + * поэтому дедуп по slug честно схлопывает филиалы «Корунд ×15 → один» и склеивает разные написания + * одной фирмы («КрасЛомбард»/«Красломбард» → slug `kraslombard`). При этом две РАЗНЫЕ фирмы с похожим + * именем, которым Яндекс дал разные slug, остаются раздельными. Это опознавательный признак источника, + * а не догадка по строке. Чистый разбор: на вход сырой массив организаций из render, наружу не ходит. + */ +final class YandexListParser +{ + /** + * @param list> $orgs сырые организации из render-скрипта (name, href, category, ...) + * @return list + */ + public function parse(array $orgs): array + { + $seen = []; + $out = []; + + foreach ($orgs as $org) { + $href = trim((string) ($org['href'] ?? '')); + $name = trim((string) ($org['name'] ?? '')); + if ($name === '' || ! preg_match('#^/maps/org/([a-z0-9_-]+)/(\d+)#i', $href, $m)) { + continue; + } + $slug = mb_strtolower($m[1], 'UTF-8'); + if (isset($seen[$slug])) { + continue; // одна фирма (slug) = одна строка; филиалы схлопнуты + } + $seen[$slug] = true; + // Рубрика из списка Яндекса («Ломбард, автоломбард», «Микрофинансовая организация») = описание. + $category = trim((string) ($org['category'] ?? '')); + $out[] = [ + 'name' => $name, + 'card_url' => 'https://yandex.ru'.$href, + 'slug' => $slug, + 'description' => $category !== '' ? $category : null, + ]; + } + + return $out; + } +} diff --git a/app/app/Services/Autopodbor/Agent/ChannelB/AitunnelResearcher.php b/app/app/Services/Autopodbor/Agent/ChannelB/AitunnelResearcher.php new file mode 100644 index 00000000..58c05ada --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/ChannelB/AitunnelResearcher.php @@ -0,0 +1,51 @@ +http + ->withToken($key) + ->timeout((int) ($cfg['research_timeout_sec'] ?? 120)) + ->post(rtrim((string) ($cfg['base_url'] ?? ''), '/').'/chat/completions', [ + 'model' => $cfg['research_model'] ?? 'sonar-reasoning-pro', + 'messages' => [ + ['role' => 'system', 'content' => $system], + ['role' => 'user', 'content' => $user], + ], + 'temperature' => 0, + ]); + + if (! $resp->successful()) { + return '[]'; + } + + $content = (string) $resp->json('choices.0.message.content'); + + return $content !== '' ? $content : '[]'; + } catch (\Throwable) { + return '[]'; + } + } +} diff --git a/app/app/Services/Autopodbor/Agent/ChannelB/ChannelBSearch.php b/app/app/Services/Autopodbor/Agent/ChannelB/ChannelBSearch.php new file mode 100644 index 00000000..eca8d658 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/ChannelB/ChannelBSearch.php @@ -0,0 +1,93 @@ + $known имена, уже известные (список из канала А + примеры клиента) + * @return list только НОВЫЕ фирмы (имя + тип; сайт найдёт EXA) + */ + public function harvest(string $profile, string $region, string $clientSite, array $known, int $passes = 2): array + { + $accumulated = []; + $seen = []; + $stop = []; + + foreach ($known as $name) { + $name = trim((string) $name); + if ($name === '') { + continue; + } + $k = $this->key($name); + if (isset($seen[$k])) { + continue; // чистим стоп-лист от дублей-филиалов: «Корунд» ×27 → один + } + $seen[$k] = true; + $stop[] = $name; + } + + for ($pass = 1; $pass <= max(1, $passes); $pass++) { + $user = $this->userPrompt($profile, $region, $clientSite, $stop); + $raw = $this->client->research(self::SYSTEM_PROMPT, $user); + + foreach ($this->parser->parse($raw) as $cand) { + $k = $this->key($cand['name']); + if (isset($seen[$k])) { + continue; // уже известен (канал А, пример или прошлый проход) + } + $seen[$k] = true; + $accumulated[] = $cand; + $stop[] = $cand['name']; // стоп-лист растёт к следующему проходу + } + } + + return $accumulated; + } + + /** Пользовательский промт §11.3: профиль/регион/сайт клиента + список известных имён. */ + private function userPrompt(string $profile, string $region, string $clientSite, array $stop): string + { + $list = $stop === [] + ? '(пока пусто)' + : implode("\n", array_map(static fn (string $n): string => '- '.$n, $stop)); + + return "Наша компания: {$profile} в {$region}. Наш сайт: {$clientSite}.\n". + "Уже известные конкуренты — НЕ выводить их повторно:\n". + "{$list}\n". + "Дай ТОЛЬКО НОВЫЕ фирмы-конкуренты в {$region}, которых нет в списке — только название и тип. Ответ — строго в формате из системного промта."; + } + + /** Ключ дедупа по имени: ё→е, нижний регистр, только буквы/цифры. */ + private function key(string $name): string + { + $name = str_replace('ё', 'е', mb_strtolower($name, 'UTF-8')); + + return (string) preg_replace('/[^\p{L}\p{N}]+/u', '', $name); + } +} diff --git a/app/app/Services/Autopodbor/Agent/ChannelB/ExaSiteFinder.php b/app/app/Services/Autopodbor/Agent/ChannelB/ExaSiteFinder.php new file mode 100644 index 00000000..6d8d4af2 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/ChannelB/ExaSiteFinder.php @@ -0,0 +1,176 @@ +http + ->withHeaders(['x-api-key' => $key]) + ->withOptions($this->proxyOptions()) + ->timeout((int) ($cfg['timeout_sec'] ?? 30)) + ->post(rtrim((string) ($cfg['base_url'] ?? 'https://api.exa.ai'), '/').'/search', $this->body($name)); + + return $this->pickDomain($resp); + } catch (\Throwable) { + return null; + } + } + + /** + * Пакетный поиск сайтов ПАРАЛЛЕЛЬНО — пулом Laravel Http, порциями по services.exa.concurrency + * (внутри порции одновременно, порции по очереди). Лимит EXA — на КЛЮЧ (один на всех клиентов), + * поэтому не «все разом», а бережно; глобальный потолок между клиентами держит очередь. + * Сбой/429 по имени → его сайт null (не роняет пул и не теряет остальных). + * + * @param list $names + * @return array имя → домен (или null) + */ + public function findSites(array $names, string $region): array + { + $names = array_values(array_unique(array_filter( + array_map(static fn ($n): string => trim((string) $n), $names), + static fn (string $n): bool => $n !== '', + ))); + + $out = []; + foreach ($names as $n) { + $out[$n] = null; + } + if ($names === []) { + return $out; + } + + $cfg = (array) config('services.exa'); + $key = (string) ($cfg['key'] ?? ''); + if ($key === '') { + return $out; + } + + $url = rtrim((string) ($cfg['base_url'] ?? 'https://api.exa.ai'), '/').'/search'; + $timeout = (int) ($cfg['timeout_sec'] ?? 30); + $concurrency = max(1, (int) ($cfg['concurrency'] ?? 5)); + + foreach (array_chunk($names, $concurrency) as $chunk) { + try { + $responses = $this->http->pool(fn ($pool) => array_map( + fn (string $n) => $pool->as($n) + ->withHeaders(['x-api-key' => $key]) + ->withOptions($this->proxyOptions()) + ->timeout($timeout) + ->post($url, $this->body($n)), + $chunk, + )); + } catch (\Throwable) { + continue; // весь чанк не удался — имена остаются null, идём к следующему + } + + foreach ($chunk as $n) { + $out[$n] = $this->pickDomain($responses[$n] ?? null); + } + } + + return $out; + } + + /** Тело запроса EXA по имени. */ + private function body(string $name): array + { + return ['query' => $name.' официальный сайт', 'numResults' => 10, 'type' => 'auto']; + } + + /** + * Опции Guzzle для запроса EXA: прокси (services.exa.proxy), если задан. EXA за Cloudflare + * режет российские IP, поэтому на боевом канал В ходит через зарубежный выход. Пусто → напрямую. + * + * @return array + */ + private function proxyOptions(): array + { + $proxy = trim((string) (config('services.exa.proxy') ?? '')); + + return $proxy !== '' ? ['proxy' => $proxy] : []; + } + + /** Первый «настоящий» домен из ответа EXA (не каталог/агрегатор/иностранный TLD), иначе null. */ + private function pickDomain(mixed $resp): ?string + { + if (! $resp instanceof Response || ! $resp->successful()) { + return null; + } + foreach ((array) $resp->json('results') as $r) { + $url = is_array($r) ? (string) ($r['url'] ?? '') : ''; + $domain = $this->domain($url); + if ($domain !== null && $this->isRealSite($domain)) { + return $domain; + } + } + + return null; + } + + /** Голый домен из URL: без схемы/www/пути, нижний регистр. */ + private function domain(string $url): ?string + { + $url = mb_strtolower(trim($url), 'UTF-8'); + if ($url === '') { + return null; + } + $url = preg_replace('#^https?://#', '', $url); + $url = preg_replace('#^www\.#', '', $url); + $url = explode('/', $url)[0]; + $url = explode('?', $url)[0]; + + return $url !== '' ? $url : null; + } + + /** Настоящий сайт фирмы — не из чёрного списка и не иностранный TLD. */ + private function isRealSite(string $domain): bool + { + if (in_array($domain, self::BLACKLIST, true)) { + return false; + } + foreach (self::FOREIGN_TLD as $tld) { + if (str_ends_with($domain, $tld)) { + return false; + } + } + + return true; + } +} diff --git a/app/app/Services/Autopodbor/Agent/ChannelB/ResearcherClient.php b/app/app/Services/Autopodbor/Agent/ChannelB/ResearcherClient.php new file mode 100644 index 00000000..f8eb68ad --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/ChannelB/ResearcherClient.php @@ -0,0 +1,16 @@ + + */ + public function parse(string $raw): array + { + $out = []; + foreach ($this->decodeArray($raw) as $row) { + if (! is_array($row)) { + continue; + } + $name = trim((string) ($row['name'] ?? '')); + if ($name === '') { + continue; // без имени — мусор + } + // ТОЛЬКО имя+тип. Если модель по инерции положит site/card — игнорируем: сайт ищет EXA. + $out[] = [ + 'name' => $name, + 'type' => $this->str($row['type'] ?? null), + ]; + } + + return $out; + } + + /** + * Достаёт JSON-массив: фрагмент от первой `[` до последней `]`. Не распарсилось — пустой массив. + * + * @return array + */ + private function decodeArray(string $raw): array + { + $start = strpos($raw, '['); + $end = strrpos($raw, ']'); + if ($start === false || $end === false || $end < $start) { + return []; + } + $decoded = json_decode(substr($raw, $start, $end - $start + 1), true); + + return is_array($decoded) ? $decoded : []; + } + + private function str(mixed $v): ?string + { + if (! is_string($v)) { + return null; + } + $v = trim($v); + + return $v !== '' ? $v : null; + } +} diff --git a/app/app/Services/Autopodbor/Agent/CompetitorAgent.php b/app/app/Services/Autopodbor/Agent/CompetitorAgent.php new file mode 100644 index 00000000..827e616e --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/CompetitorAgent.php @@ -0,0 +1,19 @@ +parseYandexCard (свой формат, не 2ГИС). + private readonly TwoGisReader $twoGis = new HtmlTwoGisReader, + ) {} + + /** + * @param array $candidates + * @return array + */ + public function enrich(array $candidates): array + { + // Цели = ВСЕ кандидаты, у которых есть карточка справочника (гейта «сайт есть → пропустить» + // больше нет — максимум качества: вскрываем всё, кол-во режем на старте страницами/рубриками). + // idx => первая карточка. Без лимита. + $targets = []; + foreach ($candidates as $idx => $c) { + foreach ($c['directory_urls'] ?? [] as $u) { + $u = trim((string) $u); + if ($u !== '') { + $targets[$idx] = $u; + break; + } + } + } + if ($targets === []) { + return $candidates; + } + + // Пачкой (параллельно) если умеет, иначе по одной. + $urls = array_values(array_unique(array_values($targets))); + $htmls = $this->pages instanceof BatchPageFetcher + ? $this->pages->htmlBatch($urls) + : $this->fetchSequential($urls); + + foreach ($targets as $idx => $url) { + $html = $htmls[$url] ?? ''; + if ($html === '') { + continue; + } + $c = $candidates[$idx]; + foreach ($this->parseCards($html, $url) as $card) { + if (empty($c['site_url']) && $card->siteUrl !== null && $card->siteUrl !== '') { + $c['site_url'] = $card->siteUrl; + } + if (empty($c['description']) && $card->description !== null && $card->description !== '') { + $c['description'] = $card->description; + } + if ($card->number !== '') { + $c['phones'][] = $card->number; + } + } + $c['phones'] = array_values(array_unique($c['phones'] ?? [])); + $candidates[$idx] = $c; + } + + return $candidates; + } + + /** + * @param list $urls + * @return array + */ + private function fetchSequential(array $urls): array + { + $out = []; + foreach ($urls as $u) { + $out[$u] = $this->pages->html($u); + } + + return $out; + } + + /** + * Разбор карточки по типу справочника: Яндекс → parseYandexCard, 2ГИС → parse2gisCard. + * + * @return list + */ + private function parseCards(string $html, string $url): array + { + if (str_contains($url, 'yandex')) { + return $this->parser->parseYandexCard($html, $url); + } + if (str_contains($url, '2gis')) { + return $this->twoGis->card($html, $url); + } + + return []; + } +} diff --git a/app/app/Services/Autopodbor/Agent/Dto/CollectedSource.php b/app/app/Services/Autopodbor/Agent/Dto/CollectedSource.php new file mode 100644 index 00000000..6f124262 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Dto/CollectedSource.php @@ -0,0 +1,23 @@ + $sources */ + public function __construct( + public readonly string $signalType, // call | site + public readonly string $identifier, // 7XXXXXXXXXX | домен + public readonly ?string $phoneKind, // real | substitute | null + public readonly ?string $phoneType, // city | mobile | tollfree | null + public readonly ?string $office, // подпись филиала | null + public readonly array $sources, // «где нашли» + ) {} + + public function confirmations(): int + { + return count($this->sources); + } +} diff --git a/app/app/Services/Autopodbor/Agent/Dto/FindCompetitorsRequest.php b/app/app/Services/Autopodbor/Agent/Dto/FindCompetitorsRequest.php new file mode 100644 index 00000000..e92eee7d --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Dto/FindCompetitorsRequest.php @@ -0,0 +1,14 @@ + $competitors + */ + public function __construct(public readonly array $competitors) {} +} diff --git a/app/app/Services/Autopodbor/Agent/Dto/ResolveByNameRequest.php b/app/app/Services/Autopodbor/Agent/Dto/ResolveByNameRequest.php new file mode 100644 index 00000000..1b55159b --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Dto/ResolveByNameRequest.php @@ -0,0 +1,11 @@ + $candidates + */ + public function __construct(public readonly array $candidates) {} +} diff --git a/app/app/Services/Autopodbor/Agent/Dto/StudyCompetitorRequest.php b/app/app/Services/Autopodbor/Agent/Dto/StudyCompetitorRequest.php new file mode 100644 index 00000000..1de7a967 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Dto/StudyCompetitorRequest.php @@ -0,0 +1,14 @@ +,office?:?string,confirmations?:int}> $sources + */ + public function __construct(public readonly array $sources) {} +} diff --git a/app/app/Services/Autopodbor/Agent/Extract/CalltrackingDetector.php b/app/app/Services/Autopodbor/Agent/Extract/CalltrackingDetector.php new file mode 100644 index 00000000..298df891 --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Extract/CalltrackingDetector.php @@ -0,0 +1,23 @@ + */ + public function detect(string $html): array + { + $found = []; + foreach (self::PROVIDERS as $p) { + if (stripos($html, $p) !== false) { + $found[] = $p; + } + } + + return array_values(array_unique($found)); + } +} diff --git a/app/app/Services/Autopodbor/Agent/Extract/CandidateBuilder.php b/app/app/Services/Autopodbor/Agent/Extract/CandidateBuilder.php new file mode 100644 index 00000000..a1485a3b --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Extract/CandidateBuilder.php @@ -0,0 +1,136 @@ + $sites + * @param list $cards + * @param ?string $defaultAreaCode запасной код города (по региону конкурента) — + * для достройки коротких номеров, если на странице нет полных + * @return list + */ + public function build(array $sites, array $cards, ?string $defaultAreaCode = null): array + { + $out = []; + foreach ($sites as $site) { + $scan = $this->scanner->scan($site->rawHtml, $defaultAreaCode); + $hasTracker = $this->detector->detect($site->rawHtml) !== []; + + foreach (array_keys($scan['code']) as $number) { + $out[] = [ + 'number' => (string) $number, + 'kind' => 'code', + 'label' => 'в коде сайта', + 'url' => $site->url, + 'office' => null, + 'tracker' => $hasTracker, + ]; + } + + // короткие локальные номера, код города которых не удалось определить — + // НЕ теряем, отдаём клиенту с пометкой «требует проверки» + foreach ($scan['uncertain'] ?? [] as $short) { + $out[] = [ + 'number' => (string) $short, + 'kind' => 'uncertain', + 'label' => 'локальный номер на сайте — код города не определён, требует проверки', + 'url' => $site->url, + 'office' => null, + 'tracker' => $hasTracker, + ]; + } + + $codeNumbers = array_map('strval', array_keys($scan['code'])); + + // видимые отрендеренные номера → displayed (подменный), если их нет в коде + $visible = []; + foreach ($site->visiblePhones as $raw) { + $n = $this->normalize($raw); + if ($n === null) { + continue; + } + $visible[] = $n; + if (! in_array($n, $codeNumbers, true)) { + $out[] = [ + 'number' => $n, 'kind' => 'displayed', 'label' => 'показан на сайте (коллтрекинг)', + 'url' => $site->url, 'office' => null, 'tracker' => $hasTracker, + ]; + } + } + + // пул ротации: номер в теле ≥2 раз, не в коде и не видимый, при активном трекере + if ($hasTracker) { + foreach ($scan['body'] as $number => $count) { + $number = (string) $number; + if ($count >= 2 && ! in_array($number, $codeNumbers, true) && ! in_array($number, $visible, true)) { + $out[] = [ + 'number' => $number, 'kind' => 'pool', 'label' => 'пул подмены', + 'url' => $site->url, 'office' => null, 'tracker' => true, + ]; + } + } + } else { + // без коллтрекинга: номера, написанные на сайте ПРОСТЫМ ТЕКСТОМ (не tel:/schema, + // не отрендеренные) — не теряем, а отдаём клиенту с пометкой «проверьте». На + // верифицированном сайте фирмы это чаще её линии, но без tel:-ссылки за 100% + // настоящие не выдаём. (Число вхождений не важно — номер уже в видимом тексте.) + foreach ($scan['body'] as $number => $count) { + $number = (string) $number; + if (in_array($number, $codeNumbers, true) || in_array($number, $visible, true)) { + continue; + } + $out[] = [ + 'number' => $number, 'kind' => 'text', 'label' => 'в тексте сайта — проверьте', + 'url' => $site->url, 'office' => null, 'tracker' => false, + ]; + } + } + + // номера филиалов со страницы «Контакты» (рендер) + foreach ($site->contactsNumbers as $row) { + $n = $this->normalize($row['number']); + if ($n === null) { + continue; + } + $out[] = [ + 'number' => $n, 'kind' => 'contacts', 'label' => 'страница «Контакты»', + 'url' => $site->url, 'office' => $row['office'] ?? null, 'tracker' => $hasTracker, + ]; + } + } + + foreach ($cards as $card) { + $n = $this->normalize($card->number); + if ($n === null) { + continue; + } + $out[] = [ + 'number' => $n, 'kind' => 'directory', 'label' => $card->source, + 'url' => $card->url, 'office' => $card->office, 'tracker' => false, + ]; + } + + return $out; + } + + private function normalize(string $raw): ?string + { + $n = $this->norm->phone($raw); + + return (strlen($n) === 11 && $n[0] === '7') ? $n : null; + } +} diff --git a/app/app/Services/Autopodbor/Agent/Extract/DirectoryParser.php b/app/app/Services/Autopodbor/Agent/Extract/DirectoryParser.php new file mode 100644 index 00000000..4703654f --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Extract/DirectoryParser.php @@ -0,0 +1,352 @@ +). + * + * @return list + */ + public function parseBranchList(string $html): array + { + // Путь карточки филиала /<город>/firm/ — 2ГИС отдаёт его то как href, + // то внутри JSON-состояния страницы. Берём отовсюду (с дедупом) — иначе на части + // прорисовок филиалы теряются (поймано на живом 2ГИС). + $out = []; + if (preg_match_all('#/[a-z0-9_-]+/firm/\d+#i', $html, $m)) { + foreach ($m[0] as $href) { + if (! in_array($href, $out, true)) { + $out[] = $href; + } + } + } + + return $out; + } + + /** + * Карточка филиала: телефоны из tel:-ссылок + адрес из заголовка страницы. + * + * @return list + */ + public function parseFirmCard(string $html, string $url, string $source): array + { + $office = $this->officeFromTitle($html); + + $out = []; + if (preg_match_all('/tel:([+0-9()\s-]{7,})/i', $html, $m)) { + $seen = []; + foreach ($m[1] as $raw) { + $num = trim($raw); + if (isset($seen[$num])) { + continue; + } + $seen[$num] = true; + $out[] = new DirectoryCard(number: $num, office: $office, url: $url, source: $source); + } + } + + return $out; + } + + /** + * Карточка организации Яндекс.Карт: телефоны + сайт + адрес из заголовка. + * + * Приоритеты парсинга: + * 1. Телефоны из
+ * 2. Резервные номера из wa.me-ссылок (если не совпадают по цифрам с уже найденными) + * 3. Сайт из + * 4. Адрес офиса через officeFromTitle() + * + * Если телефонов нет, но сайт есть — одна карточка с number=''. + * Если нет ни телефона, ни сайта — []. + * + * @return list + */ + public function parseYandexCard(string $html, string $url): array + { + $office = $this->officeFromTitle($html); + // «Чем занимается» — рубрика из заголовка карточки Яндекса («Имя, РУБРИКА, адрес…, Город»). + $description = $this->categoryFromTitle($html); + + // Телефоны из
ТЕЛЕФОН
+ $phones = []; + if (preg_match_all( + '/class="orgpage-phones-view__phone-number"[^>]*>\s*([^<]+?)\s*) сюда не попадают: сайт берём ТОЛЬКО из + * объекта-контакта website, а не «первый домен на странице». + * + * Если телефонов нет, но сайт есть — одна карточка с number=''. Нет ни того, ни другого — []. + * + * @return list + */ + public function parse2gisCard(string $html, string $url): array + { + $office = $this->officeFromTitle($html); + // «Чем занимается»: сначала своё описание (og), если оно не заглушка; иначе — рубрика из + // заголовка карточки («Имя, РУБРИКА, адрес…, Город»). Рекламный текст 2ГИС грузится отдельно + // (не в HTML) — его тут нет, рубрика — надёжный минимум. + $description = $this->descriptionFromMeta($html) ?? $this->categoryFromTitle($html); + + // Сайт: в объекте-контакте url идёт ДО "type":"website" (порядок ключей url,text,code,type). + // Берём первый такой объект; [^}]*? не даёт «перепрыгнуть» в соседний объект. + $siteUrl = null; + if (preg_match('/"url":"([^"]+)"[^}]*?"type":"website"/u', $html, $sm)) { + $siteUrl = $sm[1]; + } + + // Телефоны: value у объектов {"type":"phone","value":"+7…"}; дедуп по цифрам. + // ВАЖНО: разбираем ПООБЪЕКТНО (не сквозной регуляркой по всей странице), чтобы отсеять + // РЕКЛАМНЫЕ кнопки-действия. 2ГИС вшивает в карточку рекламу чужих фирм («Связаться с + // менеджером») — это объект с caption/platforms и БЕЗ code/print_text; его value — телефон + // другой компании. Если его взять, чужой номер попадает в контакты фирмы и даёт ложную + // склейку/«объединить?» (живой случай: телефон Айклауда прилетал в Ломбард Бодрый). + $phones = []; + $seenDigits = []; + if (preg_match_all('/\{[^{}]*?"type":"phone"[^{}]*?\}/u', $html, $om)) { + foreach ($om[0] as $obj) { + // Рекламная кнопка-действие, а не контакт фирмы → пропускаем. + if (str_contains($obj, '"platforms"') || str_contains($obj, '"caption"')) { + continue; + } + if (! preg_match('/"value":"(\+?\d{10,15})"/u', $obj, $vm)) { + continue; + } + $digits = preg_replace('/\D/', '', $vm[1]); + if ($digits === '' || isset($seenDigits[$digits])) { + continue; + } + $seenDigits[$digits] = true; + $phones[] = $vm[1]; + } + } + + if ($phones === [] && $siteUrl === null) { + return []; + } + + if ($phones === []) { + return [new DirectoryCard( + number: '', + office: $office, + url: $url, + source: '2ГИС', + siteUrl: $siteUrl, + description: $description, + )]; + } + + $out = []; + foreach ($phones as $raw) { + $out[] = new DirectoryCard( + number: $raw, + office: $office, + url: $url, + source: '2ГИС', + siteUrl: $siteUrl, + description: $description, + ); + } + + return $out; + } + + /** + * Описание «чем занимается» из og:description / meta description (best-effort, может быть null). + */ + private function descriptionFromMeta(string $html): ?string + { + if (preg_match('/]+property="og:description"[^>]+content="([^"]+)"/i', $html, $m) + || preg_match('/]+name="description"[^>]+content="([^"]+)"/i', $html, $m)) { + $d = trim(html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8')); + + return ($d !== '' && ! $this->isJunkDescription($d)) ? $d : null; + } + + return null; + } + + /** + * Служебная заглушка справочника (не «чем занимается»): «Оценка N, N фото, N отзывов. Вся + * информация об этом месте есть в 2ГИС» и т.п. Такое в описание не пишем — это мусор для + * релевантности и отсева агрегаторов. + */ + private function isJunkDescription(string $d): bool + { + $low = mb_strtolower($d, 'UTF-8'); + + // Упоминает сам справочник (2ГИС/Яндекс) или это блок «оценка … фото … отзыв». + return str_contains($low, '2гис') + || str_contains($low, '2gis') + || (str_contains($low, 'фото') && str_contains($low, 'отзыв')) + || (bool) preg_match('/^оценка\s+\d/u', $low); + } + + /** + * Рубрика «чем занимается» из вида «Имя, РУБРИКА, адрес…, Город — 2ГИС/Яндекс». + * Рубрика — 2-й кусок, НО только если он не похож на адрес (иначе это «Имя, адрес…, Город» без рубрики). + */ + private function categoryFromTitle(string $html): ?string + { + if (! preg_match('#<title>(.*?)#is', $html, $m)) { + return null; + } + $title = html_entity_decode(trim($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $title = preg_replace('/[\s\x{00A0}]+/u', ' ', $title) ?? $title; + $title = trim($title); + $title = preg_replace('/\s*[—-]\s*(2ГИС|Яндекс[^,]*)\s*$/u', '', $title) ?? $title; + + $parts = array_map('trim', explode(',', $title)); + if (count($parts) < 3) { + return null; // «Имя, Город» — отдельной рубрики нет + } + $cand = $parts[1] ?? ''; + + return ($cand !== '' && ! $this->looksLikeAddress($cand)) ? $cand : null; + } + + /** Кусок заголовка похож на адрес (улица/проспект/дом), а не на рубрику. */ + private function looksLikeAddress(string $s): bool + { + $low = mb_strtolower($s, 'UTF-8'); + + return (bool) preg_match('/улиц|ул\.|проспект|пр-т|пр\.|переул|пер\.|бульвар|шоссе|набереж|площад|проезд|тракт|\d/u', $low); + } + + /** + * Адрес офиса из вида «Имя, адрес…, Город — 2ГИС». + * Берём всё между первой и последней запятой (имя фирмы и город/суффикс отбрасываем). + */ + private function officeFromTitle(string $html): ?string + { + if (! preg_match('#<title>(.*?)#is', $html, $m)) { + return null; + } + $title = html_entity_decode(trim($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + // нормализуем пробелы (в т.ч. неразрывные U+00A0) → одиночные + $title = preg_replace('/[\s\x{00A0}]+/u', ' ', $title) ?? $title; + $title = trim($title); + // отрезаем хвост « — 2ГИС» / « — Яндекс Карты» + $title = preg_replace('/\s*[—-]\s*(2ГИС|Яндекс[^,]*)\s*$/u', '', $title) ?? $title; + + $parts = array_map('trim', explode(',', $title)); + if (count($parts) < 3) { + return null; // нет адреса между именем и городом + } + // имя фирмы — первый кусок, город — последний; адрес — середина + $address = array_slice($parts, 1, count($parts) - 2); + + return implode(', ', $address) ?: null; + } +} diff --git a/app/app/Services/Autopodbor/Agent/Extract/HtmlPhoneScanner.php b/app/app/Services/Autopodbor/Agent/Extract/HtmlPhoneScanner.php new file mode 100644 index 00000000..230aa9dc --- /dev/null +++ b/app/app/Services/Autopodbor/Agent/Extract/HtmlPhoneScanner.php @@ -0,0 +1,171 @@ +>, body: array, emails: list, uncertain: list} + */ + public function scan(string $html, ?string $defaultAreaCode = null): array + { + $code = []; + $uncertain = []; + + // 1. Собираем сырые значения из ЯВНЫХ телефонных контекстов (tel/schema/microdata). + // Короткие локальные формы (без кода города) здесь безопасны — это точно телефоны. + $rawCandidates = []; + $patterns = [ + ['/tel:([+0-9()\s-]{6,})/i', 'tel'], + ['/"telephone"\s*:\s*"([^"]+)"/i', 'schema'], + ['/itemprop=["\']telephone["\'][^>]*content=["\']([^"\']+)/i', 'microdata'], + ]; + foreach ($patterns as [$re, $slot]) { + if (preg_match_all($re, $html, $m)) { + foreach ($m[1] as $x) { + $rawCandidates[] = ['raw' => $x, 'slot' => $slot]; + } + } + } + + // 2. Из полных номеров берём национальные части (10 цифр) — по ним определяем код города. + $national = []; + foreach ($rawCandidates as $c) { + $n = $this->normalizeMaybe($c['raw']); + if ($n !== null) { + $national[] = substr($n, 1); + } + } + + $add = function (string $n, string $slot) use (&$code): void { + $code[$n] ??= []; + if (! in_array($slot, $code[$n], true)) { + $code[$n][] = $slot; + } + }; + + // 3. Разносим: полные — как есть; короткие локальные — достраиваем кодом города. + foreach ($rawCandidates as $c) { + $n = $this->normalizeMaybe($c['raw']); + if ($n !== null) { + $add($n, $c['slot']); + + continue; + } + [$status, $value] = $this->classifyShort($c['raw'], $national, $defaultAreaCode); + if ($status === 'built') { + $add((string) $value, $c['slot']); + } elseif ($status === 'uncertain' && ! in_array($value, $uncertain, true)) { + $uncertain[] = (string) $value; // код города не определить — не теряем, к проверке + } + // 'fragment' (обрезок полного номера) и 'skip' (не телефон) — игнорируем + } + + // Номера в теле берём из ВИДИМОГО текста: вырезаем + + + diff --git a/app/resources/css/motion.css b/app/resources/css/motion.css index 25ea7654..5e3d5013 100644 --- a/app/resources/css/motion.css +++ b/app/resources/css/motion.css @@ -129,4 +129,13 @@ animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } + + /* Исключение: спиннеры-загрузчики. Без вращения они выглядят как зависший процесс — + клиент думает, что всё сломалось. WCAG допускает анимацию индикатора загрузки. + Возвращаем медленное непрерывное вращение (специфичность классов бьёт универсальный сброс). */ + .ld-spin, + .ld-collecting__spin { + animation-duration: 1.5s !important; + animation-iteration-count: infinite !important; + } } diff --git a/app/resources/help/api-i-webhook.md b/app/resources/help/api-i-webhook.md new file mode 100644 index 00000000..efd8881e --- /dev/null +++ b/app/resources/help/api-i-webhook.md @@ -0,0 +1,32 @@ +--- +title: API-ключ и вебхук — интеграция с вашей системой +tour: api +topics: api, ключ, апи ключ, интеграция, webhook, вебхук, вебхуки, получать заявки в свою crm, отправлять заявки в crm, интеграция с моей crm, интеграция с амосрм, amocrm, битрикс24, забирать заявки автоматически, передача событий, потерял ключ, перевыпустить ключ, подпись, secret, тестовый запрос, endpoint, автоматическая отправка событий, документация по api +--- + +Настройки → вкладка **«API и Webhook»**. + +**API-ключ** нужен, чтобы ваша система могла обращаться к порталу и забирать +заявки. Ключ показывается **полностью только один раз** — сразу после создания. +Дальше видно лишь его начало. Потеряли — единственный путь «Перегенерировать»: +портал выдаст новый, а старый **перестанет работать немедленно**, и все +интеграции на нём придётся обновить. + +**Вебхук — пока только проверка связи.** Вы указываете адрес на своей стороне +(только `https://`) и нажимаете **«Тестовый webhook»** — портал отправит туда +один пробный запрос и покажет, каким кодом ответила ваша система. При сохранении +адреса выдаётся секрет для подписи — он тоже показывается один раз, сохраните +сразу. + +Честно и важно: **автоматическая отправка настоящих событий (новая заявка, смена +статуса) пока не подключена.** Сколько бы вы ни настраивали вебхук, кроме +тестового запроса на ваш адрес сейчас ничего не придёт. Мы обязательно сообщим, +когда эта возможность появится — не тратьте время на приёмник событий заранее. + +Как забирать заявки в свою систему **прямо сейчас**: через API-ключ (постоянный +доступ к данным) или выгрузкой в Excel/CSV в разделе «Сделки». + +**Готовых интеграций с amoCRM, Битрикс24 и другими CRM у нас нет** — коробочной кнопки +«подключить amoCRM» не существует. Забрать заявки к себе можно двумя честными путями: +силами вашего программиста через API-ключ либо выгрузкой в Excel/CSV. Если нужна +техническая помощь по API — напишите на `support@liderra.ru`. diff --git a/app/resources/help/balans-i-pauza.md b/app/resources/help/balans-i-pauza.md new file mode 100644 index 00000000..ad0f1c88 --- /dev/null +++ b/app/resources/help/balans-i-pauza.md @@ -0,0 +1,49 @@ +--- +title: Баланс кончился — почему всё встало и как включить обратно +tour: balance-pause +topics: баланс, кончились деньги, приём приостановлен, заморозка, разморозка, всё или ничего, не хватает баланса, автопауза, письмо о балансе, хватит на дней, запас, включить обратно, частичное пополнение, баланс ушёл в минус, минус, долг, задолженность, пополнил а проекты не запустились, сколько денег держать на балансе +--- + +Каждый вечер портал считает: хватит ли денег на **завтрашний дневной заказ всех +ваших активных проектов вместе** (сумма их лимитов по вашей цене за заявку). +Не хватает — приём заявок **по всему аккаунту** ставится на паузу, и вам уходит +письмо с точной недостающей суммой. + +Ничего при этом не пропадает: заявки, история, настройки и баланс остаются +на месте. **В минус аккаунт не уходит никогда** — ни долга, ни задолженности +у вас перед нами не возникает: вместо этого приём просто встаёт. + +**«Пополнил — а проекты не запустились».** Отдельной кнопки «запустить обратно» +искать не надо: после зачисления денег приём включается **сам**. Если не включился, +значит, суммы всё ещё не хватает на **полный** дневной заказ всех активных проектов — +портал покажет, сколько именно не хватает. + +**Сколько денег держать на балансе.** Столько, чтобы хватало на суммарный дневной +заказ всех активных проектов: сложите их дневные лимиты и умножьте на вашу цену +за заявку. Плитка «Запас» показывает, на сколько дней хватит при текущем заказе. + +**Разморозка работает по правилу «всё или ничего».** Пока денег не хватает +на **полный** дневной заказ, не включится **ни один** проект — даже если на часть +из них денег хватило бы. Сделано специально, чтобы не было наполовину +работающего состояния. + +Отсюда два пути включиться обратно: + +1. **Пополнить баланс** до нужной суммы — приём включится сразу после зачисления, + ждать вечера не нужно. +2. **Уменьшить заказ** — снизить дневные лимиты проектов или поставить часть + из них на паузу. Тогда требуемая сумма падает, и уже имеющихся денег может + хватить. + +Портал всё время показывает, как обстоят дела: на плитке «Запас» — на сколько +дней хватит баланса при текущем заказе; под балансом — строка вида «Баланс: +X ₽ = до Y заявок по тарифу, проекты заказывают Z заявок в день». Если денег +осталось меньше чем на 3 дня, портал предупредит заранее. + +Когда приём приостановлен, на всех страницах висит красная плашка «Приём лидов +приостановлен» — с суммой, которой не хватает, и ссылками «Пополнить счёт» +и «Перейти к проектам». + +Отдельный случай — **новый проект не запустился**: значит, на него одного +не хватило баланса. Портал покажет окно «Проект не запущен» и предложит либо +пополнить на конкретную сумму, либо уменьшить объём. diff --git a/app/resources/help/besplatnyy-test.md b/app/resources/help/besplatnyy-test.md new file mode 100644 index 00000000..b8725121 --- /dev/null +++ b/app/resources/help/besplatnyy-test.md @@ -0,0 +1,24 @@ +--- +title: Бесплатный тест — 1000 ₽ в подарок при регистрации +tour: start +topics: бесплатно, бесплатный тест, тестовый период, пробный период, попробовать, демо, подарок, 1000 рублей, дарим, проверить качество, протестировать, тест на 10 заявках, а бесплатно можно +--- + +**Да, попробовать можно бесплатно.** При регистрации мы дарим **1000 ₽ на баланс** — +первые заявки идут за счёт этих денег. Своих вкладывать не нужно: пополняете баланс +только тогда, когда решите продолжить. + +Важно: 1000 ₽ — это **подарок, который уже лежит на вашем балансе**, а не сумма, которую +надо внести. + +**Как честно проверить качество данных.** Самый показательный тест — **на своём +собственном номере или сайте**: создайте проект на свой номер телефона или сайт +и посмотрите, кто из тех, кто вам вчера звонил или заходил на сайт, придёт в «Сделки». +Так вы увидите своими глазами, что данные живые. + +**Абонентской платы нет.** Вы платите только за фактически полученные заявки, поэтому +«тестового периода» с ограничением по дням у нас просто нет — тест ограничен только +подарочной суммой. + +**Хотите попробовать на небольшом объёме** — поставьте в проекте маленький дневной лимит +(хоть 5–10 заявок в день), и поток будет ровно такой, какой вы готовы обработать. diff --git a/app/resources/help/bezopasnost.md b/app/resources/help/bezopasnost.md new file mode 100644 index 00000000..fa0e9a32 --- /dev/null +++ b/app/resources/help/bezopasnost.md @@ -0,0 +1,40 @@ +--- +title: Безопасность — пароль, двухфакторная защита, сессии +tour: security +topics: безопасность, пароль, сменить пароль, не принимает пароль, 10 символов, двухфакторная, как включить двухфакторную, включить 2fa, подключить 2fa, 2fa, гугл аутентификатор, резервные коды, потерял телефон, сессии, выйти на другом устройстве, вход заблокирован, слишком много попыток, где хранятся данные, хранение данных, за границей, серверы, в россии, защита данных +--- + +Всё это — **Настройки → Безопасность**. Там четыре карточки. + +**Пароль.** Кнопка «Сменить пароль»: текущий пароль, новый и повтор. Новый +пароль — **не короче 10 символов** (при регистрации допускалось 8, при смене +требование строже — это частая причина «почему не принимает»). + +**Двухфакторная защита (2FA).** Подходят Google Authenticator, Яндекс.Ключ, +1Password и любое приложение с одноразовыми кодами. Включение — три шага: +сканируете QR-код (или вводите ключ вручную), вводите 6-значный код из +приложения, сохраняете **8 резервных кодов**. + +Важно: пока вы не ввели первый верный код, 2FA **не включена**. Если закрыли +окно на середине — начинайте заново. Резервные коды показываются **один раз** — +сохраните их сразу. + +**Резервные коды.** Карточка появляется, когда 2FA включена. Кнопку +«Перегенерировать» можно нажать в любой момент — старые коды сразу перестанут +работать. Потеряли телефон и коды одновременно — пишите в поддержку. + +**Активные сессии.** Список устройств, с которых вы входили: браузер, система, +адрес, когда были активны. Текущая помечена. У остальных есть кнопка +«Завершить» — она сразу выкидывает это устройство из аккаунта. + +**Если не пускает вход:** после 5 неудачных попыток за 15 минут вход по этой +почте временно блокируется — портал скажет, через сколько минут пробовать +снова. Отдельно защищаемся от перебора: много неудачных попыток с одного адреса +блокируют его на час. Забыли пароль — «Забыли пароль?» на странице входа, +ссылка из письма живёт 60 минут. + +**Где хранятся ваши данные.** На серверах **в России** — в российском облаке +(дата-центры в Москве). За границу данные не вывозятся. Доступ к базе — только +у сотрудников сервиса и только по служебной необходимости; все действия +с данными протоколируются. Как мы обрабатываем персональные данные, написано +в Политике: `/legal/privacy`. diff --git a/app/resources/help/chem-mogu-pomoch.md b/app/resources/help/chem-mogu-pomoch.md new file mode 100644 index 00000000..1c0d916b --- /dev/null +++ b/app/resources/help/chem-mogu-pomoch.md @@ -0,0 +1,36 @@ +--- +title: Чем я могу помочь — коротко обо всём портале +tour: start +topics: что ты умеешь, что ты знаешь, чем ты можешь помочь, что ты можешь, помоги, оглавление, о чём можно спросить, что есть на портале, какие разделы, возможности портала, с чего начать, помощь бота +--- + +Я консультант по порталу Лидерра и знаю его целиком. Вот о чём можно спрашивать — +по любой теме покажу и на самом портале. + +**Начало работы.** Как всё устроено, с чего начать, откуда берутся заявки и законно ли +это, что портал даёт и чем отличается от других способов искать клиентов. + +**Проекты.** Как создать проект (сайт, звонок или СМС), что писать в каждом поле, лимит +заявок в день, регионы и дни приёма, пауза и запуск, смена источника, удаление, изменение +сразу многих проектов. И главное правило: изменения вступают в силу со следующего дня — +до 18:00 по Москве с завтра, после — с послезавтра. + +**Заявки (Сделки).** Где смотреть заявки, что в карточке, пять статусов воронки, доска +«Канбан», выгрузка в Excel и CSV, перенос вашей истории из старой CRM. + +**Деньги.** Как пополнить баланс, оплата картой и по счёту с закрывающими документами, +тарифные ступени (чем больше заявок в месяц — тем дешевле каждая), за что списываются +деньги и как это проверить, что делать, если приём встал из-за баланса. + +**Конкурентное поле.** Как найти конкурентов и их источники клиентов, сколько это стоит +и когда бесплатно, как читать пометки у номеров, как чистить дубли, как из найденного +источника сделать проект. + +**Настройки.** Профиль и реквизиты, смена пароля, двухфакторная защита, активные сессии, +уведомления, API-ключ и вебхук. + +**Отчёты и дашборд.** Сводка по заявкам, воронка, выгрузки за период. + +Спрашивайте своими словами — я отвечу по инструкции и покажу нужное место на портале. +Если вопрос не по инструкции (деньги по конкретной заявке, договор, спорная ситуация) — +попрошу ваш телефон и передам живому специалисту. diff --git a/app/resources/help/chto-my-daem.md b/app/resources/help/chto-my-daem.md new file mode 100644 index 00000000..80b0352e --- /dev/null +++ b/app/resources/help/chto-my-daem.md @@ -0,0 +1,27 @@ +--- +title: Что вы получаете — тёплый контакт, а не реклама +tour: deals +topics: что вы даёте, что я получу, как это работает, чем отличается от рекламы, лидогенерация, канал продаж, тёплые лиды, горячие или холодные, насколько свежие заявки, сколько времени прошло, свежесть контакта, вчерашний интерес, человек знает что я позвоню, зачем мне это, что делать с номером +--- + +Лидерра — это **канал**, а не реклама. Мы не показываем баннеры и не ждём, пока человек +куда-то кликнет. Мы даём вам **телефон человека, который буквально вчера интересовался +таким же товаром или услугой у вашего конкурента**: заходил к нему на сайт или разговаривал +с ним по телефону. + +Это значит, что потребность у человека уже есть — он ищет решение прямо сейчас. Ваша +задача — предложить ему **условия лучше, чем у конкурента**: цену, сроки, качество, +гарантию, сервис. Что именно предложить, решаете вы — мы в это не вмешиваемся. + +**Насколько свежий контакт.** Речь про интерес вчерашнего дня, не про старую базу. +Поэтому чем быстрее вы выходите на связь, тем выше шанс. + +**Знает ли человек, что вы позвоните.** Специально вас он не ждёт — он общался +с конкурентом. Поэтому важно не «звонить в лоб», а сначала показать своё предложение: +как это лучше сделать, описано в статье «Как работать с полученным номером». + +**Что это НЕ.** Это не гарантированная продажа и не заявка с вашего сайта. + +**Гарантий мы не даём — ни продажи, ни отклика.** Мы отвечаем за другое: человек живой, +интерес у него свежий, и согласие на рекламные предложения он давал. Дальше результат +зависит от вашего предложения и от того, как быстро вы вышли на связь. diff --git a/app/resources/help/dashboard.md b/app/resources/help/dashboard.md new file mode 100644 index 00000000..bde3c6cd --- /dev/null +++ b/app/resources/help/dashboard.md @@ -0,0 +1,28 @@ +--- +title: Дашборд — сводка по заявкам +tour: dashboard +topics: дашборд, главная, сводка, статистика, статистика за неделю, статистика за месяц, сколько заявок, сколько заявок пришло за неделю, сколько пришло за месяц, конверсия, активные проекты, воронка, график, период, за 7 дней, за 30 дней, средняя стоимость +--- + +**Дашборд** — короткая сводка: как идут дела прямо сейчас. + +Сверху переключатель периода: **Сегодня / 7 дней / 30 дней**. Всё, что ниже, +считается за выбранный период. + +Три плитки: + +- **Получено лидов** — сколько заявок пришло за период (и насколько это больше + или меньше прошлого такого же периода). +- **Конверсия в оплату** — доля заявок, дошедших до статуса «Сделка». +- **Активные проекты** — сколько проектов сейчас собирает заявки. + +Рядом — **баланс**: сумма, на сколько дней хватит и сколько заявок ещё оплачено. + +Ниже — **график активности** за последние 7 дней (столбик на день) и **воронка**: +сколько заявок на каждом из пяти этапов — новая, просмотрено, в работе, сделка, +не реализовано. По воронке сразу видно, где заявки «залипают». + +В самом верху — строка «сегодня N · вчера N · средняя стоимость X ₽». + +Если вы только зарегистрировались и проектов ещё нет, дашборд подскажет, с чего +начать: создать первый проект и пополнить баланс. diff --git a/app/resources/help/dobavit-vruchnuyu.md b/app/resources/help/dobavit-vruchnuyu.md new file mode 100644 index 00000000..71c3896d --- /dev/null +++ b/app/resources/help/dobavit-vruchnuyu.md @@ -0,0 +1,38 @@ +--- +title: Добавить конкурента и источник вручную — бесплатно +tour: manual-add +topics: добавить вручную, руками, самому, самостоятельно, без ИИ, бесплатно, добавить конкурента, добавить источник вручную, изменить карточку, похожесть, элементы конкурента, холдинг +--- + +Не обязательно ждать ИИ — конкурентов и их источники можно завести руками. +Это **бесплатно**. + +**Конкурент.** Кнопка «+ Добавить вручную» на экране «Конкурентное поле». +Заполните: + +- **Название** — как называется конкурент. +- **Тип** — региональный или федеральный. +- **Похожесть на вас, %** — ставите на своё усмотрение (по умолчанию 100). + Портал сортирует поле по похожести, 100% — сверху. +- **Элементы конкурента** — сайт **или** ссылка на карточку в справочнике + (2ГИС / Яндекс.Карты). Без этого Лидерра не сможет найти его источники. + Если конкурент — холдинг, добавьте несколько элементов. +- **Описание** — по желанию. + +Добавленный вручную конкурент сразу появляется в поле (не в предложениях) +и помечается бейджем «добавлен вручную». + +**Источник конкурента.** Откройте конкурента и нажмите «+ Добавить источник +вручную». Выберите тип — **сайт** или **телефон**, укажите значение (сайт — +без http, просто адрес; телефон — в обычном формате, приведём сами) и, по +желанию, «источник сведений — где нашли», чтобы потом помнить, откуда он. +Добавленный вручную источник сразу считается доверенным и попадает в работу, +минуя предложения. + +Если такой сайт или номер уже есть у другого конкурента, портал покажет окно +«Такой источник уже есть» и предложит решить, где его оставить — подробнее +в статье про дубли. + +Карточку конкурента можно поправить в любой момент: «✎ Изменить» — меняются +все поля, включая элементы. Тип источника (сайт/телефон) после создания +не меняется: если нужен другой тип, добавьте отдельный источник. diff --git a/app/resources/help/dogovor-i-dokumenty.md b/app/resources/help/dogovor-i-dokumenty.md new file mode 100644 index 00000000..c1d67aec --- /dev/null +++ b/app/resources/help/dogovor-i-dokumenty.md @@ -0,0 +1,33 @@ +--- +title: Договор (оферта) и закрывающие документы +tour: invoice +topics: договор, как подписать договор, договор с юр лицом, оферта, публичная оферта, подписать, подписание, бумажный договор, закрывающие документы, акт, счёт, счет фактура, ндс, работаете ли с ндс, с ндс или без ндс, без ндс, усн, отчётные документы, бухгалтерия, для бухгалтерии, политика конфиденциальности, возврат +--- + +**Отдельный договор подписывать не нужно.** Мы работаем по **публичной оферте**: вы +принимаете её при регистрации (галочка «Принимаю оферту»), и этого достаточно — +и для физлиц, и для ИП, и для юридических лиц. Бумаги, курьеры и подписи не требуются. + +Прочитать документы можно в любой момент: + +- **Оферта (договор)** — страница `/legal/offer` на портале. +- **Политика обработки персональных данных** — `/legal/privacy`. +- **Условия возврата** — `/legal/refund`. + +Ссылки на них есть и внизу страницы входа, и на форме регистрации. + +**Закрывающие документы для бухгалтерии** — есть, всё как положено: + +1. Пополняете баланс **по счёту**: «Биллинг» → «Пополнить баланс» → «По счёту + (для юрлиц)». Портал сформирует **счёт** (PDF) по реквизитам вашей компании из + раздела «Настройки → Реквизиты». +2. Оплачиваете его банковским переводом. +3. Когда оплата дойдёт, портал автоматически сформирует **акт** — он придёт вам + на почту и появится рядом со счётом на вкладке «Счета» в «Биллинге». + +Счёт и акт можно скачать в любое время во вкладке «Счета». Услуга в документах +называется «Оплата генерации рекламных лидов». Работаем по УСН — **без НДС**, +это прямо указано в счёте и акте. + +Если бухгалтерии нужен какой-то документ, которого нет в кабинете, — напишите +в поддержку, поможем. diff --git a/app/resources/help/dubli-konkurentov.md b/app/resources/help/dubli-konkurentov.md new file mode 100644 index 00000000..fbad2a2c --- /dev/null +++ b/app/resources/help/dubli-konkurentov.md @@ -0,0 +1,53 @@ +--- +title: Дубли конкурентов — где чистятся (два места) и как объединить +tour: dupes +topics: дубли конкурентов, дубликаты, один и тот же конкурент два раза, конкурент повторяется, конкурент дважды, объединить дубли, склеить, одинаковые конкуренты, две карточки одной фирмы, почистить дубли, найти и объединить дубли, чистка дублей в конкурентном поле, дубли в конкурентном поле, дубли в поле, дубли в предложениях, дубли в карточке конкурента, дубль источника, источник уже есть, история слияний, разъединить, вернуть карточку +--- + +Кнопка «🧹 Найти и объединить дубли» есть в **двух местах** — это разные списки, +и каждая кнопка работает со своим: + +**1. В «Поле»** (вкладка «В поле»). Кнопка появляется, когда конкурентов двое +и больше. Она ищет двойников среди ваших конкурентов **и заодно сравнивает их +с «Предложениями»** — то есть покажет и такое: «вот это новое предложение похоже +на вашего действующего конкурента». Рядом с надписью на кнопке — счётчик +найденных групп. + +**2. В «Предложениях»** (вкладка «Предложения»). Точно такая же кнопка, но она +ищет двойников **только среди самих предложений**, с полем не сравнивает. Нужна, +чтобы прибраться в черновике до того, как переносить кого-то в поле. + +В обоих случаях окно устроено одинаково: Лидерра показывает **группы** карточек, +которые похоже относятся к одной фирме (совпал сайт или телефон). В группе у +каждой карточки — галочка (по умолчанию отмечены все) и пометки: «· предложение» +— если карточка из предложений, «· останется» — та, что станет главной после +склейки (портал сам выбирает ту, у которой есть реальная работа и проекты). +Имя итоговой карточки вы выбираете сами из списка. + +Решение всегда за вами: по каждой группе две кнопки — **«Это разные»** (не +объединять) и **«Объединить выбранные (N)»**. Без вашего подтверждения ничего +не склеивается и не удаляется. Если в группе три карточки и больше — можно снять +лишние галочки и слить только часть. + +Объединение безопасно для работы: источники и проекты переезжают на оставшуюся +карточку, ничего не останавливается. Это бесплатно. + +**Ошиблись — можно вернуть.** Кнопка «🕘 История слияний» показывает, что во что +объединяли; кнопка «↩ Вернуть карточку» достаёт поглощённую обратно — она +появится в «Предложениях», и вы решите заново. + +**Третий случай — не про карточки, а про один источник.** Если вы вручную +добавляете конкуренту сайт или телефон, который уже есть у другого конкурента, +портал покажет окно «Такой источник уже есть». Варианты: +если по источнику **уже работает проект** — «Перенести проект сюда» (проект +продолжит работу, настройки и заявки не потеряются); если проекта нет — +«Оставить там» или «Оставить здесь, убрать там». Один источник берём в работу +один раз — поэтому портал и не даёт завести его дважды. + +Та же подсказка всплывает при создании проекта: если источник занят, вы увидите +«Похоже, это одна фирма под двумя карточками — проверьте на дубли» и кнопку +«🧹 Проверить дубли», которая сразу откроет окно объединения. + +В **карточке самого конкурента** отдельной чистки дублей нет — там убираются +только повторы среди найденных источников, и это происходит само: при повторном +сборе уже найденное не задваивается. diff --git a/app/resources/help/dubli-nomerov.md b/app/resources/help/dubli-nomerov.md new file mode 100644 index 00000000..c795ba84 --- /dev/null +++ b/app/resources/help/dubli-nomerov.md @@ -0,0 +1,25 @@ +--- +title: Повторы и дубли номеров — один номер не вернётся 30 дней +tour: deals +topics: дубли номеров, дубликаты номеров, списывается ли за дубли, платят ли за дубли, повторный номер платный, повторы, один и тот же человек дважды, повторный номер, два раза пришёл, платить за повтор, эхо, тот же телефон +--- + +**Дубликатов не бывает.** Прямой ответ на вопрос «а дубли номеров бывают?» — **нет**: +номер, который мы уже вам передавали, **не вернётся в течение 30 дней**. Это защита от «эха»: у конкурента с рабочего номера весь день +общаются не только клиенты, но и родня, партнёры, курьеры. Постоянный круг конкурента +отсеивается за пару дней, и в потоке остаются только новые люди. + +Что это значит на практике: + +- Одного и того же человека дважды в пределах месяца вы не получите — и, значит, + дважды за него не заплатите. Отдельной платы «за дубли» не существует: деньги + списываются за каждую **полученную** заявку, а повтор вам просто не придёт. +- Если человек снова проявит интерес спустя месяц и больше — это уже новый интерес, + и такой контакт может прийти повторно. + +Не путайте с **дублями конкурентов** в разделе «Конкурентное поле» — это другое: +там речь про одинаковые карточки компаний, и чистятся они кнопкой «Найти и объединить +дубли» (см. статью «Дубли конкурентов»). + +Если вам всё же кажется, что один и тот же номер пришёл дважды за месяц, — напишите +на `support@liderra.ru` с номером сделки, разберёмся индивидуально. diff --git a/app/resources/help/eksport-sdelok.md b/app/resources/help/eksport-sdelok.md new file mode 100644 index 00000000..01c4d71a --- /dev/null +++ b/app/resources/help/eksport-sdelok.md @@ -0,0 +1,25 @@ +--- +title: Выгрузить сделки в Excel или CSV +tour: export-deals +topics: экспорт, выгрузить, скачать сделки, скачать заявки в csv, выгрузить заявки в csv, скачать csv, excel, xlsx, csv, диапазон поставки, период выгрузки, какие колонки в файле, отчёт по заявкам +--- + +Заявки можно выгрузить файлом — на странице «Сделки», блок «Диапазон поставки»: +укажите даты «с» и «по» (не обязательно — пусто значит весь период) и нажмите +**«Экспорт в Excel»** или **«Экспорт в CSV»**. + +В файл попадают колонки: **Телефон, Источник, Город, Статус, Комментарий, +Поставлен**. «Источник» в файле — это название проекта и тип канала +(Сайт / Звонки / СМС). Статусы — русскими названиями. + +CSV сделан так, чтобы Excel открыл его без «кракозябр» — с русскими буквами +всё будет в порядке. + +Отдельно выгружаются **списания**: раздел «Биллинг» → вкладка «Списания» → +кнопка «Скачать CSV». Там каждая оплаченная заявка отдельной строкой: дата, +номер сделки, ступень тарифа, цена, остаток баланса. Это удобно приложить +к вопросу в поддержку, если что-то в списаниях смущает. + +Если нужен более сложный отчёт (за период, по менеджерам, по источникам, +по биллингу) — это раздел **«Отчёты»**: там можно заказать выгрузку в CSV, +XLSX, JSON или PDF. Большие отчёты (свыше 5000 строк) приходят на почту. diff --git a/app/resources/help/import-dannyh.md b/app/resources/help/import-dannyh.md new file mode 100644 index 00000000..feb162eb --- /dev/null +++ b/app/resources/help/import-dannyh.md @@ -0,0 +1,34 @@ +--- +title: Импорт данных — перенести заявки из старой CRM +tour: import +topics: импорт, импортировать, импортировать базу, загрузить базу клиентов, перенести заявки, перенести базу, загрузить csv, из амосрм, из битрикса, из старой crm, файл, формат файла для импорта, пробный прогон, ошибки импорта, дубли при импорте, неизвестные статусы, замапить, история импортов +--- + +Раздел **«Импорт данных»** переносит вашу историю заявок из прежней CRM +в «Сделки». Это **бесплатно** — баланс не списывается. + +**Файл:** CSV в кодировке UTF-8, до 10 МБ. Колонок ровно 9, в таком порядке: +`id, Проект, Тег проекта, Телефон, Создано, Напоминание, Комментарий, Состояние, +Имя`. Телефон — в виде `79161234567`. Даты — вида `2026/07/01 14:30:00`. + +**Пробный прогон.** Поставьте галочку «Пробный прогон» — портал разберёт файл +и покажет, что получится, но **ничего не запишет**. Удобно проверить формат, +прежде чем переносить всё по-настоящему. + +**Ошибки не роняют импорт.** Кривая строка просто пропускается, и портал +называет причину (телефон не того формата, дата не распознана, пустое состояние +или проект, не хватает колонок). Остальные строки загрузятся. + +**Дубли.** Если заявка из файла уже импортировалась раньше, портал **не заведёт +её второй раз** — он обновит существующую (статус, имя, комментарий). Вы увидите +это в счётчике **«Обновлено»** (а не «Добавлено»). Итоговые цифры после импорта: +Добавлено · Обновлено · Пропущено · Неизвестных статусов. + +**Статусы.** Старые названия («Недозвон», «Переговоры», «Оплачено» и прочие) +портал сам сведёт к пяти нынешним статусам воронки. Если попались вовсе +незнакомые названия, вверху появится жёлтая плашка и кнопка **«Замапить»**: +в окне вы сами укажете, какому из пяти статусов соответствует каждое незнакомое +название. Портал запомнит это и применит при следующих импортах. + +Снизу — **история импортов**: файл, статус (В очереди / Обрабатывается / Готово / +Ошибка), и цифры добавлено/обновлено/пропущено. diff --git a/app/resources/help/izuchenie-istochnikov.md b/app/resources/help/izuchenie-istochnikov.md new file mode 100644 index 00000000..6652f86f --- /dev/null +++ b/app/resources/help/izuchenie-istochnikov.md @@ -0,0 +1,44 @@ +--- +title: Собрать источники конкурента (шаг 2) — цена, очередь, повторный сбор +tour: study-sources +topics: чем изучение отличается от подбора, разница между подбором и изучением, чем отличается сбор от подбора, источников не нашлось деньги спишутся, ничего не нашлось спишут ли деньги, пустой результат сбора, висит в очереди, зависло, долго идёт, не двигается, собрать источники, изучить конкурента, шаг 2, 50 рублей, цена изучения, повторный сбор, собрать источники ещё раз, пакетный сбор, очередь, вы в очереди, не хватает баланса на сбор, источники в работе +--- + +Чтобы получить заявки, мало знать конкурента — нужны его **источники**: сайты +и телефоны, по которым к нему идут клиенты. Их ищет шаг 2. + +Откройте конкурента («Открыть конкурента →») и нажмите **«✨ Собрать источники +для меня»**. Лидерра пройдёт по его сайтам и карточкам справочников (а если это +холдинг — по всем его элементам) и принесёт найденные сайты и номера. + +**Цена — {{цена_изучения}} за одно изучение** (точная цена написана в окне). Важное различие: + +- **Первый сбор** по конкуренту — деньги спишутся, **только если источники + нашлись**. Пусто — бесплатно. +- **Повторный сбор** («🔄 Собрать источники ещё раз») — **Повторный сбор списывается всегда**, + **даже если ничего не нашлось**: вы платите за саму работу, а не за результат. Портал + честно предупреждает об этом прямо в окне, до списания. + +Отсюда прямой ответ на частый вопрос **«источников не нашлось — деньги спишутся?»**: +за первый сбор — нет, за повторный — да. + +При повторном сборе уже найденное **не задваивается**, работающие источники +не трогаются, а то, что вы когда-то удаляли, портал не подсунет молча — покажет +отдельным блоком «Ранее удалённые — снова найдены» с кнопкой «Вернуть». + +**Пачкой.** Отметьте несколько конкурентов галочками в поле и нажмите +**«🔎 Собрать источники (N)»** — задания встанут в честную очередь и выполнятся +сами, страницу можно закрыть. Если баланса на всю пачку не хватает, портал прямо +скажет: пополнить на столько-то или уменьшить выборку до стольких-то конкурентов, +и не запустит ни одного, пока вы не решите. + +Чтобы нашлось больше: у конкурента должен быть указан **сайт или ссылка на +2ГИС / Яндекс.Карты**. Если данных мало — сначала допишите их в карточке +(«✎ Изменить»), потом запускайте сбор. + +Найденное ложится во вкладку «Предложения» внутри карточки конкурента — +оттуда вы переносите нужное в «Источники в работе». + +**Задание висит в очереди дольше обычного?** Это не про деньги: если баланса не хватает, +портал вообще не запускает сбор и прямо об этом говорит. Если задание **висит дольше** +получаса и не двигается — напишите на `support@liderra.ru`, посмотрим, что с ним. diff --git a/app/resources/help/kachestvo-istochnika.md b/app/resources/help/kachestvo-istochnika.md new file mode 100644 index 00000000..52e1f47b --- /dev/null +++ b/app/resources/help/kachestvo-istochnika.md @@ -0,0 +1,54 @@ +--- +title: Качество источника — за ним нужно присматривать +tour: change-source +topics: качество источника, не мои клиенты, не моя ниша, пришли не те люди, нецелевые заявки, заявки не по теме, чужая тематика, источник шире, широкий источник, конкурент расширил профиль, конкурент изменил рекламу, холодный обзвон, обзвон базы, исходящие звонки конкурента, откуда столько лишних, почему приходят не те, заявки не по профилю, следить за источником, проверять источник, сменить источник из-за качества, ухудшилось качество, раньше было лучше, стало хуже, мусорные заявки, не целевая аудитория, ответственность за источник, кто выбирает источник +--- + +**Мы отдаём все заявки, которые прошли через выбранный вами источник.** И тех, кто сам +интересовался — заходил на сайт, звонил, — и тех, кому звонили с этого номера. Что именно +за люди идут через источник, определяет не Лидерра, а тот, кому этот источник принадлежит. +Поэтому **источник — это ваше решение, и присматривать за ним стоит так же, как за рекламой.** + +## Почему могут прийти «не ваши» люди + +**Источник шире вашей темы.** Классический пример: большая клиника, где 30 врачей — +и стоматолог, и кардиолог, и хирург. Если вы занимаетесь только зубами, а источником взяли +сайт такой клиники, к вам придут не только те, у кого болит зуб. Источник отработал честно — +просто он не про одну стоматологию. + +**Конкурент вёл холодный обзвон.** Если он купил базу и прозванивал её со своего номера, +эти контакты тоже пойдут к вам: формально они прошли через источник, хотя интереса +у людей не было. + +**Со временем всё меняется.** Конкурент расширил профиль, поменял настройки рекламы, +погнал трафик не того сегмента — и источник, который месяц назад давал отличные заявки, +сегодня начинает давать лишние. Это происходит без нашего участия и без нашего ведома. + +## Где чья ответственность + +**Лидерра отвечает за то, чтобы номера дошли:** быстро, без потерь и без повторов +(один и тот же номер не вернётся 30 дней). Что происходит на стороне источника — реклама +конкурента, его профиль, его обзвоны — **вне нашего контроля и вне нашей ответственности.** + +Мы не сортируем людей по темам и **не отсеиваем «нецелевых»** — у нас просто нет данных, +чтобы решать за вас, чей это клиент. Мы передаём контакт человека с интересом по указанному +вами источнику; кто из них ваш — решаете вы. + +Поэтому **заявка не по вашей теме — это не брак**, и заменой или возвратом такие случаи +не закрываются. Это свойство выбранного источника, а не сбой портала. + +## Что делать, чтобы источник работал на вас + +- **Выбирайте узкое, а не крупное.** Прямой конкурент, который делает ровно то же, что и вы, + почти всегда лучше большого многопрофильного игрока. Заявок будет меньше — зато ваших. +- **Смотрите первые дни внимательно.** Начните с небольшого дневного лимита, посмотрите, + кто приходит, и только потом поднимайте. +- **Проверяйте источник и дальше, а не только на старте.** Пошёл поток «не тех» — это сигнал, + что у конкурента что-то поменялось. +- **Не нравится, что идёт, — меняйте.** Источник в проекте можно поменять в любой момент + («Сменить источник проекта»), а проект — поставить на паузу, и списания сразу прекратятся. +- **Подобрать более точный источник** помогают «Конкурентное поле» и «Изучение источников». + +**Спорная ситуация — напишите живому специалисту** на `support@liderra.ru`: укажите номер +сделки и что именно вас смутило. Каждый случай разбирается индивидуально, заранее ничего +не обещаем. diff --git a/app/resources/help/kak-popolnit-balans.md b/app/resources/help/kak-popolnit-balans.md new file mode 100644 index 00000000..d7582566 --- /dev/null +++ b/app/resources/help/kak-popolnit-balans.md @@ -0,0 +1,32 @@ +--- +title: Как пополнить баланс +tour: top-up-balance +topics: пополнить, пополнение, положить деньги, оплатить, минимальная сумма, 100 рублей, картой, автопополнение, кошелёк, баланс не пополнился, платёж обрабатывается +--- + +Баланс — это ваш кошелёк: из него оплачиваются полученные заявки и услуги +«Конкурентного поля». Пополнение — раздел **«Биллинг»**, кнопка **«Пополнить +баланс»** справа сверху. + +В окне пополнения: + +- **Сумма** — минимум **100 ₽**, максимум 1 000 000 ₽ за одну операцию. Есть + готовые кнопки: 1 000 / 5 000 / 10 000 / 25 000 ₽. +- **Способ** — **«Картой»** или **«По счёту (для юрлиц)»** (про счёт — отдельная + статья). + +При оплате картой вы переходите на защищённую страницу оплаты; после возврата +портал покажет: «Платёж обрабатывается. Баланс обновится автоматически +в течение минуты после подтверждения оплаты». Если баланс не обновился +за несколько минут — напишите нам, разберёмся. + +Как только денег снова хватает на дневной заказ, **приём заявок включается сам** — +отдельно ничего нажимать не надо. + +Новичкам при регистрации мы дарим **1000 ₽ на баланс** — их сразу можно тратить +на первые заявки. + +На главной плитке «Кошелёк» видно текущую сумму, рядом — сколько это примерно +заявок по вашей нынешней цене и на сколько дней хватит при текущем заказе. + +Автопополнение пока не подключено — кнопка есть, но неактивна. diff --git a/app/resources/help/kak-rabotat-s-nomerom.md b/app/resources/help/kak-rabotat-s-nomerom.md new file mode 100644 index 00000000..13e1a06c --- /dev/null +++ b/app/resources/help/kak-rabotat-s-nomerom.md @@ -0,0 +1,30 @@ +--- +title: Как работать с полученным номером — сначала СМС, через час звонок +tour: deals +topics: что говорить когда звоню, скрипт продаж, как звонить, что предложить клиенту, смс, промокод, рассылка, как обрабатывать заявку, первый контакт, чем перебить конкурента +--- + +Наш опыт: **лучше всего работает связка «сначала СМС — через час звонок»**. + +1. **СМС с предложением.** Человек вчера смотрел товар или услугу у конкурента. Пришлите + ему короткое сообщение с тем, чем вы лучше: цена, условия, скидка, промокод, гарантия, + сроки. Одно понятное предложение — не рассказ о компании. +2. **Через час — звонок.** К этому моменту человек уже видел ваше сообщение, и разговор + начинается не с нуля: он знает, кто вы и что предлагаете. Конверсия у такого звонка + заметно выше, чем у холодного. +3. **Медийный вариант.** Если продажа не в один звонок, можно просто заявить о себе: + «мы работаем в этой же теме, посмотрите наши условия» — и дальше вести человека + привычными вам инструментами. + +**Что предлагать — решаете вы.** Мы даём контакт человека с актуальной потребностью; +чем именно перебить конкурента (ценой, качеством, сервисом, сроком) — ваше решение +и ваше конкурентное преимущество. + +**Если человек не берёт трубку** — это обычное дело, а не брак: он за рулём, на встрече +или не отвечает на незнакомые номера. Отправьте СМС, перезвоните позже или в другое время +суток, напишите в мессенджер. Один пропущенный звонок — ещё не отказ. Если же номер выглядит +явно нерабочим или заявка вызывает вопросы, напишите на `support@liderra.ru` с номером +сделки — специалист разберёт случай индивидуально. + +**Скриптов продаж мы не даём** — они у каждого бизнеса свои. Но важное правило одно: +чем быстрее вы вышли на связь, тем теплее контакт. diff --git a/app/resources/help/kanban.md b/app/resources/help/kanban.md new file mode 100644 index 00000000..8fdd8d33 --- /dev/null +++ b/app/resources/help/kanban.md @@ -0,0 +1,35 @@ +--- +title: Канбан — доска заявок +tour: kanban +topics: канбан, доска, колонки, перетащить, drag and drop, чем отличается от списка, новая сделка вручную, доска на телефоне, сумма по колонке +--- + +«Канбан» — те же самые заявки, что и в «Сделках», только показаны доской: +пять колонок по числу статусов (Новая сделка → Просмотрено → В работе → +Сделка / Не реализовано). + +В шапке каждой колонки — название, счётчик заявок и **суммарная стоимость** +заявок в этой колонке. + +**Перетаскивание.** Карточку можно взять мышью и перетащить в другую колонку — +статус сменится сразу. Если что-то пойдёт не так, карточка вернётся на место +и портал скажет об этом. Перетаскивание внутри одной колонки просто меняет +порядок карточек. + +Клик по карточке открывает ту же панель заявки, что и в «Сделках». + +**Чем отличается от «Сделок»:** + +| | Сделки (список) | Канбан (доска) | +|---|---|---| +| Вид | таблица со строками | карточки по статусам | +| Фильтры, поиск, экспорт | есть | нет | +| Смена статуса | по одной или массово | перетаскиванием, по одной | +| Создать заявку вручную | нельзя | можно («Новая сделка») | + +Массово менять статусы удобнее в «Сделках», а глазами оценивать, где что +застряло, — в «Канбане». + +**На телефоне** доска не листается вбок: сверху переключатель этапов +(с counter'ом заявок), под ним — одна колонка выбранного этапа. Статус там +меняется через открытие карточки, а не перетаскиванием. diff --git a/app/resources/help/kogda-poydut-zayavki.md b/app/resources/help/kogda-poydut-zayavki.md new file mode 100644 index 00000000..afbb8810 --- /dev/null +++ b/app/resources/help/kogda-poydut-zayavki.md @@ -0,0 +1,33 @@ +--- +title: Выгрузка по проекту — когда пойдут заявки и когда прекратятся +tour: rule-18 +topics: выгрузка, выгрузка по проекту, через сколько придёт выгрузка, когда придёт выгрузка, когда начнётся выгрузка, когда остановится выгрузка, когда пойдут заявки, когда прекратятся заявки, поставка, поток заявок, в какое время приходят заявки, заявки идут неравномерно, нет заявок сегодня, в какое время дня приходят заявки, когда приходят лиды, время поставки, в течение дня +--- + +Сначала про слово: клиенты часто называют поток заявок **«выгрузкой»** по проекту. +Это то же самое — заявки, которые падают вам в «Сделки». + +**Когда пойдут.** Не сегодня. Создали проект (или включили его) **до 18:00 по Москве** — +заявки пойдут **со следующего дня**. После 18:00 — **с послезавтра**. Так работает +потому, что заказ на завтра портал фиксирует накануне вечером. + +**В какое время в течение дня.** Заявки идут **потоком, а не пачкой в одно время**: +кто-то заинтересовался утром, кто-то вечером. Поэтому в один день их может прийти +гуще, в другой реже — это нормально, а не сбой. Больше дневного лимита не придёт +никогда, а вот меньше — бывает (см. статью про лимит). + +**Когда прекратятся.** + +- **Поставили на паузу** — новые сборы прекращаются сразу. Но заявки, уже попавшие + в вечерний заказ, ещё дойдут — примерно сутки. Это «хвост», их оплачивают как обычно. +**Ночью заявки не приходят** — поток идёт в течение дня. Ночью суточные счётчики +обнуляются, и с утра дневной лимит открывается заново. + +- **Выбрали дневной лимит** — до полуночи по этому проекту больше ничего не придёт, + с утра лимит открывается заново. +- **Кончился баланс** — приём встаёт на паузу по всему аккаунту, вам уходит письмо. + Пополнили — включается само. +- **Сегодняшний день недели не выбран** в «Днях приёма» — в этот день заявок не будет. + +Если заявок нет несколько дней подряд, а проект активен и баланс есть — напишите нам, +разберёмся. diff --git a/app/resources/help/komanda-i-dostup.md b/app/resources/help/komanda-i-dostup.md new file mode 100644 index 00000000..21bf659c --- /dev/null +++ b/app/resources/help/komanda-i-dostup.md @@ -0,0 +1,32 @@ +--- +title: Могут ли работать несколько сотрудников в одном кабинете +tour: security +topics: несколько человек, нас трое, нас двое, всем сотрудникам зайти, один логин на всех, два человека, три сотрудника, команда, сотрудники, коллеги, вместе работать, одновременно, второй пользователь, роли, доступ сотруднику, менеджеры, разделить доступ, один аккаунт, сколько человек можно добавить, сколько сотрудников, ограничение на сотрудников, лимит пользователей, скрыть биллинг от менеджера, чтобы менеджер не видел, ограничить права, права доступа, кто что видит, общий баланс на всех, у каждого свой баланс +--- + +Сейчас в кабинет входят **по одной учётной записи** — отдельных сотрудников с личными +логинами и правами пока нет. + +Что это значит на практике: работать в кабинете могут несколько ваших сотрудников, +но под одним и тем же логином и паролем. Портал это выдержит: заявки, статусы +и комментарии видны всем сразу, а «Активные сессии» в настройках покажут, с каких +устройств сейчас входили, — лишнее устройство можно отключить кнопкой «Завершить». + +**Сколько человек можно пустить?** Ограничения на число сотрудников нет — вход общий, +и «добавлять» никого не нужно: заходят все, кто знает логин и пароль. Считать людей портал +не умеет и отдельных учёток не заводит. + +**Скрыть от сотрудника раздел (например, Биллинг) нельзя.** Вход общий — значит и видно всё +одинаково: заявки, списания, баланс, настройки. Раздельных прав пока нет. + +**Баланс общий на всех.** Отдельных кошельков у сотрудников не бывает: все тратят одни +и те же деньги. Поэтому доступ стоит давать людям, которым вы доверяете финансы. + +Чего пока нет: раздельных учёток для менеджеров, ролей и прав доступа, персональной +статистики по каждому сотруднику. + +Если вам нужен доступ для команды с разделением прав — напишите в поддержку, расскажите, +как устроен ваш отдел: мы учитываем такие запросы. + +Совет по безопасности: если общий пароль знают несколько человек, меняйте его при уходе +сотрудника (Настройки → Безопасность → «Сменить пароль») и включите двухфакторную защиту. diff --git a/app/resources/help/konkurentnoe-pole.md b/app/resources/help/konkurentnoe-pole.md new file mode 100644 index 00000000..7286d961 --- /dev/null +++ b/app/resources/help/konkurentnoe-pole.md @@ -0,0 +1,50 @@ +--- +title: Конкурентное поле — что это и как устроено +tour: konkurentnoe-pole +topics: конкурентное поле, поле, автоподбор, радар, зачем нужно поле, что лучше проекты или конкурентное поле, чем пользоваться, что выбрать, разница между проектами и полем, это одно и то же, вкладки поля, предложения, в поле, карточка конкурента, похожесть, федеральный, региональный, холдинг +--- + +«Конкурентное поле» — раздел, где Лидерра находит ваших конкурентов и те места, +откуда к ним приходят клиенты (их сайты и телефоны). Найденное вы превращаете +в свои проекты — и заявки идут уже к вам. Пункт меню слева (значок радара), +на телефоне — нижняя вкладка «Поле». + +**Что выбрать — проекты или конкурентное поле?** Это не «или-или», это одно +и то же, только с разных концов. Если вы **уже знаете источник** (свой сайт-донор, +номер конкурента) — идите сразу в «Проекты» и заводите проект руками: быстрее +и бесплатно. Если **источников вы не знаете** — начните с «Поля»: Лидерра найдёт +конкурентов и их источники, а из найденного вы в один клик сделаете те же самые +проекты. Заявки в итоге приходят одинаково — в «Сделки», и платите вы за них +по одним и тем же тарифам. Разница только в том, кто ищет источник: вы или мы +(и за нашу работу берём {{цена_подбора}} за подбор и {{цена_изучения}} за изучение конкурента). + +Раздел устроен в три экрана: + +**«В поле»** — конкуренты, которых вы взяли в работу. У каждого на карточке: +процент похожести на вас (сортировка — самые похожие сверху), метка +«региональный» или «федеральный», сайт и ссылки на справочники (2ГИС, +Яндекс.Карты), счётчики «источников: N · создано проектов: N · M в работе», +кнопки «✎ Изменить», «✕ Удалить» и «Открыть конкурента →». + +**«Предложения»** — черновик: то, что Лидерра нашла, но вы ещё не разобрали. +Отсюда вы переносите нужных в поле кнопкой «В поле →», а лишних удаляете. +Ничего не попадает в работу само. + +**Карточка конкурента** (кнопка «Открыть конкурента →») — здесь его источники: +вкладки «Источники в работе» и «Предложения», легенда «как читать» пометки +у номеров и кнопки, чтобы собрать источники или создать по ним проект. + +Конкурент может быть «холдингом» — несколько фирм-элементов под одной карточкой +(например, застройщик и его жилые комплексы). У каждого элемента свои сайты +и карточки в справочниках; при сборе источников Лидерра пройдёт по всем разом. + +Порядок работы простой: **собрать конкурентов** (шаг 1) → **изучить конкурента**, +то есть найти его источники (шаг 2) → **создать проект** из подходящего источника. +Платно только два первых шага и только при результате; создание проекта — +бесплатно, деньги дальше идут только за полученные заявки. Актуальная цена всегда +написана прямо в окне, где вы запускаете сбор — ориентируйтесь на неё. + +И главное правило: **в поле берите только проверенных конкурентов.** Перед +переносом из «Предложений» открывайте сайт и карточку справочника по ссылкам — +тот ли это бизнес, ваш ли город, живой ли адрес. От чистоты поля зависит всё +дальнейшее: какие источники мы найдём и за что вы заплатите. diff --git a/app/resources/help/limit-zayavok.md b/app/resources/help/limit-zayavok.md new file mode 100644 index 00000000..3d548187 --- /dev/null +++ b/app/resources/help/limit-zayavok.md @@ -0,0 +1,35 @@ +--- +title: Лимит заявок в день — и почему пришло меньше лимита +tour: limit +topics: лимит, лимит в день, сколько заявок в день, увеличить лимит, уменьшить лимит, мало заявок, пришло меньше лимита, не выбирается лимит, нет заявок, почему нет заявок, максимум 10000, минимум 1 +--- + +**Лимит заявок в день** — сколько заявок вы готовы принимать и оплачивать +по проекту. От 1 до 10 000, по умолчанию 50. Менять можно в любой момент — +в карточке проекта или сразу по нескольким проектам массовым действием. + +Лимит — это **потолок, а не гарантия**. Больше него за день не придёт никогда. +А вот меньше — бывает, и это нормально. Частые причины: + +- **В этот день в вашей нише и регионе просто не набралось столько обращений.** + Заявки идут потоком в течение дня, а не пачкой: то гуще, то реже. +- **Не хватило баланса** — приём тогда встаёт на паузу (см. статью про баланс). +- **Сегодняшний день недели не выбран** в «Днях приёма». +- **Регион указан слишком узко** — попробуйте расширить или включить «Вся РФ». + +Если заявок стабильно меньше, чем нужно, — **увеличьте лимит**. Ваш заказ +распределяется по нескольким направлениям, и при большем лимите заявок +приходит больше. Это самый прямой способ. + +Важно про сроки: новый лимит начинает работать **не сегодня**. Изменили +до 18:00 МСК — действует с завтра, после 18:00 — с послезавтра. Пауза — +исключение, срабатывает сразу. + +Ещё два правила: + +- **Ноль поставить обычной формой нельзя** (минимум 1). Ноль возможен только + как выбор «Уменьшить объём» в окне нехватки баланса. +- **Нельзя опустить лимит ниже уже полученных сегодня заявок** — портал скажет, + сколько их уже пришло. +- Если новый лимит не покрывается балансом, портал прямо посчитает: «хватает + на N заявок, запрошено M — пополните баланс». diff --git a/app/resources/help/massovye-deystviya.md b/app/resources/help/massovye-deystviya.md new file mode 100644 index 00000000..9a33efb4 --- /dev/null +++ b/app/resources/help/massovye-deystviya.md @@ -0,0 +1,34 @@ +--- +title: Массовые действия с проектами — менять много проектов разом +tour: bulk-projects +topics: массовые действия, сразу несколько проектов, выбрать все, изменить лимит у всех, добавить регионы всем, убрать дни, приостановить все, возобновить все, 500 проектов, фильтры проектов +--- + +Если проектов много, менять их по одному не надо. Отметьте нужные галочками — +внизу экрана появится панель массовых действий. + +Что можно сделать разом: + +- **«Лимит лидов…»** — либо прибавить/убавить (например, «+10 к лимиту каждому»), + либо заменить на одно и то же число («установить лимит 50 всем»). +- **«Регионы…»** — отдельно «➕ Добавить регионы» и «➖ Убрать регионы»: + выбранные субъекты добавятся к регионам каждого проекта или уберутся из них. +- **«Дни сбора…»** — так же: добавить или убрать дни недели. +- **«Приостановить» / «Возобновить»** — с подтверждением. +- **«Удалить»** — проекты со сделками будут пропущены. + +Можно отметить не вручную, а **все проекты по текущим фильтрам** — галочка над +списком, надпись «Выбрано: N из M». За один раз портал обработает до 500 +проектов; если под фильтр попало больше, он попросит сузить. + +После применения портал честно отчитается, что прошло и что нет, например: +«Применено: 12. Пропущено: 3 — не хватает баланса на новый лимит; 1 — по проекту +есть сделки.» Другие причины пропуска: «мы уже начали сбор на завтра», +«лимит ниже уже доставленных сегодня заявок». + +Сами фильтры на странице «Проекты»: тип источника, статус (активные / на паузе), +регион, день приёма, поиск по названию и источнику, сортировка (по заявкам +за сегодня, за месяц, по лимиту, по названию, по дате создания). + +Как и всегда, новые лимиты/регионы/дни вступают в силу со следующего дня +(до 18:00 МСК — завтра, после — послезавтра), а пауза — сразу. diff --git a/app/resources/help/metki-nomerov.md b/app/resources/help/metki-nomerov.md new file mode 100644 index 00000000..81e2175b --- /dev/null +++ b/app/resources/help/metki-nomerov.md @@ -0,0 +1,27 @@ +--- +title: Пометки у найденных номеров — какой номер настоящий +tour: phone-marks +topics: настоящий номер, непроверенный номер, что значит непроверенный, подменный номер, рекламный номер, на сайте проверьте, где нашли, как читать номера, метки, пометки, значки у номеров, городской, мобильный, 8-800, подтверждений +--- + +У каждого найденного номера конкурента стоит пометка — она говорит, насколько +номеру можно доверять. Легенда «Как читать» есть прямо в карточке конкурента: + +- **✓ настоящий номер** — живой номер фирмы, по нему дозвонитесь. Такой номер + и стоит брать в работу. +- **🎭 рекламный подменный** — номер из рекламы: он постоянно меняется, поэтому + как источник заявок он бесполезен. В работу брать не стоит. +- **⚠ на сайте — проверьте** — нашли в тексте страницы, но ссылкой это не + подтверждено. Позвоните и убедитесь сами, прежде чем заводить проект. + +Рядом с каждым номером есть **«где нашли»** — кликабельные ссылки на места, +где номер обнаружен. Их можно открыть и проверить своими глазами. Если мест +несколько — рядом счётчик «✓N подтвержд.»: чем больше подтверждений, тем +надёжнее. + +Портал также показывает тип линии: городской, мобильный или 8-800. + +Мы дополнительно проверяем номера по независимым справочникам — чтобы отсеять +технически несуществующие и правильно определить город. Но окончательное +решение, брать номер в работу или нет, остаётся за вами: портал ничего +не подставляет молча. diff --git a/app/resources/help/mobilnaya-versiya.md b/app/resources/help/mobilnaya-versiya.md new file mode 100644 index 00000000..4f9ee2e2 --- /dev/null +++ b/app/resources/help/mobilnaya-versiya.md @@ -0,0 +1,21 @@ +--- +title: Портал на телефоне +topics: портал на телефоне, работает ли на телефоне, мобильная версия, смартфон, мобильный браузер, приложение, андроид, айфон, планшет, с телефона, со смартфона, нижние вкладки, ещё, меню на телефоне, где настройки на телефоне +--- + +Портал полноценно работает с телефона — отдельное приложение не нужно, просто +откройте lk.liderra.ru в браузере. + +Меню слева на телефоне **нет**. Внизу экрана — пять вкладок: **Поле** +(Конкурентное поле), **Проекты**, **Сделки**, **Биллинг**, **Канбан**. +Всё остальное — в панели **«Ещё»**: Дашборд, Отчёты, Настройки, Импорт данных, +Помощь. Ни один раздел не потерян, просто вход через «Ещё». + +С телефона удобно: смотреть новые заявки и звонить по ним в один тап, менять +статусы, ставить проекты на паузу и запускать, пополнять баланс. + +В «Канбане» на телефоне доска не листается вбок: сверху переключатель этапов, +внизу — одна колонка выбранного этапа; статус меняется через открытие карточки. + +Всё, что вы делаете с телефона, тут же видно на компьютере и наоборот — это +один и тот же кабинет. diff --git a/app/resources/help/nachalo-raboty.md b/app/resources/help/nachalo-raboty.md new file mode 100644 index 00000000..4efd2739 --- /dev/null +++ b/app/resources/help/nachalo-raboty.md @@ -0,0 +1,26 @@ +--- +title: С чего начать — первые три шага +tour: start +topics: с чего начать, начало работы, первые шаги, как начать получать заявки, новичок, первый проект, когда пойдут заявки, подарок 1000, стартовый бонус +--- + +Чтобы пошли заявки, нужно три вещи. + +**1. Баланс — он у вас уже есть.** При регистрации мы дарим **1000 ₽**, и они уже лежат +на балансе: первые заявки идут за счёт подарка, пополнять на старте ничего не нужно. +Когда подарок закончится — «Биллинг» → «Пополнить баланс» (минимум 100 ₽). + +**2. Проект.** Раздел «Проекты» → «Создать проект». Проект — это ваш заказ: +откуда брать заявки (сайт, звонок или СМС в вашей нише) и сколько штук в день +вы готовы принимать и оплачивать. Не знаете, какой источник указать — идите +в «Конкурентное поле»: Лидерра найдёт конкурентов и их источники за вас. + +**3. Подождать до завтра.** Заявки пойдут **не сегодня**: создали проект +до 18:00 МСК — со следующего дня, после 18:00 — через день. Заказ на завтра +портал фиксирует вечером, поэтому «прямо сейчас» заявки не появляются. + +Дальше всё приходит в раздел **«Сделки»** — с телефоном, городом и источником. +Там же ведёте работу: статусы, комментарии, звонки в один клик. + +Если баланса не хватит на дневной заказ, приём встанет на паузу — портал +предупредит письмом и покажет, сколько не хватает. Пополните — включится сам. diff --git a/app/resources/help/nishi-i-pravila.md b/app/resources/help/nishi-i-pravila.md new file mode 100644 index 00000000..47c52b9f --- /dev/null +++ b/app/resources/help/nishi-i-pravila.md @@ -0,0 +1,26 @@ +--- +title: Для каких ниш подходит и какие есть правила +tour: start +topics: ставки на спорт, букмекер, казино, азартные игры, алкоголь, табак, возьмёте ли мою нишу, для каких сфер, подойдёт ли мне, моя ниша, микрозаймы, мфо, медицина, юруслуги, запрещённые ниши, ограничения, кому нельзя, правила использования, злоупотребление, блокировка, бан, ответственность +--- + +**Ограничений по нишам у нас нет.** Сервис не оценивает, чем занимается клиент, — он +даёт контакт человека с актуальным интересом. Займы и МФО, медицина, юруслуги, окна, +ремонт, обучение, страхование, автоуслуги — работают все, кто продаёт по телефону. + +Подходит ли конкретно вам — проверяется просто: если у вашего конкурента есть сайт или +телефон, по которым к нему идут клиенты, значит, вы можете получать таких же клиентов себе. + +**Правила, которые важно понимать:** + +- **Ответственность за то, что вы предлагаете человеку, — на вас.** Мы передаём контакт; + что и как вы продаёте, как ведёте рекламу и соблюдаете ли отраслевые требования — + ваша зона ответственности. +- **За злоупотребление — блокировка.** Если сервис используют для обмана людей или иных + недобросовестных действий, доступ закрывается. +- **Мы ведём журнал.** Все запросы и передачи контактов фиксируются. При законном запросе + государственных органов мы обязаны предоставить эти сведения — включая информацию о том, + кто запрашивал конкретный номер. Скрывать эти данные сервис не будет. + +Если сомневаетесь, подходит ли ваш случай, — напишите на `support@liderra.ru`, +подскажем честно. diff --git a/app/resources/help/o-servise.md b/app/resources/help/o-servise.md new file mode 100644 index 00000000..f659539d --- /dev/null +++ b/app/resources/help/o-servise.md @@ -0,0 +1,42 @@ +--- +title: О сервисе — зачем нужна Лидерра и чем она отличается +tour: start +topics: зачем нужна лидерра, чем отличается, чем вы лучше, преимущества, выгода, что даёт, почему вы, сравнение, о сервисе, как это работает, что за сервис, это таргет, таргет, реклама, директ, контекстная реклама, чем лучше директа, стоит ли, риски, обязательства, попробовать, это развод, вы мошенники +--- + +Лидерра решает одну задачу: приводить вам заявки от людей, которые прямо сейчас +ищут услугу в вашей нише. Вы не покупаете рекламу, не настраиваете кампании +и не ждёте месяцами — просто указываете, по какому сайту или телефону идут клиенты +к похожим компаниям, и получаете таких же клиентов себе. + +Чем это отличается от привычных способов привлечения: + +- Оплата за результат, а не за показы и клики. Абонентской платы нет: вы платите + фиксированную цену за каждую переданную заявку. Не пришло заявок — не потратили + ни рубля. +- Цена падает с объёмом. Семь тарифных ступеней: чем больше заявок за месяц — + тем дешевле каждая следующая. +- Полная прозрачность. Каждое списание видно поимённо: дата, заявка, ступень, + сумма. Скрытых комиссий нет, выгрузка в CSV — в один клик. +- Вы управляете краном. Дневной лимит от 1 до 10 000 заявок, регионы, дни недели — + меняются в любой момент. Пауза действует сразу, возобновление — тоже. +- Никаких обязательств и минимального срока. Не понравилось — поставили на паузу, + деньги остаются на балансе. +- Старт за минуты. Регистрация, подтверждение почты, первый проект — и заявки идут + со следующего дня. Новичкам дарим 1000 ₽ на баланс: первые заявки можно + попробовать бесплатно. +- Заявки сразу в работе. Приходят в раздел «Сделки» с телефоном, городом + и источником; есть воронка, канбан, комментарии менеджера, экспорт и импорт + своей базы — отдельная CRM не нужна. +- Умный поиск точек роста. Раздел «Конкурентное поле» помогает найти компании, + похожие на вашу, и увидеть, по каким сайтам и телефонам к ним идут клиенты — + из этого в один клик получается новый проект. + +Кому подходит: бизнесу с услугами и продажами по телефону, где важен живой контакт +с клиентом — окна, ремонт, юруслуги, займы, страхование, медицина, обучение +и подобное. + +Что важно понимать честно: заявка — это интерес человека, а не гарантированная +продажа. Ваш результат зависит и от того, как быстро вы перезваниваете и как +разговариваете. Поэтому мы даём инструменты, чтобы это отслеживать: воронку, +конверсию в оплату и стоимость заявки в дашборде. diff --git a/app/resources/help/oplata-po-schetu.md b/app/resources/help/oplata-po-schetu.md new file mode 100644 index 00000000..862defb2 --- /dev/null +++ b/app/resources/help/oplata-po-schetu.md @@ -0,0 +1,32 @@ +--- +title: Оплата по счёту и закрывающие документы (для юрлиц и ИП) +tour: invoice +topics: счёт, выставить счёт, оплата по счёту, безнал, банковский перевод, закрывающие документы, акт, ндс, усн, реквизиты, скачать счёт pdf, счёт просрочен, когда зачислят деньги +--- + +Юрлицам и ИП удобнее платить по счёту — портал выставит его сам. + +**Порядок такой:** + +1. Заполните реквизиты компании: **Настройки → Реквизиты**. Тип лица (ИП или + юрлицо), ИНН — по нему портал сам подтянет название, КПП, ОГРН и адрес + (кнопка «Найти по ИНН»), затем банк, БИК, расчётный и корр. счёт. Без + реквизитов счёт не выставится. +2. «Биллинг» → **«Пополнить баланс»** → способ **«По счёту (для юрлиц)»** → + сумма → **«Сформировать счёт»**. +3. PDF счёта сразу откроется в новой вкладке, а сам счёт появится на вкладке + **«Счета»** — оттуда его можно скачать в любой момент. +4. Оплачиваете обычным банковским переводом по реквизитам счёта. +5. **Баланс пополняется, когда мы увидим вашу оплату** — зачисление по счёту + не мгновенное, деньги должны дойти до банка и мы отметим счёт оплаченным. + После этого автоматически формируется **Акт** (закрывающий документ), он + уходит вам на почту и появляется на вкладке «Счета» рядом со счётом. + +**Про НДС:** мы работаем по УСН, **НДС не облагается** — так и написано +в счёте и акте. Услуга в документах называется «Оплата генерации рекламных +лидов». + +Счёт действует 5 рабочих дней; если не оплачен — он получает статус +«Просрочен», просто выставьте новый. + +Все ваши счета и акты всегда лежат в разделе «Биллинг» → вкладка «Счета». diff --git a/app/resources/help/otchety.md b/app/resources/help/otchety.md new file mode 100644 index 00000000..715ade60 --- /dev/null +++ b/app/resources/help/otchety.md @@ -0,0 +1,33 @@ +--- +title: Отчёты — заказать выгрузку за период +tour: reports +topics: отчёты, отчет, заказать отчёт, выгрузка за период, pdf, xlsx, json, csv, отчёт по биллингу, отчёт по источникам, отчёт по менеджерам, отчёт готовится, скачать отчёт, большой отчёт на почту +--- + +Раздел **«Отчёты»** — это выгрузки за период. На десктопе он открывается +по адресу `/reports`, на телефоне — через меню «Ещё». + +Что можно заказать: + +- **Сделки · детально** — все заявки со всеми колонками. +- **Менеджеры** — конверсия, средний чек, время в воронке. +- **Источники** — откуда пришли заявки. +- **Биллинг** — пополнения, списания, возвраты. + +Формат на выбор: **CSV, XLSX, JSON, PDF**. Обязательно указать период «с» и «по». + +Оговорка: фильтры «Проект» и «Менеджер» в форме пока работают только в режиме +«Все» — выбрать конкретный проект в отчёте нельзя. Нужна выборка по одному +проекту — сделайте её в «Сделках»: там фильтр по проекту есть, и оттуда +выгружается Excel или CSV. + +Отчёт готовится не мгновенно — он встаёт в очередь, и в списке ниже видно его +состояние. Как только готов, его можно скачать; ссылка живёт сутки. **Большие +отчёты (свыше 5000 строк) приходят на почту** — портал предупреждает об этом +прямо в форме. + +Одновременно можно держать до трёх отчётов в работе. Неудачный отчёт можно +перезапустить, ненужный — удалить. + +Если нужна просто выгрузка заявок — быстрее сделать её прямо в «Сделках» +(кнопки «Экспорт в Excel» / «Экспорт в CSV»). diff --git a/app/resources/help/otkuda-zayavki.md b/app/resources/help/otkuda-zayavki.md new file mode 100644 index 00000000..8f815cf4 --- /dev/null +++ b/app/resources/help/otkuda-zayavki.md @@ -0,0 +1,69 @@ +--- +title: Откуда берутся заявки и номера телефонов, законно ли это +topics: оштрафуют, штраф, законно ли звонить, ответственность, реклама, откуда заявки, откуда номера, откуда приходят номера телефонов, где вы берёте номера, откуда телефон клиента, откуда данные, как вы получаете номер человека, номер посетителя сайта, кто заходил на сайт, все ли номера цепляет система, все номера с источника, сколько номеров получаем, почему не все номера, половина номеров, 50 процентов, законно ли, законно ли брать телефоны с сайтов конкурентов, 152-фз, персональные данные, согласие абонента, операторы связи, база номеров, спам, легально, как вы находите клиентов, слежка, счётчик на сайте +--- + +Заявки формируются на основе данных, которые нам передают операторы связи. Мы получаем +контакты только тех абонентов, которые дали оператору согласие на передачу своих данных +третьим лицам и на получение рекламных предложений. Абонент без такого согласия +к нам не попадает — никогда. + +Проще говоря: человек сам заранее разрешил, чтобы ему звонили с предложениями по темам, +которые ему интересны. Мы видим, что он интересуется услугой в вашей нише — по тому +источнику, который вы указали в проекте (сайт, телефон или СМС-отправитель), — и передаём +вам его контакт. + +**«А как получить номер человека, который заходит на сайт?» — это ровно то, что делает +Лидерра.** Именно за этим к нам и приходят. Порядок такой: создайте проект, укажите +в нём сайт (свой или конкурента) — и контакты людей, которые интересовались этой темой, +начнут приходить вам в раздел «Сделки». Ничего устанавливать на сайт не нужно, доступ +к чужому сайту не требуется. + +Работает это так: операторы связи видят интерес своих абонентов — например, что человек +заходил на сайт по такой-то теме или звонил по такому-то номеру. Контакты тех, кто дал +оператору согласие на передачу данных третьим лицам и на рекламные предложения, приходят +к нам, а от нас — к вам, по указанному вами источнику. Самостоятельно «снять» номер +посетителя с сайта нельзя — это и есть наша работа, за неё вы и платите: только за +полученные заявки. + +**Все ли номера с источника вы получаете? Нет — примерно половину.** Передавать можно +только тех абонентов, кто дал оператору согласие на передачу данных третьим лицам +и на рекламные предложения. Согласие есть примерно у половины людей — значит, и заявок +с источника приходит около 50% от всех, кто им интересовался. Остальные для нас +недоступны, и это не ограничение портала, а требование закона. Поэтому не удивляйтесь, +если по «горячему» источнику заявок меньше, чем вы ожидали: мы отдаём всё, что можно +отдать законно. + +Законность: обработка персональных данных ведётся в соответствии с Федеральным законом +№ 152-ФЗ «О персональных данных». Основание передачи — согласие самого абонента, которое +он дал своему оператору связи. Никаких «слитых» или купленных на стороне баз мы +не используем. + +**А контакты конкурентов в «Конкурентном поле» — это другое.** Там мы показываем сайты +и рабочие телефоны фирм, взятые из **открытых источников**: их собственных сайтов +и публичных справочников (2ГИС, Яндекс.Карты). Это контакты организаций, которые они сами +опубликовали для клиентов, а не персональные данные граждан. Использовать их, чтобы понять, +откуда к конкуренту идут клиенты, — законно. Заявки же (контакты живых людей) приходят +только тем путём, что описан выше: от операторов, по согласию абонента. + +Что это значит для вас на практике: +- Человеку можно звонить: он согласие давал и рекламные предложения ждёт. +- Звоните быстро — интерес живой, и чем раньше вы наберёте, тем выше шанс на сделку. +- Представляйтесь и говорите по существу предложения. Если человек скажет, что ему + неинтересно или просит больше не звонить — просто вежливо завершите разговор + и отметьте заявку статусом «Не реализовано». +- Свои обязательства перед клиентом (как вы храните его контакт и что предлагаете) + вы выполняете сами — как и при любом другом источнике клиентов. + +Заявка — это интерес человека, а не гарантированная продажа: результат зависит и от +скорости звонка, и от вашего разговора. Отслеживать это помогают воронка и конверсия +в дашборде. + +Если у вас юридические вопросы по обработке данных или нужен договор — напишите +в поддержку, ответит живой специалист. + +**«Меня не оштрафуют, если я буду звонить?»** Человек дал оператору согласие на передачу +данных третьим лицам и на рекламные предложения — поэтому звонить и писать ему можно. +Но **гарантий по вашей рекламе мы не даём**: за то, что и как вы предлагаете, за соблюдение +закона о рекламе и за уважение к отказу человека отвечаете вы сами. Если нужен юридический +разбор вашего случая — напишите на `support@liderra.ru`, ответит живой специалист. diff --git a/app/resources/help/pauza-proekta.md b/app/resources/help/pauza-proekta.md new file mode 100644 index 00000000..1113ec12 --- /dev/null +++ b/app/resources/help/pauza-proekta.md @@ -0,0 +1,37 @@ +--- +title: Пауза и запуск проекта +tour: pause-project +topics: пауза, приостановить, возобновить, возобновить проект, запустить проект обратно, включить проект обратно, снять с паузы, запустить проект, остановить приём, на паузе, не запущен, хвост заявок после паузы, статусы проекта, собирает заявки, готовим к запуску, поставил на паузу вечером, спишутся ли деньги после паузы, пауза и списание +--- + +Приём заявок по проекту можно остановить и снова включить в любой момент: +кнопка «Приостановить» / «Возобновить» — в меню карточки проекта (три точки), +в панели деталей проекта, а в «Конкурентном поле» — у самого источника. + +**Пауза действует сразу.** Но уже заказанные заявки ещё дойдут — обычно +в течение суток. Это нормально: их привезут и спишут по обычной цене +(подробнее — в статье про правило 18:00). + +Отсюда честный ответ на частый вопрос: **«поставил паузу вечером — деньги +всё равно спишутся?»** — да, за те заявки, которые уже попали в заказ, спишутся. +Новых заказов после паузы не будет, но «хвост» дойдёт и будет оплачен. + +**Возобновление** работает не мгновенно: новые заявки пойдут со следующего дня +(если включили до 18:00 МСК) или через день (если после). И ещё: если баланса +не хватает на дневной заказ, проект **останется на паузе** — портал покажет, +сколько не хватает. + +Что означают плашки на карточке проекта: + +- **«Собирает заявки»** (зелёная) — всё работает. +- **«Готовим к запуску»** (жёлтая) — проект принят, идёт подготовка. +- **«На паузе»** — вы остановили его сами. +- **«Не запущен — не хватает баланса»** — проект создан, но стартовать не смог: + денег не хватило на его дневной заказ. +- **«Приостановлен — не хватает баланса»** — работал, но деньги кончились. + Пополните баланс — приём включится сам. +- **«Ошибка — напишите в поддержку»** (красная) — редкий случай, напишите нам. + +Приостановить или запустить сразу несколько проектов можно массовым действием: +отметьте их галочками — внизу появится панель с кнопками «Приостановить» +и «Возобновить». diff --git a/app/resources/help/podderzhka.md b/app/resources/help/podderzhka.md new file mode 100644 index 00000000..6042945c --- /dev/null +++ b/app/resources/help/podderzhka.md @@ -0,0 +1,31 @@ +--- +title: Поддержка — как с нами связаться +tour: support +topics: поддержка, помощь, написать в поддержку, чат, почта, support, оставить заявку, специалист, связаться, вопрос по списанию, жалоба, спорная заявка, вернуть деньги, когда работает поддержка, режим работы, часы работы, круглосуточно, 24/7, есть ли телефон поддержки, куда писать если сломалось, сайт тормозит, не открывается, зависает, глючит, ошибка на экране, что-то сломалось, кнопка не работает +--- + +**Поддержка работает круглосуточно, 24/7** — пишите в любое время, в том числе ночью +и в выходные. + +**Телефона поддержки у нас нет** — общаемся в чате, письмом и через форму. Если вопрос +требует разговора, оставьте свой номер: специалист перезвонит сам. + +Три способа связаться: + +- **Чат** — кнопка в правом нижнем углу портала, на любой странице. Здесь + отвечает бот, а если вопрос не для него — он попросит ваш телефон и передаст + контакт живому специалисту. +- **Форма «Оставить заявку»** — раздел «Помощь»: имя, контакт (телефон или + почта) и сообщение. Мы свяжемся по указанному контакту. +- **Почта** — `support@liderra.ru`. + +В разделе «Помощь» есть ещё FAQ — короткие ответы на частые вопросы. + +**Что обязательно разбирает живой специалист, а не бот:** возвраты и спорные +списания, вопросы по договору и документам, всё, что касается качества +конкретных заявок, и любые нестандартные ситуации. Такие вопросы решаются +индивидуально — просто опишите ситуацию и приложите номер сделки, если он есть. + +Списания в портале прозрачны: «Биллинг» → «Списания» показывает каждую заявку +отдельной строкой (дата, номер сделки, цена) и выгружается в файл — это удобно +приложить к обращению. diff --git a/app/resources/help/pravilo-18-00.md b/app/resources/help/pravilo-18-00.md new file mode 100644 index 00000000..8d93ee97 --- /dev/null +++ b/app/resources/help/pravilo-18-00.md @@ -0,0 +1,41 @@ +--- +title: Правило 18:00 МСК — когда изменения вступают в силу +tour: rule-18 +topics: 18:00, вступает в силу, завтра, послезавтра, когда начнёт работать, изменил лимит ничего не изменилось, хвост, заявки после паузы, отсечка, заморозка изменений, почему изменения не сразу, когда пойдут заявки, через сколько придёт выгрузка, когда начнётся выгрузка, когда остановится выгрузка, выгрузка по проекту, поставка заявок +--- + +Небольшая оговорка про слова: клиенты часто называют поток заявок «выгрузкой» +по проекту. Это одно и то же — заявки, которые приходят вам в «Сделки». +Когда они начнутся, когда прекратятся и почему не сразу — как раз про это статья. + +**Важно: 18:00 — это НЕ время, когда заявки перестают приходить.** Это граница, после +которой ваши изменения (лимит, пауза, новый проект) начинают действовать на день позже. +Сами заявки приходят потоком в течение суток — «часа отключения» у них нет. + +Заказ заявок на завтрашний день Лидерра фиксирует **вечером текущего дня**. +Поэтому у изменений есть граница — **18:00 по Москве**: + +- Изменили **до 18:00 МСК** — работает **с завтрашнего дня**. +- Изменили **после 18:00 МСК** — работает **с послезавтра**. + +Это касается всего, что определяет завтрашнюю поставку: лимит, регионы, дни +приёма, источник, а также создание нового проекта и его запуск. + +**Пауза — исключение: она срабатывает сразу.** Новые сборы по проекту +прекращаются немедленно. Но то, что уже попало в вечерний заказ, ещё дойдёт — +обычно в течение следующих суток. Это «хвост», и это не сбой: заявки уже +заказаны, их привезут и спишут по обычной цене. + +Из того же правила растут два ограничения, которые часто удивляют: + +- **Проект нельзя удалить или сменить ему источник**, пока может прийти хвост + уже заказанных заявок. Портал прямо называет дату, после которой это станет + можно. Хотите быстрее — сначала пауза, потом ждём названную дату. +- **Изменение лимита «на сейчас» не ускорит сегодняшний день** — сегодня + приедет то, что было заказано вчера вечером. + +Ночью суточные счётчики обнуляются, и с утра у каждого проекта снова открыт +его дневной лимит. + +Если вы поменяли что-то поздно вечером, портал всё равно донесёт правку +в работу сразу после полуночи — ждать целые сутки лишний раз не придётся. diff --git a/app/resources/help/predlozheniya.md b/app/resources/help/predlozheniya.md new file mode 100644 index 00000000..a271d6b6 --- /dev/null +++ b/app/resources/help/predlozheniya.md @@ -0,0 +1,50 @@ +--- +title: Предложения — черновик находок и актуализация +tour: proposals +topics: предложения, черновик, разобрать предложения, перенести в поле, что проверить перед переносом, как правильно сформировать поле, проверить ссылки, новые находки, на актуализацию, новый сайт конкурента, заменить сайт, добавить сайт, отказаться, ранее удалённые, вернуть +--- + +«Предложения» — это черновик: всё, что Лидерра нашла, но вы ещё не разобрали. +Из предложений ничего не попадает в работу само — решаете вы. + +**Главное правило: перед переносом в поле проверьте карточку ссылками.** Откройте +сайт и карточку в справочнике (2ГИС, Яндекс.Карты) и убедитесь: это действительно +ваш конкурент, он работает в вашем городе и по вашей теме, а адрес — живой, +а не заброшенный. В поле берите только проверенных. + +Почему это важно: **поле — основа всей дальнейшей работы.** По каждому конкуренту +из поля Лидерра дальше ищет источники, а изучение конкурента стоит денег ({{цена_изучения}}). +Если в поле попала чужая или мёртвая карточка, вы заплатите за поиск источников, +которые вам не нужны, а проекты по ним приведут не тех клиентов. Полминуты +на проверку ссылки экономят и деньги, и качество заявок. + +Экран открывается вкладкой «Предложения» на «Конкурентном поле». Внутри — +три группы: + +**🆕 Новые находки** — обычные кандидаты. Кнопка «В поле →» переносит одного, +галочки + «Перенести выбранных в поле →» — сразу нескольких. Ненужных удаляйте. + +**🔁 На актуализацию** — особый случай: у фирмы, которая уже работает у вас +в поле, Лидерра нашла **новый сайт** (старый мог «умереть»). Портал показывает +текущие адреса и найденный новый — и просит решить: + +- **«Добавить»** — старый сайт продолжает работать, новый встаёт рядом. +- **«Заменить»** — старый уходит в архив, работаем по новому. +- **«Отказаться»** — находка больше не всплывёт, пока не появится реально + другой адрес. + +Перед решением проверьте адреса ссылками — портал об этом прямо предупреждает. +После «Добавить» или «Заменить» откроется создание проекта по новому сайту. + +**♻️ Ранее удалённые** — те, кого вы когда-то удалили, а новый сбор нашёл снова. +Они не возвращаются в работу молча: лежат отдельно, с пометкой. Можно вернуть, +можно убрать всю группу разом кнопкой «Удалить всех». + +Здесь же — своя кнопка **«🧹 Найти и объединить дубли»**: она чистит двойников +**только внутри предложений** (в «Поле» такая же кнопка сравнивает поле +с предложениями — это разные проверки, см. статью про дубли). + +Удаление предложения — мягкое: всё можно вернуть из «недавно удалённых». +Если у удаляемой строки есть «соседи», найденные в том же месте, портал +предложит удалить их заодно — галочками, по умолчанию отмечена только та, +по которой вы нажали. diff --git a/app/resources/help/proekt-iz-istochnika.md b/app/resources/help/proekt-iz-istochnika.md new file mode 100644 index 00000000..579edc36 --- /dev/null +++ b/app/resources/help/proekt-iz-istochnika.md @@ -0,0 +1,43 @@ +--- +title: Проект из найденного источника — запустить конкурента в работу +tour: project-from-source +topics: проект из найденного источника, из найденного источника, по найденному источнику, нашёл источник что дальше, нашел источник что делать, что делать с найденным источником, проект по конкуренту, запустить источник из поля, источники в работе, приостановить источник, лимит источника, регионы источника, запуск источника бесплатно +--- + +Когда у конкурента найдены источники, из любого можно в один клик сделать свой +проект — и заявки по этому источнику пойдут к вам. + +В карточке конкурента, вкладка «Источники в работе», у нужного источника +нажмите **«Создать проект»**. В окне заполняются три вещи: + +- **Регионы** — где собирать заявки. Либо галочка «Вся РФ (все регионы)» — + тогда конкретные регионы не нужны, проект работает по всей стране. +- **Лимит заявок в день** — сколько вы готовы принимать и оплачивать. Менять + можно в любой момент. Если лимит превысит баланс, проект встанет на паузу, + пока не пополните счёт. +- **Дни недели приёма** — есть быстрые кнопки «Будни» и «Все дни». + +Кнопки: «Создать» (проект появится, но не запустится) или «Создать и запустить». + +**Создание проекта — бесплатно.** Деньги списываются только за полученные +заявки, по обычным тарифным ступеням. + +Если это ваш **самый первый проект**, портал попросит короткие реквизиты +(тип лица, контактное имя, телефон, ИНН для ИП и юрлиц) — форма всплывёт +поверх, ничего из введённого не потеряется, после сохранения проект создастся +сам. Полные банковские реквизиты сейчас не нужны — они понадобятся, только +если будете платить по счёту. + +Дальше у источника появляются кнопки **«Приостановить» / «Возобновить»** +и **«Настройки проекта»** (лимит, регион, дни). Правки лимита, регионов и дней +вступают в силу со следующего дня: внесённые до 18:00 МСК — завтра, после +18:00 — послезавтра. **Пауза действует сразу.** + +Заявки из таких проектов приходят в «Сделки» — там в колонках «Конкурент» +и «Источник» видно, откуда пришла каждая. + +**Выбирая источник, помните: мы отдадим всё, что через него прошло.** Если конкурент +многопрофильный (скажем, клиника, где кроме стоматолога есть кардиолог и хирург), к вам +придут люди по всем его темам, а не только по вашей. Поэтому узкий конкурент почти всегда +лучше крупного, и первые дни стоит начать с небольшого лимита и посмотреть, кто приходит. +Подробнее — «Качество источника». diff --git a/app/resources/help/profil-i-rekvizity.md b/app/resources/help/profil-i-rekvizity.md new file mode 100644 index 00000000..ca50cf8b --- /dev/null +++ b/app/resources/help/profil-i-rekvizity.md @@ -0,0 +1,24 @@ +--- +title: Профиль и реквизиты компании +tour: requisites +topics: профиль, имя, фамилия, телефон, email, поменять почту, часовой пояс, реквизиты, инн, найти по инн, кпп, огрн, банк, бик, расчётный счёт, физлицо, ип, юрлицо, готово к оплате +--- + +**Профиль** (Настройки → Профиль): имя, фамилия, телефон, часовой пояс. +Email тут только показывается — **сменить почту можно лишь через поддержку**: +она связана с входом в аккаунт. + +**Реквизиты** (Настройки → Реквизиты) нужны в двух случаях. + +**Короткие — чтобы создать первый проект.** Это тип лица (Физическое лицо / ИП / +Юридическое лицо), контактное имя, контактный телефон и ИНН (для ИП и юрлиц). +Пара минут. Для ИП и юрлиц есть кнопка **«Найти по ИНН»** — портал сам подтянет +название, КПП, ОГРН и адрес. + +**Полные — чтобы платить по счёту.** Блок «Реквизиты для оплаты»: КПП (у юрлиц), +ОГРН/ОГРНИП, юридический адрес, банк, БИК, расчётный и корреспондентский счёт. +Заполнять сразу не обязательно — портал так и пишет: «Можно заполнить позже». +Понадобятся, когда будете выставлять счёт. Статус видно по чипу: «Готово +к оплате» или «Не заполнено». + +У физического лица блока банковских реквизитов нет вовсе. diff --git a/app/resources/help/registraciya-i-vhod.md b/app/resources/help/registraciya-i-vhod.md new file mode 100644 index 00000000..c691bb60 --- /dev/null +++ b/app/resources/help/registraciya-i-vhod.md @@ -0,0 +1,28 @@ +--- +title: Регистрация и вход +tour: start +topics: регистрация, зарегистрироваться, вход, войти, подтверждение почты, код из письма, не приходит код, забыл пароль, восстановить пароль, оферта, согласие на обработку данных, подарок при регистрации +--- + +**Регистрация:** почта и пароль (не короче 8 символов), согласие с офертой +и с политикой обработки персональных данных. После этого на почту придёт +**6-значный код** — введите его на странице подтверждения. Код можно выслать +повторно, кнопка станет активна через 30 секунд. + +Если письмо не пришло — проверьте папку «Спам». + +**Подарок новичку:** при регистрации мы кладём на баланс **1000 ₽** — их сразу +можно тратить на первые заявки. + +**Вход:** почта и пароль. Если включена двухфакторная защита, портал попросит +6-значный код из приложения (или резервный код). + +**Забыли пароль:** ссылка «Забыли пароль?» на странице входа. Придёт письмо +со ссылкой, она действует 60 минут. Новый пароль — **не короче 10 символов**. + +**Не пускает:** после 5 неудачных попыток за 15 минут вход по этой почте +временно блокируется, портал скажет, через сколько пробовать снова. Это защита +от подбора пароля, а не блокировка аккаунта. + +Сменить саму почту в кабинете нельзя — она связана с входом; напишите +в поддержку, поможем. diff --git a/app/resources/help/sbor-konkurentov.md b/app/resources/help/sbor-konkurentov.md new file mode 100644 index 00000000..92427c06 --- /dev/null +++ b/app/resources/help/sbor-konkurentov.md @@ -0,0 +1,38 @@ +--- +title: Собрать конкурентов (шаг 1) — как заполнить и сколько стоит +tour: collect-competitors +topics: ниша, что писать в поле ниша, чем вы занимаетесь, поле ниша, собрать конкурентов, подбор конкурентов, найти конкурентов, 300 рублей, цена подбора, примеры конкурентов, регион поиска, федеральные конкуренты, идёт подбор, пустой результат, повторный подбор +--- + +Кнопка «✨ Собрать конкурентов для меня» — на экране «Конкурентное поле» и на +экране «Предложения». Лидерра соберёт список похожих на вас фирм; он ляжет +в «Предложения», а вы сами решите, кого взять в поле. + +В окне подбора нужно заполнить: + +- **Чем вы занимаетесь** — простыми словами, своё направление. От этого зависит, + кого найдём; чем понятнее опишете, тем точнее результат. +- **Регион поиска** — один регион за один подбор. +- **Ваш сайт** — чтобы не предложить вас самих (не обязательно, но лучше указать). +- **Примеры ваших конкурентов — минимум 2** (лучше 2–5): у каждого сайт **или** + ссылка на 2ГИС / Яндекс.Карты. Чем больше примеров — тем точнее подбор. +- **Включать федеральных конкурентов** — да или нет (по умолчанию да). + +**Цена — {{цена_подбора}} за подбор** (точная цена всегда написана прямо в окне). Деньги +списываются **только если что-то нашли**: пустой результат — бесплатно. + +Пока идёт подбор, окно показывает «Идёт подбор…» — вкладку можно закрыть, +результат сохранится. Запускать повторно, пока идёт текущий подбор, не нужно — +портал скажет «Подбор уже идёт — дождитесь результата». + +Найденное появится в «Предложениях», и на экране «Поле» будет жёлтая плашка: +«💡 Лидерра подготовила N в предложениях — вы ещё не разобрали». + +**Разбирайте находки внимательно.** Прежде чем перенести конкурента в поле, +откройте его сайт и карточку справочника по ссылкам: тот ли это бизнес, ваш ли +город, живой ли адрес. ИИ ошибается — решение всегда за вами. Чистое поле — +основа всего дальнейшего: по каждому конкуренту из поля мы ищем источники +({{цена_изучения}} за изучение), и по грязной карточке вы заплатите за ненужный результат. + +Можно и **без ИИ, вручную и бесплатно** — кнопка «+ Добавить вручную» +(см. отдельную статью про ручное добавление). diff --git a/app/resources/help/sdelki.md b/app/resources/help/sdelki.md new file mode 100644 index 00000000..6a935ef7 --- /dev/null +++ b/app/resources/help/sdelki.md @@ -0,0 +1,56 @@ +--- +title: Сделки — где заявки и что с ними делать +tour: deals +topics: сделки, что такое сделка, что значит сделка, раздел сделки, заявки, где заявки, где смотреть заявки, смотреть заявки, таблица сделок, колонки, фильтры, поиск по телефону, найти сделку по номеру, карточка сделки, комментарий, активность, история заявки, стоимость лида, город, конкурент, позвонить, удалить сделку, восстановить сделку, из какого проекта заявка, где посмотреть из какого проекта пришла заявка, откуда пришла заявка +--- + +Все полученные заявки лежат в разделе **«Сделки»**. Заявки приходят +автоматически — вручную их тут не создают. + +**Колонки таблицы:** Телефон · Конкурент · Источник · Город · Статус · +Комментарий · Поставлен (дата и время). «Конкурент» и «Источник» заполнены, +если заявка пришла по источнику, найденному в «Конкурентном поле» — по ним +можно кликнуть и провалиться в это поле. + +**Фильтры:** поиск по телефону, статус, проект, город, плюс «Диапазон поставки» +(даты с/по). Показывать по 10, 20 или 50 на странице. + +**Карточка заявки** открывается кликом по строке — справа выезжает панель: + +- телефон (кликабельный — можно позвонить в один тап с телефона); +- параметры: проект, **стоимость заявки в ₽**, тип (сайт/звонок/СМС), источник, + город; +- **статус** — цветной чип, по нему кликаете и выбираете новый; +- **комментарий** — своё поле, до 5000 символов, кнопка «Сохранить»; +- **«Активность»** — лента: когда заявка создана, кто менял статус, когда + смотрели, когда списались деньги. + +Менять в карточке можно статус и комментарий. Телефон, дату и источник — +нельзя: это данные самой заявки. + +Про колонку **«Город»** одна честная оговорка: обычно это город самого человека, +но в редком случае, когда по его точному городу подходящих заявок не нашлось +и сработал запасной вариант, там показывается регион вашего проекта. Поэтому +не стройте по этой колонке серьёзных выводов о географии клиентов — точнее +спросить у самого человека при звонке. + +**Найти конкретную заявку** проще всего поиском по телефону — поле поиска над таблицей. +Можно вводить как удобно: с «+7», с восьмёрки, со скобками. + +**Из какого проекта пришла заявка** видно в колонке **«Источник»** (там название проекта +и тип канала — сайт, звонки или СМС) и в карточке заявки, в блоке параметров. + +**Удалить сделку нельзя** — и это сделано специально: история заявок и списаний должна +сходиться. Если заявка не пригодилась, поставьте ей статус **«Не реализовано»** — она +уйдёт из работы, но останется в истории и в отчётах. Недозвон сам по себе браком не считается +(об этом — статья «Человек не берёт трубку»). Если же заявка вызывает вопросы, напишите +на `support@liderra.ru` с номером сделки — специалист разберёт случай индивидуально. + +Успешную заявку отмечайте статусом **«Сделка»** («клиент купил»). Все пять статусов — +в статье «Статусы сделок»; выдуманных вроде «Успешная» или «Отказ» в портале нет. + +**Массово:** отметьте несколько заявок галочками — появится полоса «Выбрано: N» +с кнопкой «Сменить статус». + +На телефоне таблица превращается в карточки: телефон, статус, город, источник, +дата. Клик — та же панель заявки, на весь экран. diff --git a/app/resources/help/smena-istochnika.md b/app/resources/help/smena-istochnika.md new file mode 100644 index 00000000..a11a22fc --- /dev/null +++ b/app/resources/help/smena-istochnika.md @@ -0,0 +1,38 @@ +--- +title: Сменить источник проекта или удалить проект +tour: change-source +topics: сменить источник, поменять сайт, поменять сайт в созданном проекте, изменить сайт проекта, поменять номер в проекте, редактировать проект, поменять номер, другой источник, смена источника, заявки не потеряются, старый источник, удалить проект, нельзя удалить, есть сделки, заблокировано удаление, тип источника не меняется, хвост +--- + +**Сменить источник** (сайт, номер или отправителя СМС) можно в карточке проекта: +меню «Редактировать» или панель деталей проекта. **Менять можно значение источника** — +сайт на другой сайт, номер на другой номер: заводить новый проект ради этого не нужно. +Не меняется только **тип** (Сайт / Звонок / СМС) — он задаётся один раз при создании. +Нужен другой тип — заведите отдельный проект. + +Если по проекту уже идёт сбор на завтра, портал попросит подтверждение: +«Мы уже ведём сбор на завтра. Заявки по старому источнику придут завтра +в любом случае. Послезавтра — уже по новому». Так и будет: сутки идёт «хвост» +по старому источнику, дальше — по новому. Заявки не теряются и не задваиваются, +приходят как обычно и оплачиваются как обычно. + +Просто изменить лимит, регионы или дни — подтверждения не требует: показывается +спокойная плашка, что новые настройки вступят в силу со следующего дня +(до 18:00 МСК — завтра, после 18:00 — послезавтра). + +**Удалить проект** можно из меню карточки или панели деталей. Удаление +необратимо, и портал его заблокирует в двух случаях: + +- **По проекту есть сделки** — удалить нельзя вообще. Поставьте приём на паузу: + проект перестанет собирать заявки, а история сделок сохранится. +- **Уже начат сбор на завтра** — удаление откроется позже, портал назовёт точную + дату. Хотите быстрее — сначала пауза, потом ждём эту дату. + +Удаление нескольких проектов разом — массовым действием: отметьте галочками, +внизу появится панель. Проекты со сделками будут пропущены, портал напишет, +сколько и почему. + +**Смена источника — обычный рабочий инструмент, а не крайняя мера.** Источник со временем +меняется без нашего участия: конкурент может расширить профиль или поменять рекламу, и поток +поедет не в ту сторону. Видите, что приходят «не ваши» люди, — меняйте источник или ставьте +паузу, списания прекратятся сразу. Подробнее — «Качество источника». diff --git a/app/resources/help/sozdat-proekt.md b/app/resources/help/sozdat-proekt.md new file mode 100644 index 00000000..6af5c6e4 --- /dev/null +++ b/app/resources/help/sozdat-proekt.md @@ -0,0 +1,56 @@ +--- +title: Создать проект — пошагово, все поля формы +tour: create-project +topics: создать проект, новый проект, форма проекта, тип источника, сайт, звонок, смс, смс проект, ключевое слово, отправитель, название проекта, реквизиты для первого проекта, сколько проектов можно, несколько сайтов в проекте, проект не запущен, формат номера телефона, в каком формате номер, формат сайта, в каком формате писать сайт, как писать домен, можно ли 8800, номер 8-800, регионы, вся россия, выбрать город, дни приёма, выходные, я не работаю в выходные, не работаю по субботам, только по будням, часы приёма заявок, с 10 до 19, идут ли заявки в выходные, ночью +--- + +Проект — это ваш заказ на поток заявок: вы говорите, **откуда** их брать +и **сколько** в день. Кнопка «Создать проект» — справа сверху в разделе +«Проекты». + +**Если проект первый**, портал сначала попросит короткие реквизиты: тип лица +(физлицо / ИП / юрлицо), контактное имя, телефон и ИНН (для ИП и юрлиц). Это +пара минут, полные банковские реквизиты сейчас не нужны — они понадобятся, +только если будете платить по счёту. + +Дальше — форма проекта: + +1. **Откуда собирать заявки** — тип источника: **Сайт** (заявки с сайтов вашей + ниши), **Звонок** (звонки на номер) или **СМС** (по отправителю и, если нужно, + ключевому слову). **Тип после создания не меняется** — если нужен другой, + заводите отдельный проект. +2. **Значение источника — в каком формате писать.** + - **Сайт**: только домен, вида `okna-moskva.ru`. Без `http://`, без `www` + и без пути после адреса. Если ввести иначе, портал так и скажет. + - **Телефон**: `79161234567` — семёрка и десять цифр после неё. Вводить можно + как удобно: с `+7`, с восьмёрки, со скобками и пробелами — портал приведёт + сам. Номер 8-800 тоже подходит. + - **СМС**: имя отправителя (до 11 символов) и, если нужно, ключевое слово. + + Указывайте источник **в вашей нише**: по нему мы соберём вам таких же клиентов. +3. **Название проекта** — любое, но не повторяющееся: два проекта с одним именем + портал не заведёт. +4. **Лимит заявок в день** — от 1 до 10 000, по умолчанию 50. +5. **Регионы** — конкретные субъекты РФ (области, края, республики) или галочка + **«Вся РФ (все регионы)»** — да, всю страну поставить можно. Отдельный **город** + выбрать нельзя: география задаётся субъектами. +6. **Дни недели приёма** — заявки идут **только в отмеченные дни**. Есть быстрые кнопки + «Будни» и «Все дни». Хотите заявки в субботу и воскресенье — отметьте выходные + (или нажмите «Все дни»); оставите только будни — в выходные потока не будет. + +**Один проект — один источник.** Несколько сайтов в одном проекте указать нельзя: +на каждый сайт (или номер) заводится свой проект. Так понятно, откуда какие заявки +и сколько стоит каждый источник. + +Нажимаете «Создать». Если баланса хватает — проект запускается сразу. Если нет — +он создастся, но **не запустится**: портал покажет окно «Проект не запущен» +и скажет точно, на сколько пополнить или предложит уменьшить объём. + +Первые заявки пойдут **не сегодня**: создали до 18:00 МСК — со следующего дня, +после 18:00 — через день. Точную дату портал пишет прямо в форме. + +Два проекта с **одинаковым источником** завести нельзя — портал скажет, +в каком проекте этот источник уже используется. + +**Сколько проектов можно завести — не ограничено.** Ограничивает только баланс: +денег должно хватать на суммарный дневной заказ всех активных проектов. diff --git a/app/resources/help/spisaniya.md b/app/resources/help/spisaniya.md new file mode 100644 index 00000000..c9653b18 --- /dev/null +++ b/app/resources/help/spisaniya.md @@ -0,0 +1,39 @@ +--- +title: Списания — за что сняли деньги и как проверить +tour: charges +topics: списание, за что списали, почему списали деньги, история операций, транзакции, проверить списания, выгрузить файл списаний, ступень, цена заявки, списание за автоподбор, некачественная заявка, списали а заявок нет, деньги ушли без заявок, минус, баланс в минусе, долг, задолженность +--- + +**В минус аккаунт не уходит никогда.** Долга, задолженности и «баланс ушёл в минус» +у нас не бывает: если денег не хватает, приём просто встаёт на паузу. + +**«Деньги списали, а заявок нет» — так тоже не бывает.** Списание происходит ровно +в момент прихода заявки, одно к одному: каждая строка в «Списаниях» ссылается на +конкретную сделку. Видите списания без заявок — это повод для разбора: напишите +в поддержку, приложив дату и сумму. + +Каждое списание видно и проверяемо. Раздел **«Биллинг»** → вкладка +**«Списания»**: строка на каждую оплаченную заявку — дата, номер сделки +(кликабельный — откроет саму заявку), ступень тарифа и цена. Период +переключается: текущий месяц, прошлый, последние 90 дней. Кнопка +**«Скачать CSV»** выгрузит всё это файлом. + +Отдельно на вкладке «Обзор» есть «История транзакций» — там и пополнения, +и списания вперемешку, с фильтром. + +**За что вообще списывают деньги:** + +- **За каждую полученную заявку** — в момент, когда она пришла к вам, по цене + вашей текущей ступени. +- **За услуги «Конкурентного поля»** — подбор конкурентов и сбор источников, + и только при результате (кроме повторного сбора — он платный всегда, + портал об этом предупреждает заранее). + +**За что деньги не списываются:** создание проектов, пауза и запуск, ручное +добавление конкурентов и источников, объединение дублей, перенос вашей истории +через «Импорт данных», сам факт наличия аккаунта. + +Если по конкретной заявке есть вопрос — скажем, номер оказался нерабочим — +это разбирает живой специалист поддержки, индивидуально. Напишите нам в чат +или на почту, приложив номер сделки: списания прозрачны, найти и разобрать +конкретный случай легко. diff --git a/app/resources/help/srednie-pokazateli.md b/app/resources/help/srednie-pokazateli.md new file mode 100644 index 00000000..29700046 --- /dev/null +++ b/app/resources/help/srednie-pokazateli.md @@ -0,0 +1,31 @@ +--- +title: Средние показатели — сколько из ста номеров отвечают +tour: dashboard +topics: конверсия, какая конверсия, сколько ответят, сколько дозвонов, эффективность, отдача, окупаемость, окупятся ли лиды, а если не окупятся, риск, сколько я заработаю, гарантии результата, статистика, средние цифры, кейсы, отзывы, результаты клиентов +--- + +**«Сколько у вас клиентов?», «Есть отзывы или кейсы?»** — имена и результаты наших клиентов +мы **не раскрываем**: это их коммерческая тайна, и точно так же мы не раскрываем никому вас. +Это и есть ответ на такие вопросы — публичных кейсов и отзывов у нас нет намеренно. + +Зато **средние наблюдения по сервису** назвать можем: + +- из **100 полученных номеров примерно 8–12 человек готовы разговаривать** по телефону; +- ещё **5–7 человек откликаются на СМС** — сами приходят к вам после сообщения + с предложением. + +Отсюда и наша рекомендация: **сначала СМС с предложением, через час — звонок**. +Тогда работают оба канала сразу, а звонок попадает уже на подготовленного человека +(подробно — в статье «Как работать с полученным номером»). + +**«А если не окупится?»** Такой риск есть, и мы о нём говорим честно. Подстраховаться +можно так: начните с маленького дневного лимита, потратьте подарочную 1000 ₽, посмотрите +на свою отдачу в Дашборде — и только потом наращивайте объём. Абонплаты нет: не пошло — +поставили проект на паузу, деньги остаются на балансе (а остаток можно вернуть). + +**Это средние цифры, а не обещание.** Результат сильно зависит от вашего предложения, +скорости выхода на связь и того, как разговаривает ваш менеджер. Гарантировать продажи +мы не можем и не будем — мы отвечаем за то, что контакт живой и с актуальным интересом. + +Свою собственную отдачу удобно смотреть на **Дашборде**: сколько заявок пришло, сколько +дошло до статуса «Сделка», сколько стоила заявка. diff --git a/app/resources/help/statusy-sdelok.md b/app/resources/help/statusy-sdelok.md new file mode 100644 index 00000000..107dfd77 --- /dev/null +++ b/app/resources/help/statusy-sdelok.md @@ -0,0 +1,30 @@ +--- +title: Статусы сделок — все пять и как менять +tour: deal-status +topics: статусы, воронка, какие статусы есть, перечислите статусы, список статусов, что за статусы, новая сделка, просмотрено, в работе, сделка, не реализовано, поменять статус, этапы, сменить статус массово, что значит статус +--- + +В воронке ровно **пять статусов**: + +1. **Новая сделка** — заявка только пришла, вы её ещё не трогали. +2. **Просмотрено** — вы её открыли и посмотрели. +3. **В работе** — идёт работа: дозваниваетесь, ведёте переговоры. +4. **Сделка** — клиент купил, заявка сработала. +5. **Не реализовано** — не сложилось. + +Других статусов нет. Если вы переносите историю из старой CRM, где статусов +было больше (например «Недозвон», «Переговоры», «Ожидаем оплаты»), портал +сам сведёт их к этим пяти при импорте. + +**Как поменять статус:** + +- **У одной заявки** — откройте её в «Сделках» и нажмите на цветной чип + статуса: выпадет список из пяти. +- **Сразу у нескольких** — отметьте галочками в «Сделках», в появившейся полосе + нажмите «Сменить статус». +- **Перетаскиванием** — в «Канбане» карточку можно перетащить мышью в другую + колонку, статус сменится сам. + +Статусы видны везде одинаково: цветной чип в таблице, колонка в «Канбане» +и этап в воронке на «Дашборде» — это одни и те же пять этапов, просто в разных +видах. diff --git a/app/resources/help/tarify.md b/app/resources/help/tarify.md new file mode 100644 index 00000000..3bca0396 --- /dev/null +++ b/app/resources/help/tarify.md @@ -0,0 +1,43 @@ +--- +title: Тарифы — сколько стоит заявка +tour: tariffs +topics: тариф, тарифы, где посмотреть ступень, какая у меня ступень, скидка за объём, скидка за объем, дешевеет ли, какие тарифы, цена, сколько стоит заявка, стоимость одной заявки, цена за лид, почему так дорого, дорого, высокая цена, ступени, 7 ступеней, скидка за объём, чем больше тем дешевле, абонплата, абонентская плата, есть ли абонентская плата, ежемесячная плата, подписка, комиссия, вы здесь, цена в следующем месяце +--- + +**Коротко: заявка стоит {{вилка}}.** Так и отвечаем на вопрос «сколько стоит заявка» +или «от и до» — цифрой сразу, а не «смотрите в Биллинге». + +Абонентской платы нет. Вы платите **только за фактически полученные заявки** — +деньги списываются в момент, когда заявка приходит к вам в «Сделки». + +Цена зависит от объёма: **чем больше заявок за месяц — тем дешевле каждая +следующая.** Всего **7 ступеней**, они идут нарастающим итогом внутри +календарного месяца: первые заявки месяца — по цене первой ступени, дальше цена +опускается на следующую ступень, и так далее. В новом месяце счёт начинается +заново. + +Сейчас сетка такая: {{тарифы}}. + +Цена одинаковая для всех — личных сеток и индивидуальных условий нет. Если клиент +говорит, что видел другую цифру, — он видел свою ступень внутри той же общей сетки. + +**Как это считается — важно не перепутать.** Дешевеет не весь объём, а только следующие +заявки. Первые заявки месяца идут по цене первой ступени, следующие — по цене второй, +и так далее. Задним числом цена НЕ пересчитывается: получив сто первую заявку, вы не +начнёте платить меньше за первые сто. Поэтому неверно говорить «наберу объём — все заявки +станут дешевле»: дешевле станут только те, что придут после перехода на новую ступень. + +Всю сетку видно в разделе **«Биллинг»** — панель «Цены за лид (7 ступеней)». +Она сворачивается; разверните — увидите все ступени, а ваша текущая помечена +«вы здесь». Там же видно, сколько заявок осталось до следующей, более дешёвой +ступени. + +Скрытых комиссий нет: цена за заявку — это всё, что вы платите. Отдельно +оплачиваются только услуги «Конкурентного поля» (подбор конкурентов и сбор +источников) — и то лишь при результате. + +Точную цену вашей следующей заявки портал показывает прямо на плитке баланса: +«сейчас по X ₽ за заявку». + +Проверить каждое списание можно на вкладке «Списания» — там видно дату, номер +сделки, ступень и цену. diff --git a/app/resources/help/udalenie-akkaunta-i-dannyh.md b/app/resources/help/udalenie-akkaunta-i-dannyh.md new file mode 100644 index 00000000..d160fe7d --- /dev/null +++ b/app/resources/help/udalenie-akkaunta-i-dannyh.md @@ -0,0 +1,19 @@ +--- +title: Удалить аккаунт или свои персональные данные +tour: support +topics: удалить аккаунт, закрыть кабинет, удалить профиль, удалить мои данные, удалить данные из базы, отозвать согласие, персональные данные, 152-фз, право на удаление, перестать пользоваться +--- + +**Удалить аккаунт** и **удалить свои персональные данные** можно по письму +на `support@liderra.ru`. + +Что написать: с какой почты зарегистрирован кабинет, название компании и что именно +вы хотите — закрыть аккаунт или удалить данные. Мы подтвердим и всё сделаем. + +Пока заявка не подана, кабинет можно просто **не использовать**: абонентской платы нет, +без активных проектов деньги не тратятся, а неизрасходованный остаток остаётся на балансе +(его можно вернуть — см. статью «Вывод остатка с баланса»). + +Если вы **не наш клиент**, а человек, чей номер попал в систему, — тоже напишите +на `support@liderra.ru`. Такие обращения субъектов персональных данных мы обрабатываем +отдельно, как требует закон. diff --git a/app/resources/help/uvedomleniya.md b/app/resources/help/uvedomleniya.md new file mode 100644 index 00000000..6d687e9c --- /dev/null +++ b/app/resources/help/uvedomleniya.md @@ -0,0 +1,35 @@ +--- +title: Уведомления — что и куда приходит +tour: notifications +topics: уведомления, письма, оповещения, колокольчик, новый лид, низкий баланс, нулевой баланс, пополнение, новое устройство, промо, звук, отключить уведомления, куда приходят, придут ли смс, смс о новых сделках, уведомления в телеграм, пуш уведомления, сообщения на телефон +--- + +Уведомления настраиваются в **Настройки → Уведомления**. Там матрица: строки — +события, столбцы — каналы. Каналов **два**: **«В приложении»** (колокольчик +в верхней панели портала) и **«Email»** (на адрес из вашего профиля). + +Сразу честно: **СМС о новых заявках не приходят.** Уведомлений в Телеграм и пуш-сообщений +на телефон тоже пока нет — только колокольчик в портале и письма на почту. Чтобы не +пропускать заявки, включите «Новый лид» на канал Email, а портал держите открытым +во вкладке: там уведомление сопровождается звуком (его можно выключить). + +События, ровно семь: + +1. **Новый лид** — пришла новая заявка. +2. **Низкий баланс** — денег осталось меньше чем на 3 дня работы. +3. **Нулевой баланс** — заявки не принимаются, деньги кончились. +4. **Пополнение успешно** — платёж зачислен. +5. **Счёт оплачен** — по выставленному счёту прошла оплата. +6. **Новое устройство** — вход с незнакомого устройства или адреса. +7. **Анонсы и промо** — новости платформы и новые функции. + +Галочками включаете нужное сочетание «событие × канал», жмёте «Сохранить». +Отдельно есть переключатель **звука** для уведомлений внутри портала. + +Колокольчик в верхней панели показывает число непрочитанных; в выпадающем +списке — последние уведомления, клик по уведомлению о заявке открывает саму +заявку. Кнопка «Прочитать все» гасит счётчик. + +Письма о балансе приходят, даже если вы отключили лишнее: приостановка приёма — +это деньги, и мы предупреждаем о ней письмом, а через сутки и через трое суток +напоминаем ещё раз, если пауза затянулась. diff --git a/app/resources/help/vyvod-ostatka-i-rassrochka.md b/app/resources/help/vyvod-ostatka-i-rassrochka.md new file mode 100644 index 00000000..c41fc64f --- /dev/null +++ b/app/resources/help/vyvod-ostatka-i-rassrochka.md @@ -0,0 +1,21 @@ +--- +title: Вывод остатка с баланса, рассрочка и постоплата +tour: top-up-balance +topics: вывести деньги, вывести остаток обратно, вернуть остаток, вернуть деньги обратно, забрать деньги с баланса, возврат остатка, неизрасходованный остаток, рассрочка, постоплата, оплата потом, в долг, кредит, отсрочка платежа, предоплата +--- + +**Остаток с баланса можно вернуть.** Напишите на `support@liderra.ru` с указанием +компании и суммы — специалист оформит возврат неизрасходованного остатка на те же +реквизиты, с которых была оплата. Кнопки «вывести» в кабинете нет: возврат денег — +всегда живой разбор, а не автоматическая операция. + +**Рассрочки и постоплаты у нас нет.** Сервис работает по предоплате: вы пополняете +баланс, и с него списывается стоимость каждой полученной заявки. «Сначала лиды, потом +деньги» — так мы не работаем. + +Почему так: заявка тратит наш ресурс в момент передачи, и списание происходит ровно +тогда же. Зато и **абонентской платы нет** — не получили заявок, не потратили ни рубля, +деньги просто остаются на балансе. + +Если вы юрлицо и вам нужен счёт для бухгалтерии — это раздел «Биллинг» → «Пополнить +баланс» → «По счёту (для юрлиц)», закрывающие документы формируются автоматически. diff --git a/app/resources/help/zamena-zayavki.md b/app/resources/help/zamena-zayavki.md new file mode 100644 index 00000000..8c988399 --- /dev/null +++ b/app/resources/help/zamena-zayavki.md @@ -0,0 +1,38 @@ +--- +title: Человек не берёт трубку или заявка кажется неудачной — что делать +tour: support +topics: замена заявки, заменят ли заявку, некачественная заявка, нецелевая заявка, номер недоступен, не берёт трубку, не дозвонился, недозвон, не отвечает, автоответчик, брак, плохая заявка, вернут ли деньги, компенсация, спорная заявка, жалоба на заявку +--- + +**Не дозвонились с первого раза — это обычное дело, а не брак.** Человек за рулём, +на встрече или просто не берёт незнакомый номер. Это нормальная часть работы с тёплыми +контактами: опускать руки после одного гудка не стоит. + +Что реально повышает результат: + +- **Сначала СМС, через час — звонок.** Человек успевает посмотреть ваше предложение, + и звонок уже не «холодный»: он понимает, кто вы и зачем. +- **Перезвоните позже или на следующий день,** в другое время суток. Один пропущенный + звонок — ещё не отказ. +- **Напишите в мессенджер** — многим так удобнее ответить. +- **Звоните быстро.** Чем меньше времени прошло с момента интереса, тем выше шанс. + +Заявка — это интерес человека, а не гарантированная продажа: результат зависит и от +скорости звонка, и от вашего разговора. Сколько людей в среднем выходят на разговор — +в статье «Средние показатели». + +**Спорная ситуация — напишите живому специалисту.** Если номер выглядит явно нерабочим +или заявка вызывает вопросы, отправьте письмо на `support@liderra.ru`: укажите номер +сделки (виден в «Сделках» и в разделе «Списания») и что именно вас смутило. Каждый такой +случай специалист разбирает **индивидуально** — бот здесь ничего не решает и заранее +ничего не обещает. + +Отдельно: **повторов не бывает** — номер, который мы уже передавали, не вернётся 30 дней +(статья «Повторы номеров»). Если вам кажется, что повтор всё же случился, это тоже повод +написать в поддержку. + +**Человек оказался не по вашей теме — это тоже не брак, а свойство источника.** Мы отдаём +всё, что через источник прошло, и не сортируем людей по темам: если источник шире вашей ниши +(например, многопрофильная клиника, а вы — только стоматология), часть заявок будет чужой. +Разбираться тут надо не с заявкой, а с источником: сузить, сменить или поставить паузу — +об этом статья «Качество источника». diff --git a/app/resources/js/api/admin.ts b/app/resources/js/api/admin.ts index c6e109f5..3d2f4b5a 100644 --- a/app/resources/js/api/admin.ts +++ b/app/resources/js/api/admin.ts @@ -312,10 +312,28 @@ export async function listSystemSettings(): Promise { return data.settings; } +export interface Yandex360BalanceStatus { + balance: number | null; + updated_at: string | null; +} + +/** Ручной баланс Яндекс 360 (почта): текущее значение + когда обновляли. */ +export async function getYandex360Balance(): Promise { + const { data } = await apiClient.get('/api/admin/settings/yandex360-balance'); + return data; +} + +/** Сохранить ручной баланс Яндекс 360 (null — очистить). Возвращает новый статус. */ +export async function setYandex360Balance(balance: number | null): Promise { + await ensureCsrfCookie(); + const { data } = await apiClient.put('/api/admin/settings/yandex360-balance', { balance }); + return data; +} + export interface UpdateSystemSettingPayload { value: string; reason: string; // ≥30 chars - admin_user_id: number; // на prod удалится + admin_user_id?: number; // опц.: id админа проставляет бэкенд из сессии (saas_admin auth); клиент не диктует } export interface UpdateSystemSettingResponse { diff --git a/app/resources/js/api/adminVisitors.ts b/app/resources/js/api/adminVisitors.ts new file mode 100644 index 00000000..b8031326 --- /dev/null +++ b/app/resources/js/api/adminVisitors.ts @@ -0,0 +1,65 @@ +import { apiClient } from './client'; + +/** + * SaaS-admin «Посетители» — свой счётчик (НЕ Метрика). + * Роботы сюда не попадают вовсе: они не исполняют JS страницы. + * Backend: AdminVisitorsController. Spec: + * docs/superpowers/specs/2026-07-13-visitors-analytics-design.md + */ +export type Period = 'today' | '7d' | '30d'; + +export interface Funnel { + period: string; + funnel: { + view: number; + alive: number; + cta_click: number; + register_open: number; + register_done: number; + login_done: number; + }; + vpn_or_hosting: number; +} + +export interface ChannelRow { + channel: string; + people: number; + cta_clicks: number; + registrations: number; + logins: number; +} + +export interface VisitRow { + id: string; + first_seen_at: string; + channel: string; + device: string; + city: string | null; + asn_org: string | null; + is_datacenter: boolean; + is_human: boolean; + ip: string | null; + tenant_id: number | null; + steps: string[]; +} + +export interface PortalActivity { + screens: Array<{ screen: string; opens: number; people: number }>; + clients: Array<{ name: string; subdomain: string; last_seen_at: string }>; +} + +export const getFunnel = (p: Period) => + apiClient.get('/api/admin/visitors/funnel', { params: { period: p } }).then((r) => r.data); + +export const getChannels = (p: Period) => + apiClient + .get<{ channels: ChannelRow[] }>('/api/admin/visitors/channels', { params: { period: p } }) + .then((r) => r.data.channels); + +export const getVisits = (p: Period) => + apiClient + .get<{ visits: VisitRow[] }>('/api/admin/visitors/visits', { params: { period: p } }) + .then((r) => r.data.visits); + +export const getPortalActivity = (p: Period) => + apiClient.get('/api/admin/visitors/portal', { params: { period: p } }).then((r) => r.data); diff --git a/app/resources/js/api/autopodbor.ts b/app/resources/js/api/autopodbor.ts new file mode 100644 index 00000000..5cd8566d --- /dev/null +++ b/app/resources/js/api/autopodbor.ts @@ -0,0 +1,502 @@ +import axios from 'axios'; +import { apiClient } from './client'; + +/** + * Адресные сообщения по коду ошибки автоподбора (бэкенд кладёт `{ error: 'code' }`). + * Общий `extractErrorMessage` читает `message`, поэтому для наших кодов нужен отдельный маппер — + * иначе клиент видит общий текст «Проверьте баланс» на ЛЮБУЮ ошибку. + */ +const AUTOPODBOR_ERROR_MESSAGES: Record = { + balance_insufficient: 'Не хватает денег на балансе — пополните счёт, чтобы запустить.', + requisites_required: 'Заполните реквизиты компании — без них проект не создать. Откройте раздел «Реквизиты» в настройках.', + run_in_flight: 'Подбор уже идёт — дождитесь результата, повторно запускать не нужно.', + name_or_site_required: 'Укажите название или сайт конкурента.', + has_active_project: 'Сначала остановите проект на этом источнике.', + has_active_projects: 'Сначала остановите проекты этого конкурента.', + manage_via_project: 'Смена адреса/номера источника — через «Сменить источник» в проекте.', +}; + +export function autopodborErrorMessage(error: unknown, fallback: string): string { + if (axios.isAxiosError(error)) { + const data = error.response?.data as + | { error?: string; message?: string; errors?: Record } + | undefined; + const code = data?.error; + if (code && AUTOPODBOR_ERROR_MESSAGES[code]) { + return AUTOPODBOR_ERROR_MESSAGES[code]; + } + // Нет известного кода — показываем КОНКРЕТНУЮ причину от бэкенда (валидация/бизнес-правило), + // а не общую фразу. Напр. «У вас уже есть проект с этим источником: «…»» при дубле источника. + const fieldError = data?.errors ? Object.values(data.errors)[0]?.[0] : undefined; + if (fieldError) { + return fieldError; + } + if (data?.message) { + return data.message; + } + } + return fallback; +} + +// ——— DTOs ——— + +export type RunKind = 'search' | 'study' | 'resolve'; +export type RunStatus = 'queued' | 'running' | 'done' | 'empty' | 'failed'; + +export interface RunDto { + id: number; + kind: RunKind; + status: RunStatus; + region_code: number | null; + params: Record; + price_rub_charged: string | null; + error_code: string | null; + competitors_count: number; + sources_count: number; + started_at: string | null; + finished_at: string | null; + created_at: string | null; + competitor_id: number | null; + /** Место в общей очереди (пока прогон ждёт запуска). null — если уже идёт/завершён. */ + queue_position: number | null; + /** Текущий этап работы (пока прогон идёт): «этап stage из total: label». null — если ждёт/завершён. */ + progress: RunProgress | null; + /** Итог прогона (для study: сколько новых источников и сколько ранее удалённых нашли снова). */ + result: RunResult | null; +} + +/** Этап живого прогона для показа клиенту. */ +export interface RunProgress { + stage: number; + total: number; + label: string; +} + +/** Итог сбора источников: новые + ранее удалённые (их id — для блока «вернуть?»). */ +export interface RunResult { + new_sources: number; + refound_deleted: number; + refound_deleted_ids: number[]; +} + +/** + * Честное сообщение клиенту по итогу сбора источников. Пусто (''), если итога нет (первый сбор + * покажет общий флеш). Формулировки без согласования числа с существительным — «источников — N». + */ +export function collectResultMessage(result: RunResult | null): string { + if (!result) return ''; + const { new_sources: added, refound_deleted: refound } = result; + if (added > 0 && refound > 0) { + return `Готово: новых источников — ${added}, ранее удалённых снова найдено — ${refound} (можно вернуть). Они в «Предложениях».`; + } + if (added > 0) { + return `Готово: новых источников — ${added}. Они в «Предложениях».`; + } + if (refound > 0) { + return `Новых источников нет. Ранее удалённых снова найдено — ${refound} (можно вернуть).`; + } + return 'Новых источников не нашлось — всё, что есть по этому конкуренту, вы уже видите.'; +} + +export type Box = 'proposal' | 'field' | 'archived'; +export type PhoneType = 'city' | 'mobile' | 'tollfree' | null; + +/** Элемент конкурента: подкомпания/ЖК холдинга со своими сайтами и карточками справочников. */ +export interface CompetitorElement { + name: string; + sites: string[]; + cards: string[]; +} + +export interface CompetitorDto { + id: number; + name: string; + description: string | null; + is_federal: boolean; + relevance_pct: number | null; + origin: 'auto' | 'manual' | 'resolve'; + box: Box; + site_url: string | null; + directory_urls: string[]; + elements: CompetitorElement[]; + studied_at: string | null; + study_run_id: number | null; + search_run_id: number | null; +} + +/** Одно место, где нашли источник (для кликабельного списка «где нашли»). */ +export interface WhereFound { + label: string; + url: string | null; +} + +export interface SourceDto { + id: number; + competitor_id: number; + signal_type: 'site' | 'call'; + identifier: string; + phone_kind: 'real' | 'substitute' | 'unverified' | 'uncertain' | null; + phone_type: PhoneType; + box: Box; + provenance_url: string | null; + provenance_label: string | null; + created_project_id: number | null; + existing_project_id?: number | null; + /** Полный список «где нашли» (места + ссылки); подтверждения = его длина. */ + where_found?: WhereFound[]; + confirmations?: number; + /** Адрес офиса/филиала, если источник привязан к конкретному адресу. */ + office?: string | null; +} + +/** Статус проекта, привязанного к источнику (для рабочего места «поле»). */ +export interface SourceProjectDto { + id: number; + name: string; + signal_identifier: string | null; + is_active: boolean; + paused_at: string | null; + preflight_blocked_at: string | null; + daily_limit_target: number; + delivered_in_month: number; + delivery_days_mask: number; + regions: number[]; +} + +/** Ответ смены источника проекта (change_source, §14.10). */ +export interface ChangeSourceResult { + applies_from?: string | null; + source_locked?: boolean; + source_change_message?: string | null; +} + +export interface FieldSourceDto extends SourceDto { + project: SourceProjectDto | null; +} + +export interface FieldCompetitorDto extends CompetitorDto { + counters: { sources: number; projects_created: number; projects_in_work: number }; + sources: FieldSourceDto[]; +} + +export interface StateDto { + enabled: boolean; + runs: RunDto[]; + prices: { search: string; study: string }; +} + +// ——— API functions ——— + +export async function fetchState(): Promise { + const { data } = await apiClient.get('/api/autopodbor/state'); + return data; +} + +export async function fetchRun(id: number): Promise { + const { data } = await apiClient.get<{ data: RunDto }>(`/api/autopodbor/runs/${id}`); + return data.data; +} + +export async function fetchCompetitor( + id: number, +): Promise<{ competitor: CompetitorDto; sources: FieldSourceDto[]; activeRun: RunDto | null }> { + const { data } = await apiClient.get<{ data: CompetitorDto; sources: FieldSourceDto[]; active_run: RunDto | null }>( + `/api/autopodbor/competitors/${id}`, + ); + return { competitor: data.data, sources: data.sources, activeRun: data.active_run ?? null }; +} + +export async function startSearch(p: { + region_code: number; + examples: string[]; + about_self: string[]; + include_federal: boolean; +}): Promise { + const { data } = await apiClient.post<{ data: RunDto }>('/api/autopodbor/search', p); + return data.data; +} + +export async function startStudy(competitor_id: number): Promise { + const { data } = await apiClient.post<{ data: RunDto }>('/api/autopodbor/study', { competitor_id }); + 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 { + const { data } = await apiClient.post('/api/autopodbor/study/batch', { + competitor_ids: competitorIds, + }); + return data; +} + +export async function startResolve(p: { name: string; region_code: number }): Promise { + const { data } = await apiClient.post<{ data: RunDto }>('/api/autopodbor/resolve', p); + return data.data; +} + +export async function startManualStudy(p: { + competitor_id?: number; + name?: string; + site_url?: string; + directory?: string; + region_code: number; +}): Promise { + const { data } = await apiClient.post<{ data: RunDto }>('/api/autopodbor/manual-study', p); + return data.data; +} + +export async function addManualSource(p: { + competitor_id: number; + raw: string; + force?: boolean; // клиент подтвердил дубль + absorb_source_id?: number; // источник-дубль у прежнего конкурента — убрать +}): Promise { + const { data } = await apiClient.post<{ data: SourceDto }>('/api/autopodbor/sources/manual', p); + return data.data; +} + +export interface LaunchBalance { + topup_rub: number; + [key: string]: unknown; +} + +export interface LaunchSummary { + launched: number; + deferred: number; + balance: LaunchBalance | null; +} + +export interface CreateProjectsResult { + data: Array<{ id: number; name: string; is_active?: boolean }>; + launch: LaunchSummary | null; +} + +export async function createProjects(p: { + source_ids: number[]; + regions: number[]; + daily_limit_target: number; + delivery_days_mask: number; + launch: boolean; +}): Promise { + const { data } = await apiClient.post('/api/autopodbor/projects', p); + // Бэкенд отдаёт { data:[...], launch:{...} }; поддерживаем старый формат (только data.[]) + if (Array.isArray(data)) { + return { data: data as Array<{ id: number; name: string }>, launch: null }; + } + return data; +} + +export async function fetchRunCompetitors(runId: number): Promise { + const { data } = await apiClient.get<{ data: CompetitorDto[] }>(`/api/autopodbor/runs/${runId}/competitors`); + return data.data; +} + +// ——— «Конкурентное поле»: рабочее место (два ящика) ——— + +/** Конкуренты в поле с источниками в работе и счётчиками. */ +export async function fetchField(): Promise { + const { data } = await apiClient.get<{ competitors: FieldCompetitorDto[] }>('/api/autopodbor/field'); + return data.competitors; +} + +// Финальный проход «Почистить поле»: группы-кандидаты на склейку (клиент подтверждает). +export interface DuplicateGroupDto { + reason: string; + competitors: Array<{ id: number; name: string; box?: 'field' | 'proposal'; site_url?: string | null; directory_urls?: string[]; survivor?: boolean }>; +} + +// box — какой ящик чистим (поле/предложения). scope='cross' — кросс-ящик поле↔предложения: +// на экране поля показываем и предложения-дубли карточек поля (бэкенд оставляет группы с ≥1 полем). +export async function fetchDuplicateGroups(box: 'field' | 'proposal' = 'field', scope?: 'cross'): Promise { + const { data } = await apiClient.get<{ groups: DuplicateGroupDto[] }>('/api/autopodbor/field/duplicate-groups', { + params: { box, ...(scope ? { scope } : {}) }, + }); + return data.groups; +} + +export async function mergeCompetitors(competitorIds: number[], name?: string | null): Promise<{ survivor_id: number }> { + const { data } = await apiClient.post<{ survivor_id: number }>('/api/autopodbor/field/merge', { + competitor_ids: competitorIds, + name: name ?? undefined, + }); + return data; +} + +// Журнал слияний: что во что слили + снимок поглощённых. Делает склейку прозрачной и обратимой. +export interface MergeEventDto { + id: number; + survivor_id: number | null; + survivor_name: string; + reason: string | null; + restored: boolean; + created_at: string | null; + absorbed: Array<{ id: number | null; name: string; box?: 'field' | 'proposal' | 'archived' | null }>; +} + +export async function fetchMergeEvents(): Promise { + const { data } = await apiClient.get<{ events: MergeEventDto[] }>('/api/autopodbor/field/merge-events'); + return data.events; +} + +export async function restoreMergeEvent(eventId: number): Promise<{ restored: Array<{ id: number; name: string }> }> { + const { data } = await apiClient.post<{ restored: Array<{ id: number; name: string }> }>( + `/api/autopodbor/field/merge-events/${eventId}/restore`, + ); + return data; +} + +/** Совпавшая фирма-в-поле для карточки актуализации (её текущие сайты/карточки — для показа ссылками). */ +export interface ActualizeMatched { + id: number; + name: string; + sites: string[]; + directory_urls: string[]; +} + +/** Карточка группы «на актуализацию»: находка + на кого похожа + что нового (delta_keys). */ +export interface ActualizeCard extends CompetitorDto { + matched_id: number; + delta_keys: string[]; + matched?: ActualizeMatched; +} + +/** Предложения, разложенные по группам (спека §3): новые / на актуализацию / ранее удалённые. */ +export interface ProposalGroups { + new: CompetitorDto[]; + actualize: ActualizeCard[]; + archived: CompetitorDto[]; +} + +/** Предложения по группам (новые / актуализация / ранее удалённые). Скрытые дубли бэкенд не отдаёт. */ +export async function fetchProposalGroups(): Promise { + const { data } = await apiClient.get<{ groups: ProposalGroups }>('/api/autopodbor/proposals'); + return data.groups; +} + +/** Убрать всю группу «ранее удалённые» одним махом (снова в архив). */ +export async function dismissArchivedProposals(): Promise { + await apiClient.post('/api/autopodbor/proposals/dismiss-archived'); +} + +/** + * Актуализация сайта фирмы поля (спека §6): `add` — добавить новый сайт-источник (старый работает), + * `replace` — заменить (старый в архив; при активном проекте бэкенд вернёт 409 manage_via_project). + * Возвращает id заведённого источника — им сразу открываем форму создания проекта. + */ +export async function actualizeCompetitor( + competitorId: number, + action: 'add' | 'replace', + newSite: string, +): Promise<{ source_id: number }> { + const { data } = await apiClient.post<{ source_id: number }>( + `/api/autopodbor/competitors/${competitorId}/actualize`, + { action, new_site: newSite }, + ); + return data; +} + +/** «Мягко» отклонить находку актуализации: находка → архив, её новый адрес запоминается у фирмы поля. */ +export async function dismissActualizeProposal(findId: number): Promise { + await apiClient.post(`/api/autopodbor/competitors/${findId}/dismiss-actualize`); +} + +export async function setCompetitorBox(id: number, box: Box): Promise { + const { data } = await apiClient.patch<{ data: CompetitorDto }>(`/api/autopodbor/competitors/${id}/box`, { box }); + return data.data; +} + +export async function setSourceBox(id: number, box: Box): Promise { + const { data } = await apiClient.patch<{ data: SourceDto }>(`/api/autopodbor/sources/${id}/box`, { box }); + return data.data; +} + +export interface CompetitorPatch { + name?: string; + description?: string | null; + is_federal?: boolean; + relevance_pct?: number | null; + site_url?: string | null; + directory_urls?: string[]; + elements?: CompetitorElement[]; + box?: Box; +} + +export async function updateCompetitor(id: number, patch: CompetitorPatch): Promise { + const { data } = await apiClient.patch<{ data: CompetitorDto }>(`/api/autopodbor/competitors/${id}`, patch); + return data.data; +} + +export async function deleteCompetitor(id: number): Promise { + await apiClient.delete(`/api/autopodbor/competitors/${id}`); +} + +export interface SourcePatch { + identifier?: string; + phone_kind?: 'real' | 'substitute' | 'unverified' | 'uncertain' | null; + phone_type?: PhoneType; + provenance_url?: string | null; + provenance_label?: string | null; + box?: Box; +} + +export async function updateSource(id: number, patch: SourcePatch): Promise { + const { data } = await apiClient.patch<{ data: SourceDto }>(`/api/autopodbor/sources/${id}`, patch); + return data.data; +} + +export async function deleteSource(id: number): Promise { + await apiClient.delete(`/api/autopodbor/sources/${id}`); +} + +export async function createManualCompetitor(p: { + name: string; + description?: string; + site_url?: string; + directory?: string; + is_federal?: boolean; + relevance_pct?: number | null; + elements?: CompetitorElement[]; +}): Promise { + const { data } = await apiClient.post<{ data: CompetitorDto }>('/api/autopodbor/competitors/manual', p); + return data.data; +} + +/** + * Включить/выключить проект источника через ГОТОВУЮ ручку проектов — + * там все гварды (слепок 18:00 МСК, баланс, сделки, §14.9). + */ +export async function toggleProjectActive(projectId: number, active: boolean): Promise { + await apiClient.patch(`/api/projects/${projectId}/toggle-active`, { is_active: active }); +} + +/** + * Сменить источник проекта (адрес/номер) через ГОТОВУЮ ручку проектов — это и есть + * change_source со всеми гвардами §14.10 (тип источника не меняется). Возвращает + * сообщение о сроках вступления в силу. + */ +export async function changeProjectSource(projectId: number, signalIdentifier: string): Promise { + const { data } = await apiClient.patch(`/api/projects/${projectId}`, { + signal_identifier: signalIdentifier, + }); + return data ?? {}; +} + +/** Настройки проекта (лимит/регионы/дни) — через готовую ручку проектов (слепок §14.9). */ +export async function updateProjectSettings( + projectId: number, + p: { daily_limit_target?: number; regions?: number[]; delivery_days_mask?: number }, +): Promise { + const { data } = await apiClient.patch(`/api/projects/${projectId}`, p); + return data ?? {}; +} diff --git a/app/resources/js/api/billing.ts b/app/resources/js/api/billing.ts index 21659abf..20f94392 100644 --- a/app/resources/js/api/billing.ts +++ b/app/resources/js/api/billing.ts @@ -133,6 +133,27 @@ export async function topup(amountRub: number): Promise { return data; } +/** + * Незавершённая онлайн-оплата за последние сутки (pending/failed), либо null. + * + * Инцидент 14.07.2026: клиент ушёл со страницы оплаты, ЮKassa через час отменила платёж + * на 10 000 ₽ — а кабинет всё это время обещал «баланс обновится автоматически». + * Теперь показываем правду и даём оплатить снова. + */ +export interface LastPayment { + id: number; + status: 'pending' | 'failed'; + amount_rub: string; + failure_reason: string | null; + created_at: string | null; +} + +/** GET /api/billing/last-payment — судьба последней онлайн-оплаты (null = всё в порядке). */ +export async function getLastPayment(): Promise { + const { data } = await apiClient.get<{ data: LastPayment | null }>('/api/billing/last-payment'); + return data.data; +} + /** * Ответ GET /api/billing/balance-status — лёгкий статус баланса для UI префлайта * (Billing v2 Spec C §3.6): питает баннер заморозки + индикатор ёмкости. diff --git a/app/resources/js/api/deals.ts b/app/resources/js/api/deals.ts index ffd5cd1b..046d89d2 100644 --- a/app/resources/js/api/deals.ts +++ b/app/resources/js/api/deals.ts @@ -171,6 +171,14 @@ export interface ApiDeal { project_signal_identifier?: string | null; project_sms_keyword?: string | null; project_sms_senders?: string[] | null; + // Автоподбор (2026-07-08): конкурент + источник для колонок «Сделок». + competitor_id?: number | null; + competitor_name?: string | null; + source_identifier?: string | null; + source_signal_type?: string | null; + source_project_id?: number | null; + source_is_active?: boolean | null; + source_extra_count?: number; } export interface ApiDealEvent { diff --git a/app/resources/js/components/billing/AutopodborServicesPanel.vue b/app/resources/js/components/billing/AutopodborServicesPanel.vue new file mode 100644 index 00000000..f828316a --- /dev/null +++ b/app/resources/js/components/billing/AutopodborServicesPanel.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/app/resources/js/components/billing/BalanceCapacityIndicator.vue b/app/resources/js/components/billing/BalanceCapacityIndicator.vue index 2266b226..1ff78fea 100644 --- a/app/resources/js/components/billing/BalanceCapacityIndicator.vue +++ b/app/resources/js/components/billing/BalanceCapacityIndicator.vue @@ -38,7 +38,12 @@ const balanceFormatted = computed(() =>